Docker Compose Postgres connection refused: localhost is not your database
It happens on every project eventually. A new developer clones the repo, runs docker compose up, and thirty seconds later posts a screenshot in the team chat: the API container is in a crash loop, and the log says the database refused the connection. Meanwhile docker compose ps shows the Postgres container up and healthy, and psql from the host connects on the first try.
Nothing is wrong with Postgres. The connection string still says localhost, and inside a container localhost means this container. The app is knocking on its own door and reporting, accurately, that nobody is home.
This is the most common Docker Compose failure I get called about, and it is almost never fixed by editing postgresql.conf, or pg_hba.conf, or by publishing more ports. It is fixed by one word in a connection string, plus a readiness check so it does not come back as a race condition.
in the app container, use the Compose service name as the database hostname, and the container's own port, not the host-mapped one.
- Change
DATABASE_URL=postgresql://app:secret@localhost:5432/appdbtoDATABASE_URL=postgresql://app:secret@db:5432/appdb, wheredbis the service key indocker-compose.yml. - Keep the port at
5432even if you published it on the host as5433:5432. Container-to-container traffic never touches the host mapping. depends_onon its own only orders container start, not database readiness. Add ahealthcheckto the db service andcondition: service_healthyto the app.- Keep a retry loop in the app anyway. Postgres will restart one day while the app is running.
The exact errors: "connection refused" to 127.0.0.1:5432
The wording depends on your driver, but every one of these is the same bug. Note that the host in the message is localhost or 127.0.0.1, which is the giveaway.
Python, psycopg:
psycopg2.OperationalError: connection to server at "localhost" (127.0.0.1), port 5432 failed: Connection refused
Is the server running on that host and accepting TCP/IP connections?
Node, pg:
Error: connect ECONNREFUSED 127.0.0.1:5432
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)
JVM, the Postgres JDBC driver:
org.postgresql.util.PSQLException: Connection to localhost:5432 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.
There is a second, different failure that produces almost the same text but names the service instead:
connection to server at "db" (172.19.0.3), port 5432 failed: Connection refused
That one means your hostname is now correct and the app simply started before Postgres finished booting. Different bug, different fix, and it is covered in Step 4 below.
Why localhost means nothing useful inside a container
Every container gets its own network namespace. That namespace has its own loopback interface, its own routing table, and its own set of listening sockets. When your API container opens a socket to 127.0.0.1:5432, the kernel looks only at the sockets bound inside that container's namespace. Your API container is not running Postgres, so nothing is bound there, and the kernel answers with a TCP RST. Your driver translates that to "connection refused".
That is the whole mechanism. "Connection refused" is not Postgres rejecting you. It is the operating system telling you that no process is listening at that address at all.
Docker Compose does give you a way to reach the sibling container: it creates a project network (by default named <project>_default) and attaches every service to it. Inside that network, Docker runs an embedded DNS resolver at 127.0.0.11, and each service is resolvable by its service key from the YAML. So if your Compose file has a service called db, then db is a real, resolvable hostname from any other service in that project.
The second half of the confusion is the ports: key. ports: ["5433:5432"] publishes the container's port 5432 on the host's port 5433 so you, on your laptop, can run psql -h localhost -p 5433. It has nothing to do with container-to-container traffic. Two containers on the same network talk directly on the internal port, and they can do so even with no ports: key at all. In production you usually want exactly that: no published database port.
| Connecting from | Host to use | Port to use |
|---|---|---|
| Another Compose service | the service key, e.g. db | 5432 (internal) |
| Your laptop / a GUI client | localhost | the left side of ports:, e.g. 5433 |
| Inside the db container itself | localhost or the unix socket | 5432 |
| A container reaching a DB on the host | host.docker.internal (needs extra_hosts on Linux) | the host's port |
The fix, step by step
Step 1: Find every place the hostname is set
grep -rn "localhost\|127.0.0.1" --include="*.env" --include="*.yml" --include="*.yaml" --include="*.py" --include="*.ts" .
You are looking for a database URL in .env, in the Compose file's environment: block, in a settings module, and (the one that bites people) in a committed .env.example that somebody copied. If the app reads the URL from the environment, confirm what it actually receives:
docker compose config | grep -A5 environment
docker compose config prints the fully resolved file after variable interpolation and .env loading. If the value there is wrong, nothing you do inside the app will help.
Step 2: Point the app at the service name
services:
api:
build: .
environment:
DATABASE_URL: postgresql://app:secret@db:5432/appdb
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 10
start_period: 20s
volumes:
pgdata:
Note what is not there: no ports: on db, no networks: block, no links:. Compose puts both services on the project's default network automatically. Adding networks by hand is only necessary when you deliberately want isolation between groups of services.
Step 3: Use the internal port, not the published one
If you do publish the database for local tooling, write it as "5433:5432" so it cannot collide with a Postgres you already have installed on the host, and leave the app's URL on 5432. Mixing these up produces a connection refused that looks identical to the localhost bug and costs another twenty minutes.
Step 4: Make the app wait for readiness, not for start order
Plain depends_on: [db] tells Compose to create and start the db container first. It does not wait for Postgres to finish initialising the data directory, run any startup recovery, and begin accepting TCP connections. On a cold start with an empty volume, that gap is comfortably long enough for your app to attempt a connection, fail, and exit.
The long-form depends_on with condition: service_healthy fixes the cold start. Compose supports three conditions: service_started, service_healthy and service_completed_successfully. Use the second one here, with a real healthcheck on the db service, as shown above.
One honest caveat: on a first-time initialisation the official Postgres image runs a temporary internal server while it creates the database, and a socket-based pg_isready can go green against that temporary server slightly before the real, network-listening one is up. That is why start_period matters, and why you still want the next step.
Step 5: Keep a retry loop in the application
Compose conditions only help at up time. When Postgres restarts six weeks later during a minor version bump, your app needs to survive it on its own. A short bounded retry at startup is about fifteen lines:
import os, time
import psycopg
url = os.environ["DATABASE_URL"]
last = None
for attempt in range(1, 31):
try:
with psycopg.connect(url, connect_timeout=3) as conn:
conn.execute("SELECT 1")
break
except psycopg.OperationalError as exc:
last = exc
print(f"db not ready (attempt {attempt}): {exc}", flush=True)
time.sleep(min(2 * attempt, 10))
else:
raise SystemExit(f"database never became reachable: {last}")
Print the exception. A retry loop that swallows the error is how "connection refused" turns into "password authentication failed" turns into three hours of silence. The same discipline applies to your migrations: see why migrations must be idempotent if a retried startup can run them twice.
Verification
Three commands, in order. First, prove the name resolves from inside the app container:
docker compose exec api getent hosts db
172.19.0.3 db
If getent is missing from a slim image, use whatever runtime is already in there, for example docker compose exec api python -c "import socket; print(socket.gethostbyname('db'))".
Second, prove the health gate is real:
docker compose ps
NAME IMAGE STATUS
myapp-api-1 myapp-api Up 2 minutes
myapp-db-1 postgres:16 Up 2 minutes (healthy)
The (healthy) suffix only appears when a healthcheck exists and is passing. No suffix means Compose has nothing to gate on and your condition: service_healthy is doing nothing.
Third, prove the app's own credentials work end to end:
docker compose exec db psql -U app -d appdb -c "select current_database(), inet_server_addr();"
What people get wrong
"Publish the port and it will work." Adding ports: ["5432:5432"] to the db service changes nothing for container-to-container traffic. It only exposes your database to the host, and on a cloud VM with a permissive firewall, to the internet. I have found unauthenticated Postgres on public IPs this way more than once.
network_mode: host. This makes localhost "work" by destroying the isolation that made Compose worth using, breaks service-name DNS entirely, and behaves differently on Docker Desktop, where the containers do not share the macOS or Windows host's network stack the way they do on Linux. It is a workaround that guarantees the setup will not survive being deployed anywhere.
Editing listen_addresses or setting trust in pg_hba.conf. The official image already listens on all interfaces and already accepts connections from the container network. If you are getting "connection refused", authentication configuration is not involved yet: you never reached a Postgres process. Setting POSTGRES_HOST_AUTH_METHOD=trust to make the error go away turns a networking bug into an unauthenticated database.
Hardcoding the container IP. 172.19.0.3 works right up until the next docker compose down, then silently points at a different container. Names exist for this reason.
Treating restart: always as a wait-for-database strategy. It will eventually succeed, and it will also hide every genuine startup failure behind a crash loop. The same argument I make about systemd Restart=always applies exactly here.
When it is still broken
- Two Compose projects. If the database was started from a different directory or with a different
-pproject name, it is on a different network and the name will not resolve. Check withdocker network inspect $(docker network ls -q -f name=default) | grep -i name, or give the db an explicit external network and join it from both projects. container_nameconfusion. DNS resolves the service key and anyaliases, not necessarily a customcontainer_name. If you setcontainer_name: my-postgresbut the service key isdb, usedb.- The error changed. "password authentication failed for user" or "database appdb does not exist" means networking is now fine and you have moved on to a credentials problem. A very common cause:
POSTGRES_PASSWORDis only applied when the data directory is first created, so changing it in the Compose file does nothing to an existing named volume. Rundocker compose down -von a throwaway dev database, or change the password withALTER ROLE. - The database container is not actually staying up.
docker compose logs dband look fordatabase system is ready to accept connections. If instead you see shared memory or resource errors, that is a different and well-documented problem: "could not resize shared memory segment" in Docker. - It works, then fails under load. Once connections succeed but start failing intermittently, you are usually out of connection slots rather than out of network. Read sorry, too many clients already before raising
max_connections.
If you are still building your mental model of how any of this fits together, the practical on-ramp to Docker covers namespaces, networks and volumes in the order they actually matter. The full Compose file reference is at docs.docker.com.
Frequently asked questions
- What hostname should my app use to reach Postgres in Docker Compose?
- Use the service key from your docker-compose.yml, for example db or postgres, as the hostname, and port 5432. Docker Compose attaches every service in a project to a shared network and runs an embedded DNS resolver that maps service names to container IPs, so postgresql://app:secret@db:5432/appdb works from any other service in the same project.
- Why does connection refused happen even though docker compose ps shows Postgres running?
- Because "connection refused" is the operating system saying nothing is listening at that address, not Postgres rejecting you. If your connection string says localhost, the app is dialling its own container's loopback interface, where no Postgres process exists. The database being healthy in a different container is irrelevant to that lookup.
- Does depends_on wait for Postgres to be ready to accept connections?
- No. Plain depends_on only orders container startup, so your app can start dialling while Postgres is still initialising its data directory. Add a healthcheck to the database service using pg_isready, then use the long form depends_on with condition: service_healthy on the app service. Keep a bounded retry loop in the application as well, because Compose conditions do nothing after the initial start.
- Do I need to publish port 5432 for one container to reach another?
- No. The ports key publishes a container port on the host so tools on your laptop can connect; container-to-container traffic goes directly over the Compose network on the internal port. In production you should usually leave the database with no published port at all, since publishing 5432 on a cloud VM can expose the database to the internet.