@@ -560,7 +560,7 @@ def _find_exposed_port(docker: str, container_names: list[str], default: str) ->
560560# --- Environment health checks ---
561561
562562
563- def _preflight_env_health (config : dict ) -> None :
563+ def _preflight_env_health (config : dict ) -> bool :
564564 """Auto-detect and fix common environment issues before starting.
565565
566566 Each check is non-fatal — we log a warning and attempt to fix.
@@ -632,36 +632,163 @@ def _preflight_env_health(config: dict) -> None:
632632 except OSError as e :
633633 log .warning ("upload_mount %s: %s" , upload_mount , e )
634634
635- # DB/Redis connectivity — for split setups, catch unreachable
636- # DB before the worker spends 30s trying to connect.
637- def _check_port (host : str , port_str : str , label : str ) -> None :
638- if host in ("localhost" , "127.0.0.1" ):
639- return
635+ # DB connectivity — try a real psql query, not just TCP connect.
636+ # ECONNRESET from Postgres looks identical to "unreachable" from
637+ # the worker's perspective. A real query surfaces auth failures,
638+ # SSL issues, pg_hba rejections, and port conflicts clearly (#42).
639+ db_host = config .get ("db_hostname" , "localhost" )
640+ db_port = config .get ("db_port" , "5432" )
641+ db_user = config .get ("db_username" , "postgres" )
642+ db_name = config .get ("db_name" , "immich" )
643+ db_pass = config .get ("db_password" , "" )
644+ psql = shutil .which ("psql" ) or "/opt/homebrew/opt/libpq/bin/psql"
645+ if Path (psql ).exists ():
640646 try :
641- port = int (port_str )
642- except (ValueError , TypeError ):
643- return
647+ result = subprocess .run (
648+ [
649+ psql ,
650+ "-h" ,
651+ db_host ,
652+ "-p" ,
653+ str (db_port ),
654+ "-U" ,
655+ db_user ,
656+ "-d" ,
657+ db_name ,
658+ "-c" ,
659+ "SELECT 1" ,
660+ "-t" ,
661+ "-A" ,
662+ ],
663+ capture_output = True ,
664+ text = True ,
665+ timeout = 5 ,
666+ env = {** os .environ , "PGPASSWORD" : db_pass },
667+ )
668+ if result .returncode != 0 :
669+ err = (result .stderr or "" ).strip ()
670+ log .error ("Postgres connection failed:" )
671+ log .error (
672+ " host=%s port=%s user=%s db=%s" ,
673+ db_host ,
674+ db_port ,
675+ db_user ,
676+ db_name ,
677+ )
678+ if "Connection reset" in err or "ECONNRESET" in err :
679+ log .error (
680+ " Connection was reset — port conflict or auth rejection."
681+ )
682+ log .error (" Is another service using port %s?" , db_port )
683+ log .error (
684+ " Does docker-compose expose the port without 127.0.0.1 prefix?"
685+ )
686+ elif "password authentication failed" in err :
687+ log .error (
688+ " Password rejected. Check DB_PASSWORD matches config.json."
689+ )
690+ elif "Connection refused" in err :
691+ log .error (
692+ " Nothing listening on %s:%s. Is the database running?" ,
693+ db_host ,
694+ db_port ,
695+ )
696+ else :
697+ log .error (" %s" , err .split ("\n " )[0 ] if err else "unknown error" )
698+ log .error ("" )
699+ log .error (
700+ " Worker cannot start without a working database connection."
701+ )
702+ return False # Block startup — worker will crash anyway
703+ except subprocess .TimeoutExpired :
704+ log .warning (
705+ "Postgres connection timed out (host=%s port=%s)" , db_host , db_port
706+ )
707+ except (OSError , subprocess .SubprocessError ):
708+ pass
709+ else :
710+ # No psql available — fall back to TCP connect check
644711 try :
645- with socket .create_connection ((host , port ), timeout = 3 ):
712+ with socket .create_connection ((db_host , int ( db_port ) ), timeout = 3 ):
646713 pass
647- except OSError :
648- log .warning (
649- "%s at %s:%d is unreachable — worker will fail to start." ,
650- label ,
651- host ,
652- port ,
714+ except (OSError , ValueError ):
715+ log .error ("Postgres at %s:%s is unreachable." , db_host , db_port )
716+ log .error (" Is the database container running? Are ports exposed?" )
717+ return False # Block startup
718+
719+ # Redis connectivity — try a real PING if redis-cli is available.
720+ redis_host = config .get ("redis_hostname" , "localhost" )
721+ redis_port = config .get ("redis_port" , "6379" )
722+ redis_cli = shutil .which ("redis-cli" )
723+ if redis_cli :
724+ try :
725+ result = subprocess .run (
726+ [redis_cli , "-h" , redis_host , "-p" , str (redis_port ), "PING" ],
727+ capture_output = True ,
728+ text = True ,
729+ timeout = 5 ,
730+ )
731+ if "PONG" not in (result .stdout or "" ):
732+ err = (result .stderr or result .stdout or "" ).strip ()
733+ log .error (
734+ "Redis connection failed (host=%s port=%s):" , redis_host , redis_port
735+ )
736+ log .error (" %s" , err [:200 ] if err else "no response" )
737+ log .error (" Worker needs Redis for the job queue." )
738+ return False
739+ except (subprocess .TimeoutExpired , OSError , subprocess .SubprocessError ):
740+ try :
741+ with socket .create_connection ((redis_host , int (redis_port )), timeout = 3 ):
742+ pass
743+ except (OSError , ValueError ):
744+ log .error ("Redis at %s:%s is unreachable." , redis_host , redis_port )
745+ return False
746+ else :
747+ try :
748+ with socket .create_connection ((redis_host , int (redis_port )), timeout = 3 ):
749+ pass
750+ except (OSError , ValueError ):
751+ log .error ("Redis at %s:%s is unreachable." , redis_host , redis_port )
752+ return False # Block startup
753+
754+ # Media location subdirectory check — Immich expects these under
755+ # IMMICH_MEDIA_LOCATION. If they're missing, the path is wrong or
756+ # the Docker volume mount is incomplete. Don't auto-create — that
757+ # would hide the real problem (#43).
758+ if upload_mount and Path (upload_mount ).exists ():
759+ expected = [
760+ "upload" ,
761+ "thumbs" ,
762+ "encoded-video" ,
763+ "library" ,
764+ "profile" ,
765+ "backups" ,
766+ ]
767+ missing = [d for d in expected if not Path (upload_mount , d ).exists ()]
768+ if missing :
769+ log .error (
770+ "IMMICH_MEDIA_LOCATION (%s) is missing: %s" ,
771+ upload_mount ,
772+ ", " .join (missing ),
653773 )
774+ log .error ("" )
775+ log .error (
776+ " This directory should contain: upload/, thumbs/, encoded-video/,"
777+ )
778+ log .error (" library/, profile/, backups/" )
779+ log .error ("" )
780+ log .error (" Common causes:" )
781+ log .error (" - IMMICH_MEDIA_LOCATION points to the wrong directory" )
782+ log .error (
783+ " - Docker volume mount only maps a subdirectory (e.g., upload/)"
784+ )
785+ log .error (" instead of the whole media location" )
786+ log .error ("" )
787+ log .error (" Check your docker-compose volumes: the mount should cover" )
788+ log .error (" the entire IMMICH_MEDIA_LOCATION, not just upload/ inside it." )
789+ return False
654790
655- _check_port (
656- config .get ("db_hostname" , "localhost" ),
657- config .get ("db_port" , "5432" ),
658- "Postgres" ,
659- )
660- _check_port (
661- config .get ("redis_hostname" , "localhost" ),
662- config .get ("redis_port" , "6379" ),
663- "Redis" ,
664- )
791+ return True
665792
666793
667794# --- Server management ---
@@ -2616,7 +2743,8 @@ def cmd_start(args):
26162743
26172744 # Environment health checks — auto-detect and fix common issues
26182745 # (ImageMagick HEIC codec, NFS mount, DB/Redis reachability).
2619- _preflight_env_health (config )
2746+ if not _preflight_env_health (config ):
2747+ return
26202748
26212749 # Re-resolve ml_dir every start. Same pattern as the node path
26222750 # resolution above: config["ml_dir"] is a cache that goes stale
0 commit comments