W czwartek po południu ktoś dopisuje do compose.yaml drugą usługę, odpala stos i widzi w logach ECONNREFUSED. Aplikacja Node.js wstaje, baza „też wstaje”, a mimo to pierwsze zapytanie pada. Po restarcie raz działa, raz nie. Po docker compose down znikają testowe rekordy. To nie jest pechowy dzień Dockera. To zwykle kilka drobnych założeń, które na hoście są prawdziwe, a wewnątrz kontenera już nie.
Lokalne środowisko deweloperskie ma być nudne w dobrym sensie: ten sam plik, ta sama sieć, ta sama baza, ten sam sposób startu na każdym laptopie. Poniżej pięć błędów, które ten spokój psują, oraz układ dwóch usług — aplikacji Node.js i PostgreSQL — który te błędy zamyka zanim zdążą wejść do repozytorium.
Błąd 1: Próba łączenia się z bazą przez „localhost” zamiast sieci Compose
Na hoście localhost oznacza „ten komputer”. W kontenerze oznacza „ten kontener”. To dwa różne światy, nawet jeśli oba siedzą na tym samym laptopie. Aplikacja, która poza Dockerem łączy się z PostgreSQL pod 127.0.0.1:5432, po przeniesieniu do usługi Compose szuka bazy we własnym namespace sieciowym. Tam jej nie ma.
Dlaczego kontener nie widzi bazy pod adresem localhost?
Każdy kontener ma własny stos sieciowy. Proces Node.js i proces postgres nie dzielą pętli zwrotnej, dopóki nie uruchomisz ich w jednym kontenerze — a tego właśnie nie chcesz robić. Compose domyślnie tworzy sieć projektu i podpina do niej wszystkie usługi. W tej sieci nazwa usługi jest nazwą DNS. Jeśli baza nazywa się db, hostem połączenia z perspektywy aplikacji jest db, a portem port nasłuchu wewnątrz kontenera bazy, zwykle 5432, nie port zmapowany na hosta.
Mapowanie ports w stylu "5432:5432" otwiera drzwi z laptopa do kontenera. Nie tworzy skrótu z jednego kontenera do drugiego przez localhost. Stąd klasyczny objaw: klient SQL na hoście łączy się bez problemu, a ten sam connection string w Node.js w kontenerze kończy się odmową połączenia.
Jak to rozpoznać i co z tego wynika
Rozpoznanie jest dość ostre. Ta sama aplikacja, uruchomiona node server.js na hoście, widzi bazę. Po docker compose up w logach pojawia się ECONNREFUSED 127.0.0.1:5432 albo timeout do adresu, który działał wczoraj w .env skopiowanym ze starego projektu. Czasem ktoś „naprawia” to wystawianiem bazy na 0.0.0.0 i wpisaniem IP mostka Dockera. Działa do następnej zmiany sieci Wi-Fi.
Skutek uboczny to niepotrzebne mapowanie portów na zewnątrz. Do pracy samej aplikacji port bazy na hoście nie jest potrzebny. Przydaje się tylko wtedy, gdy chcesz podłączyć klienta graficznego z laptopa. Jeśli go nie używasz, zostawiasz otwarte drzwi bez zysku.
Gdy usługi korzystają z domyślnej sieci Compose, aplikacja łączy się z inną usługą po jej nazwie, na przykład hostem db, bez ręcznego definiowania links. [4]
Lepszy układ adresów
W kodzie trzymaj host, port, użytkownika, hasło i nazwę bazy w zmiennych. W Compose ustaw je tak, by hostem była nazwa usługi:
services:
app:
environment:
DATABASE_URL: postgres://app:app@db:5432/appdb
db:
image: postgres:16
Na hoście, gdy odpalasz Node bez kontenera, DATABASE_URL może wskazywać localhost i opublikowany port. W kontenerze — nazwę usługi. Dwa konteksty, dwa zestawy zmiennych, zero magii w kodzie.
| Skąd łączysz się z bazą | Host | Który port |
|---|---|---|
| Inna usługa w tym samym Compose | nazwa usługi, np. db | port wewnątrz kontenera |
| Proces na laptopie (klient SQL, Node poza Dockerem) | localhost | port z lewej strony ports |
Kontener aplikacji przez localhost | nie działa | własna pętla zwrotna kontenera, nie hosta |
Wyjątek, o którym łatwo zapomnieć: network_mode: host na Linuksie rzeczywiście współdzieli sieć z hostem, więc localhost nagle „działa”. Na Docker Desktop dla macOS i Windows ten tryb nie zachowuje się tak samo. Konfiguracja, która przechodzi tylko na jednej maszynie w zespole, nie jest lokalnym środowiskiem. Jest loterią.
Błąd 2: Iluzja kolejności startu, czyli poleganie na samym „depends_on”
depends_on bez warunku zdrowia mówi Compose: najpierw utwórz i uruchom kontener bazy, potem kontener aplikacji. Uruchomiony kontener to proces, który dostał PID. PostgreSQL w tym momencie dopiero czyta konfigurację, odtwarza pliki i otwiera gniazdo. Node.js, który w pierwszym tiknięciu robi pool.connect() albo odpala migracje, trafia w zamknięte drzwi.
Dlaczego startujący proces bazy to za mało dla Node.js?
Aplikacje w Node rzadko czekają cierpliwie. Sterownik rzuca błąd, proces kończy się kodem niezerowym, a polityka restartu — jeśli ją ustawisz — zapętla start. Bez restartu stos „wstaje”, ale API jest martwe do ręcznego docker compose restart app. Na szybkim dysku SSD wyścig bywa wygrany. Na wolniejszym laptopie albo przy pierwszym init wolumenu — przegrany. Stąd wrażenie, że „u mnie działa”.
Sam depends_on nie gwarantuje, że PostgreSQL przyjmuje już połączenia. Kolejność tworzenia kontenerów i gotowość protokołu to dwie różne rzeczy. Migracje odpalane w command przed serwerem HTTP są szczególnie wrażliwe: jeden nieudany start czyści nadzieję, ale nie czyści częściowo zaaplikowanego stanu, jeśli skrypt nie jest idempotentny.
Objawy wyścigu
Log aplikacji: connect ECONNREFUSED db:5432 albo komunikat sterownika o zamkniętym gnieździe, pojawiający się tylko przy zimnym starcie. Po kilku sekundach ręczne wejście do kontenera i ponowne zapytanie działa. Czasem stos wstaje za pierwszym razem, czasem za trzecim. To nie flaky test. To brak warunku gotowości.
Drugie rozpoznanie: docker compose ps pokazuje bazę jako running, zanim pg_isready zwróci sukces. Status kontenera i status silnika nie są synonimami.
Warunek service_healthy i pg_isready
W Compose aplikacja może czekać na bazę przez depends_on z warunkiem condition: service_healthy, a gotowość PostgreSQL sprawdza się poleceniem pg_isready w healthcheck. [2]
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 10
app:
depends_on:
db:
condition: service_healthy
pg_isready sprawdza, czy postmaster przyjmuje połączenia. Nie sprawdza, czy Twoja migracja ma sens, ani czy schemat jest aktualny. To wystarcza, żeby nie walić w zamknięte gniazdo. Dłuższą inicjalizację — duży dump, rozszerzenia — lepiej obsłużyć osobnym krokiem, nie przez wydłużanie retries w nieskończoność.
W kodzie i tak przyda się krótki retry przy starcie puli. Healthcheck zamyka wyścig między kontenerami. Nie zastępuje obsługi chwilowej niedostępności, gdy ktoś zrestartuje tylko usługę db w trakcie pracy.
Błąd 3: Znikające rekordy i brak trwałego wolumenu dla plików bazy
Kontener jest wymienny. Dane, których nie wyniesiesz na wolumen, żyją w jego zapisywalnej warstwie. docker compose down usuwa kontenery. Przebudowa obrazu aplikacji tego nie rusza, ale usunięcie kontenera bazy bez wolumenu kasuje pliki silnika. Następny up tworzy pusty klaster. Seed wraca. Testowe konta znikają. Ktoś oskarża migracje.
Co się dzieje z danymi, gdy kontener zostaje zatrzymany lub przebudowany?
docker compose stop i zwykły restart zwykle zostawiają warstwę kontenera, więc dane „jakby” trwają — dopóki nikt nie zrobi down, rm albo nie zmieni czegoś, co wymusi odtworzenie kontenera. To złudna trwałość. Wystarczy nowy członek zespołu, który sprząta wiszące kontenery, i lokalna baza jest pusta.
Nazwany wolumen Compose przechowuje dane poza cyklem życia kontenera i można go zamontować w katalogu danych bazy. [3] Ścieżka tego katalogu zależy od obrazu i jego dokumentacji. Dla oficjalnego obrazu PostgreSQL najczęściej jest to /var/lib/postgresql/data, ale inny obraz lub inna wersja mogą używać innej lokalizacji. Zanim przypniesz wolumen, sprawdź VOLUME i opis obrazu, którego naprawdę używasz.
Jak poznać, że wolumenu brakuje
Po docker compose down i ponownym up tabele są puste albo w ogóle nie istnieją, choć wczoraj wieczorem seed przeszedł. docker volume ls nie pokazuje wolumenu projektu. W compose.yaml usługa db nie ma sekcji volumes. Czasem wolumen jest, ale podpięty pod zły katalog — pliki lądują obok, a silnik dalej pisze do warstwy kontenera.
Bind mount katalogu z hosta na dane PostgreSQL bywa kuszącą „naprawą”, bo widać pliki w Eksploratorze. Na macOS i Windows wydajność i uprawnienia często gryzą się z procesem bazy. Do lokalnego stanu nazwanego wolumenu zwykle wystarcza. Bind zostaw na dumpach SQL, które chcesz wersjonować albo przekazać komuś jako plik.
Przypięcie nazwanego wolumenu
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Ten fragment zakłada oficjalny obraz i jego typowy katalog. Jeśli dokumentacja obrazu podaje inną ścieżkę, użyj jej. Inaczej wolumen będzie pustą szufladą obok prawdziwych plików.
docker compose down -v usuwa nazwane wolumeny projektu. To świadome sprzątanie, nie codzienna komenda. Warto ją oddzielić od zwykłego zatrzymania, choćby komentarzem w README zespołu: bez -v stan zostaje, z -v zaczynasz od zera.
Dobrym uzupełnieniem tego tematu jest także poradnik: Jak pisać kod odporny na ataki: praktyczne wzorce w popularnych językach.
Zmiana hasła POSTGRES_PASSWORD po pierwszym init nie przepisuje hasła w już zainicjowanym klastrze. Obraz ustawia użytkownika przy tworzeniu katalogu danych. Pusty wolumen plus nowe hasło działa. Stary wolumen plus nowe hasło w pliku — nie. Objaw to „nagle” błąd uwierzytelnienia po zmianie .env, choć healthcheck z pg_isready jest zielony, bo gotowość gniazda nie sprawdza Twojego hasła aplikacyjnego.

Błąd 4: Zepsuty cykl developerski przez błędny montaż kodu i katalogu node_modules
Obraz buduje zależności w Linuksie kontenera. Bind mount katalogu projektu z hosta nakłada na /app drzewo z laptopa, łącznie z node_modules z Windowsa albo macOS. Natywne moduły skompilowane na hoście nie pasują do glibc w kontenerze. Serwer albo nie wstaje, albo wstaje bez nasłuchu plików, bo komenda w obrazie to node dist/server.js, a nie proces w trybie watch.
Problem nadpisywania zależności systemowych przez bind mount z hosta
Kolejność jest ważna. Najpierw montujesz katalog roboczy, potem anonimowy wolumen na /app/node_modules. Drugi montaż zasłania katalog zależności z hosta i zostawia ten, który powstał w obrazie albo przy npm ci wewnątrz kontenera. Bez tej zasłony każde npm install na laptopie potrafi zepsuć kontener, a każde npm ci w kontenerze — zepsuć hosta, jeśli systemy się różnią.
Hot reload wymaga dwóch rzeczy naraz: widocznego kodu źródłowego (bind mount) i procesu, który ten kod obserwuje (node --watch, tsx watch, nodemon albo dev server frameworka). Sam mount nic nie przeładowuje. Sam watch bez mountu widzi tylko pliki w obrazie, czyli stan z chwili docker build.
Objawy i prostsza korekta
Zapisujesz .js albo .ts, a w logach cisza. Albo odwrotnie: logi krzyczą o invalid ELF header, ERR_DLOPEN_FAILED albo o module skompilowanym pod inną architekturę. Trzeci wariant: na Macu watch „nie widzi” zapisu, bo zdarzenia systemu plików nie przechodzą przez wirtualizację. Wtedy pomaga polling w narzędziu watch, nie kolejny restart całego stosu.
services:
app:
build:
context: .
target: dev
command: npm run dev
volumes:
- .:/app
- /app/node_modules
environment:
CHOKIDAR_USEPOLLING: "true"
CHOKIDAR_USEPOLLING włączaj tylko tam, gdzie watch oparty o zdarzenia naprawdę milczy. Na Linuksie zwykle nie jest potrzebny i tylko podnosi zużycie CPU. Anonimowy wolumen na node_modules nie jest backupem zależności. Po zmianie package-lock.json przebuduj obraz albo uruchom instalację w kontenerze. Inaczej zasłonięty katalog zostanie przy starej wersji, a host będzie miał nową — i znowu „u mnie działa”.
Nie montuj całego projektu na katalog, w którym Dockerfile robi WORKDIR, a potem licz, że COPY z buildu przetrwa. Bind mount wygrywa w runtime. To, co weszło do warstw obrazu, zostaje pod spodem, niewidoczne.
Błąd 5: Wrażliwe dane na wierzchu i brak separacji konfiguracji
Hasło wpisane w compose.yaml obok obrazu wygląda niewinnie, dopóki plik nie trafi do publicznego repozytorium albo do zrzutu ekranu w tickecie. Lokalne hasło app nie jest sekretem produkcyjnym, ale ten sam nawyk później wkleja prawdziwe dane. Drugi problem jest przyziemniejszy: dwie osoby w zespole chcą innych portów, innej nazwy bazy, innego poziomu logów. Plik Compose śledzony przez Git nie jest dobrym miejscem na te różnice.
Dlaczego wpisywanie loginów i haseł bezpośrednio do compose.yaml to pułapka?
Plik Compose opisuje kształt stosu: usługi, sieci, wolumeny, healthcheck. Wartości, które zmieniają się między maszynami, należą do środowiska. Compose czyta plik .env z katalogu projektu i podstawia składnię ${POSTGRES_PASSWORD}. Jawny string POSTGRES_PASSWORD: super-tajne w pliku śledzonym przez Git to sygnał, że granica się rozmyła.
Plik .env z hasłem lokalnym nie jest równoważny bezpiecznemu zarządzaniu sekretami w środowisku produkcyjnym. Leży na dysku w plaintext, często bez szyfrowania, czasem z prawami zbyt szerokimi. Do laptopa, przy haśle używanym tylko przez kontener na Twojej maszynie, to akceptowalny kompromis. Do wdrożenia — nie. Konfiguracja lokalna Compose sama w sobie nie jest konfiguracją gotową na produkcję: brakuje tam polityki sekretów, limitów, sieci zewnętrznej, kopii zapasowych i tego, kto ma prawo czytać zmienne.
Jak to zobaczyć w repozytorium
W historii Gita widać POSTGRES_PASSWORD, DATABASE_URL z użytkownikiem i hasłem albo klucz API „tylko do sandboxa”. .env nie jest w .gitignore. Nowy clone nie startuje, bo brakuje zmiennych, a nikt nie zostawił .env.example bez sekretów — same nazwy i puste wartości albo oczywiste lokale typu app.
Oficjalny układ lokalny dla Node.js to właśnie dwie usługi w compose.yaml: aplikacja i PostgreSQL, z parametrami połączenia przekazanymi do aplikacji. [1] Parametry mogą pochodzić ze środowiska, nie muszą być wklejone na stałe.
Krok po kroku: kompletny, bezbłędny plik compose.yaml w praktyce
Pięć poprawek składa się w jeden plik. Poniższy układ jest lokalnym środowiskiem developerskim: usługa app i usługa db, bez haseł wpisanych na stałe i bez pretensji do konfiguracji produkcyjnej.
1. Zostaw sekrety poza plikiem Compose
W katalogu projektu trzymaj .env w .gitignore oraz .env.example z samymi nazwami. Compose podstawi zmienne przy starcie.
POSTGRES_USER=app
POSTGRES_PASSWORD=app
POSTGRES_DB=app
DATABASE_URL=postgres://app:app@db:5432/app
Hasło app jest tu umownym lokalnym ciągiem, nie wzorcem na produkcję. W repozytorium commituj tylko .env.example.
2. Złóż obie usługi w compose.yaml
services:
app:
build:
context: .
target: dev
command: npm run dev
ports:
- "3000:3000"
environment:
DATABASE_URL: ${DATABASE_URL}
CHOKIDAR_USEPOLLING: ${CHOKIDAR_USEPOLLING:-false}
volumes:
- .:/app
- /app/node_modules
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
pgdata:
Host w DATABASE_URL to nazwa usługi, db. Aplikacja czeka na warunek service_healthy, nie na sam start kontenera. Nazwany wolumen pgdata jest podpięty pod katalog danych oficjalnego obrazu PostgreSQL 16; inny obraz może wymagać innej ścieżki z jego dokumentacji. Bind .:/app pokazuje kod źródłowy, a anonimowy wolumen zasłania /app/node_modules z kontenera. CHOKIDAR_USEPOLLING zostaje wyłączony, dopóki watch na Twoim systemie naprawdę nie milczy.
3. Uruchom stos i sprawdź, czy poprawki trzymają się razem
Po zapisaniu pliku stos wstaje jedną komendą: docker compose up --build. [1] Flaga --build jest potrzebna po zmianie Dockerfile albo lockfile’a, nie przy każdym zapisie pliku .js.
Log aplikacji powinien pokazać nasłuch, a nie ECONNREFUSED na localhost. docker compose ps dla db powinno pokazać stan healthy, zanim proces Node otworzy pulę. Zapis w tabeli ma przetrwać docker compose down i kolejne up. Znika dopiero po świadomym docker compose down -v.
Checklista przed pierwszym commitem
DATABASE_URLwskazuje hostdb, nielocalhost.depends_onmacondition: service_healthy, a healthcheck wołapg_isready.- Dane bazy siedzą na nazwanym wolumenie, pod ścieżką z dokumentacji obrazu.
- Kod jest zbindowany,
node_moduleszasłonięty osobnym wolumenem, komenda to proces dev z watchem. - Hasła i URL są w
.env, wzorzec nazw w.env.example,.envjest w.gitignore. - README odróżnia zwykłe
downoddown -v.
Szybka weryfikacja: jak diagnozować problemy, gdy kontenery nadal nie współpracują
Nawet przy poprawnym pliku konfiguracyjnym wdrożenie na nowej maszynie potrafi zatrzymać się na drobiazgach związanych ze stanem samego Dockera. Zamiast resetować cały system lub wprowadzać losowe zmiany w plikach konfiguracyjnych, warto przejść przez krótką ścieżkę eliminacji anomalii.
1. Zawsze izoluj logi konkretnej usługi
Zbiorczy widok po uruchomieniu stosu często miesza wyjścia obu procesów, przez co błąd inicjalizacji bazy ginie pod powtarzającymi się komunikatami frameworka Node.js. Weryfikację problemu zacznij od odpytania pojedynczej usługi:
docker compose logs db --tail=50
docker compose logs app --tail=50 -f
Jeśli w logach bazy pojawia się komunikat o zablokowanym porcie lub problemie z zapisem w katalogu danych, wiesz od razu, że problem leży po stronie silnika bazy lub uprawnień systemowych, a nie samej aplikacji Node.js.
2. Usuwanie konfliktów po zmianie nazw usług
Jeśli w trakcie pracy zmieniła się nazwa usługi (na przykład z database na db) albo przełączano gałęzie w repozytorium z inną strukturą compose.yaml, w domyślnej sieci mogły pozostać stare kontenery. Powoduje to konflikty nazw lub nieoczekiwane przekierowania ruchu:
docker compose down --remove-orphans
Flaga --remove-orphans czyści kontenery zdefiniowane w poprzednich wersjach pliku, które nie są już częścią aktualnego środowiska, nie ruszając przy tym zadeklarowanych wolumenów z danymi.
3. Zgodność uprawnień użytkownika na Linuksie
Na systemach z jądrem Linux proces w kontenerze domyślnie działa jako root (chyba że w Dockerfile zdefiniowano dyrektywę USER node). Pliki tworzone wewnątrz montowanego katalogu (np. wygenerowane logi czy zaktualizowany lockfile) mogą zyskać uprawnienia roota, uniemożliwiając ich edycję ze zwykłego konta na hoście. Jeśli po uruchomieniu środowiska edytor zgłasza brak uprawnień do zapisu plików w projekcie, zweryfikuj identyfikator użytkownika w kontenerze aplikacji:
services:
app:
user: "${UID:-1000}:${GID:-1000}"
Dopasowanie UID i GID kontenera do uprawnień użytkownika lokalnego eliminuje problem blokowania plików bez konieczności ciągłego wywoływania chmod na hoście.
Pytania od czytelników
Czy port PostgreSQL trzeba publikować, jeśli korzysta z niego tylko aplikacja w Compose?
db, i porcie wewnętrznym 5432. Mapowanie portu jest potrzebne tylko wtedy, gdy chcesz łączyć się z bazą bezpośrednio z laptopa.Co zrobić, gdy zmiana hasła w pliku Compose nie działa dla istniejącej bazy?
Czy healthcheck zastępuje ponawianie połączenia w aplikacji Node.js?
Jak bezpiecznie wyczyścić lokalną bazę przed ponownym seedowaniem?
docker compose down -v tylko wtedy, gdy świadomie chcesz usunąć nazwane wolumeny i wszystkie lokalne dane. Następne uruchomienie utworzy pusty klaster, w którym można ponownie wykonać migracje i seed.

