Docker Networking Is Confusing Until You Understand This One Thing
by Eric Hanson, Backend Developer at Clean Systems Consulting
The container that can't connect to anything and you don't know why
Your service container starts. It tries to reach a database at localhost:5432. It gets "connection refused." You know the database is running — you can connect to it from your terminal. You add --network host and suddenly it works, but now some other thing breaks. You try 0.0.0.0 as the hostname. Nothing makes sense.
Docker networking has a reputation for being complicated. The actual model is simple once you understand that "localhost" means something different inside a container than it does on your host, and that Docker's networking is built around this distinction.
The one thing: containers have their own network namespace
Each Docker container (by default) runs in its own network namespace. A network namespace is an isolated network stack with its own loopback interface, routing table, and network interfaces.
This means localhost inside a container refers to the container's own loopback interface — not the host's. If your PostgreSQL server is running on the host (not in Docker), localhost:5432 from inside the container doesn't reach it. The container doesn't know the host exists as a network entity unless you explicitly tell it how to reach the host.
Three separate networking questions:
- Container to container (within Docker)
- Host to container
- Container to host or external networks
Each has a different mechanism.
Container to container: bridge networks
When you create a Docker bridge network and attach containers to it, Docker's DNS resolver gives each container a hostname matching its --name or (in Compose) its service name. Containers on the same network reach each other by name.
docker network create app-network
docker run -d --name postgres --network app-network postgres:16-alpine
docker run -d --name app --network app-network \
-e DB_HOST=postgres \
my-app:latest
Inside the app container, ping postgres resolves to the postgres container's IP on the bridge network. localhost still refers to the container's own loopback — it doesn't resolve to postgres.
In Docker Compose, this happens automatically. Every service in the same Compose project is added to a shared bridge network, reachable by service name.
services:
app:
environment:
DB_HOST: db # 'db' is the service name, resolvable within the Compose network
db:
image: postgres:16-alpine
The db service name is the DNS hostname within the network. Not localhost, not the container's IP, not 0.0.0.0.
Host to container: port mapping
To reach a container from the host, you publish a port:
docker run -p 8080:80 nginx
This maps host port 8080 to container port 80. A request to localhost:8080 on the host is forwarded to port 80 inside the container. Without -p, the container's ports are not accessible from the host (only from other containers on the same network).
Port mapping creates a NAT rule: host:8080 → container:80. The EXPOSE instruction in the Dockerfile is documentation — it doesn't actually publish the port. Publishing requires -p at runtime.
In Compose:
services:
app:
ports:
- "8080:8080" # host_port:container_port
Container to host: the host.docker.internal DNS name
If a container needs to reach a service on the host (a database running natively, another service not in Docker), localhost won't work. Use host.docker.internal:
# From inside a container, reach a service on the host at port 5432
psql postgresql://user:password@host.docker.internal:5432/mydb
host.docker.internal is a special DNS name that Docker resolves to the host's gateway IP from the container's perspective. It's available on Docker Desktop (Mac and Windows) by default. On Linux, add it explicitly:
docker run --add-host=host.docker.internal:host-gateway your-image
Or in Compose:
services:
app:
extra_hosts:
- "host.docker.internal:host-gateway"
host-gateway is a special Compose string that resolves to the host's IP from the container network.
Network drivers: when the default isn't right
Docker has several network drivers. The two you'll use:
bridge (default): containers on the same bridge network can communicate by name. Containers on different bridge networks cannot communicate unless explicitly connected to both. Port publishing creates NAT rules to the host.
host: the container shares the host's network namespace entirely. No isolation, no port mapping needed, localhost inside the container is the host's localhost. Useful for high-throughput networking where NAT overhead matters. Available on Linux only — Docker Desktop on Mac and Windows doesn't support it.
# With host networking, the container and host share the network stack
docker run --network host nginx
# nginx is now accessible on host port 80 directly, no -p needed
none: no network interfaces except loopback. Container is fully isolated from all external network access. Useful for compute-only workloads that shouldn't have any network access.
The Compose network model in detail
Each Compose project gets a default bridge network named {project-name}_default. All services without an explicit networks: key are added to this network.
You can create additional networks to isolate services from each other:
networks:
frontend:
backend:
services:
proxy:
networks:
- frontend
app:
networks:
- frontend
- backend
db:
networks:
- backend # only reachable by app, not by proxy
The proxy service can reach app (shared frontend network) but not db. The app service can reach both. The db service is isolated to the backend network.
This is the principle of least privilege applied to container networking: services can only talk to services they need to talk to.
Practical debugging
When networking behaves unexpectedly:
# List networks and connected containers
docker network ls
docker network inspect app-network
# Check container's network config
docker inspect container-name | jq '.[0].NetworkSettings'
# Test DNS resolution from inside a container
docker exec -it app-container nslookup db
docker exec -it app-container ping db
# Check if a port is reachable from inside a container
docker exec -it app-container wget -qO- http://db:5432 || echo "unreachable"
# See what ports are exposed and mapped
docker port container-name
The most common issues:
- Connecting to
localhostinstead of the service name → fix the URL - Service on wrong network → check
docker network inspectfor connected containers - Port not published → add
ports:mapping - Container to host via localhost → use
host.docker.internal
Understanding which of the three flows you're dealing with (container-to-container, host-to-container, container-to-host) determines which fix applies.