Almost every "connection refused" error against a Postgres container comes down to one of two mistakes: the container's port was never published to your host, or you're connecting to localhost from inside a different container, where localhost means that container itself and not Postgres.
This happens because each container gets its own network namespace. The loopback address is scoped per-container, so localhost:5432 resolves to a different place depending on where you run it. Reaching Postgres from your host needs a published port, and reaching it from another container needs Docker's internal name resolution on a shared network. Get those two ideas straight and the rest falls into place.
The fix for both cases is below, plus how to tell which one is biting you when the connection still refuses.
Connecting from the host
Start Postgres with the port published to your host using -p HOST_PORT:CONTAINER_PORT:
1234docker run -d --name pg \-e POSTGRES_PASSWORD=secret \-p 5432:5432 \postgres:18
The -p 5432:5432 flag is the part that matters. It tells Docker to forward traffic from port 5432 on your host into port 5432 in the container. Without it, Postgres is running but unreachable from outside the container.
Now connect with the standard connection string, using localhost as the host:
1postgresql://postgres:secret@localhost:5432/postgres
Or with psql directly, if you have the client installed on your host:
1psql -h localhost -p 5432 -U postgres -d postgres
You'll be prompted for the password (secret), then dropped into the postgres=# prompt. If you don't have a local psql, you don't need one. Run the client that ships inside the container:
1docker exec -it pg psql -U postgres
This runs entirely inside the container, so it needs no published port and no networking at all. It's the fastest way to confirm the database itself is healthy before you start debugging connectivity.
Connecting from another container
This is where most people get stuck. From a second container, localhost points at that container, not at Postgres. You connect using the Postgres container's name as the host, and both containers must share a user-defined network.
12345678910docker network create appnetdocker run -d --name pg --network appnet \-e POSTGRES_PASSWORD=secret \postgres:18docker run --rm -it --network appnet \-e PGPASSWORD=secret \postgres:18 \psql -h pg -U postgres -d postgres
Two things changed from the host example. The host is now pg (the container name) instead of localhost, and there's no -p flag. Container-to-container traffic stays inside the Docker network and never touches the host, so publishing a port is unnecessary. The connection string from an application container looks like this:
1postgresql://postgres:secret@pg:5432/postgres
The user-defined network (appnet) is doing the heavy lifting here. Docker runs an embedded DNS (Domain Name System) server that resolves container names to IPs, but only on user-defined networks. The default bridge network has no name resolution, so psql -h pg there fails with "could not translate host name" rather than a refused connection. Always create a network, or use Compose, which creates one for you. Docker's own PostgreSQL networking guide covers the same setup if you want the canonical reference.
A typical Compose setup ties both cases together:
12345678910111213141516171819services:db:image: postgres:18environment:POSTGRES_PASSWORD: secretports:- "5432:5432"healthcheck:test: ["CMD-SHELL", "pg_isready -U postgres"]interval: 5stimeout: 5sretries: 5app:image: myappdepends_on:db:condition: service_healthyenvironment:DATABASE_URL: "postgresql://postgres:secret@db:5432/postgres"
Inside the app container, the database host is db (the service name) on port 5432. From your host machine, the same database is reachable at localhost:5432 because of the ports mapping. Same database, two different addresses, depending on which side of the port mapping you're standing on.
Exposed ports vs published ports
These two are constantly confused, and the confusion is the direct cause of a lot of refused connections.
Exposing a port (the EXPOSE instruction in a Dockerfile, or expose: in Compose) is documentation. It records which port the container listens on and nothing more. It does not open the port to your host. The official Postgres image already includes EXPOSE 5432, which is why you'll see the port listed in docker inspect, but that line alone never made the database reachable from outside.
Publishing a port (-p on the command line, or ports: in Compose) is what actually creates the forwarding rule. Docker programs the host's networking so traffic arriving on the host port is routed into the container. This is the flag that makes localhost:5432 work from your machine.
The practical rule: containers on the same user-defined network can reach each other on any listening port regardless of whether it's exposed or published. So a database that only ever talks to other containers needs neither EXPOSE nor -p, just a shared network. Publishing the port is only for reaching Postgres from the host or from outside Docker entirely.
Common causes of "connection refused"
When the connection still fails, work through these in order. Start by confirming the container is running and the port is mapped the way you think:
1docker ps
12CONTAINER ID IMAGE COMMAND STATUS PORTS NAMESb3f1a9c40e21 postgres:18 "docker-entrypoint.s…" Up 2 minutes 0.0.0.0:5432->5432/tcp pg
The 0.0.0.0:5432->5432/tcp in the PORTS column confirms the port is published. If that mapping is missing, you forgot -p, and that's your problem.
A second common cause is connecting before Postgres is ready. On first launch the entrypoint runs initdb, starts a temporary server that listens only on a local Unix socket (no TCP) to apply init scripts, then restarts the real server. Any external client that connects during this window gets refused. Check the logs for the readiness line:
1docker logs pg | tail -3
12PostgreSQL init process complete; ready for start up.LOG: database system is ready to accept connections
For an application container, don't rely on a bare depends_on. It waits for the container to start, not for Postgres to accept connections. Use a healthcheck with condition: service_healthy as shown above, which polls pg_isready:
1docker exec pg pg_isready -U postgres
1/var/run/postgresql:5432 - accepting connections
A custom config can also bind the server to the wrong interface. The official image sets listen_addresses = '*' so the server accepts connections on all interfaces. If you mount your own postgresql.conf that leaves listen_addresses at the default localhost, the server only listens inside the container and refuses everything from outside. Check that value whenever you supply custom configuration.
Finally, the host port may already be taken. If something else on your machine holds 5432, docker run fails with bind: address already in use. Map a different host port and connect on that one:
12docker run -d --name pg -e POSTGRES_PASSWORD=secret -p 5433:5432 postgres:18psql -h localhost -p 5433 -U postgres
Setting up psql and pgAdmin
For psql, you've already seen the two paths: run it from your host against the published port (psql -h localhost -p 5432 -U postgres), or run the bundled client inside the container with docker exec -it pg psql -U postgres. The second needs no networking and is the better first move when debugging.
For pgAdmin, the correct host depends on where pgAdmin runs. When pgAdmin runs in its own container alongside Postgres, open the Connection tab of a new server and set the host name to pg (the Postgres container name, not localhost), the port to 5432 (the container port), the username to postgres, and the password to secret. Both containers must be on the same user-defined network. Using localhost here is the single most common pgAdmin mistake, because inside the pgAdmin container localhost is pgAdmin, which isn't running a database. When you run pgAdmin as a desktop application on your host instead, the host becomes localhost and the port is whatever you published (5432, or 5433 if you remapped it).
Final thoughts
Once the connection works, the next thing you'll want to see is what Postgres is actually doing under load: slow queries, connection pool saturation, replication lag, and the CPU and memory the container is burning through. Those signals are invisible from a connection string alone, and they're exactly the ones that explain why a healthy-looking database suddenly starts timing out.
Dash0's PostgreSQL integration tracks query execution and database resource usage, and its infrastructure monitoring ties that to container metrics, real-time logs, and distributed traces, so you can correlate a slow query with the request that triggered it and the container it ran in. If you centralize Postgres logs too, trace-aware PostgreSQL logging connects each log line to the query and trace that produced it. Because it's OpenTelemetry-native, you own your data in an open standard rather than a proprietary format.
Start a free trial to monitor your Postgres containers, queries, and the services that depend on them in a single view. No credit card required.