Docker Networking Explained: Fixing "Container Can't Reach Container" Issues
You spin up a Node API and a Postgres database in Docker. Both start without errors. You try running a migration from your app container to your database container, and it fails outright:
Error: connect ECONNREFUSED 127.0.0.1:5432
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1494:16)
You double-check with docker ps. Both containers are running. You can even query the database from your host machine using DBeaver or psql. Yet, when your backend container tries to ping the database by its container name, it hits a wall: getaddrinfo ENOTFOUND postgres.
This is the most common failure point for engineers moving from simple, single-container setups to multi-tier microservices. Docker isolates containers inside distinct network namespaces by default. When an application runs inside a container, localhost points strictly to that container's own internal network interface, not your host machine, and certainly not the container next to it.
In this guide, we'll walk through how Docker connects (and isolates) containers, where automatic DNS resolution works and where it fails, how to use docker network inspect to trace packets, and why port bindings trick so many developers into debugging the wrong layer.
1. The Mental Model: Why Localhost Fails
Every standard Docker container gets its own isolated Linux network namespace (unless you explicitly tell it to share one). That namespace brings its own loopback interface (lo), its own routing table, its own firewall rules, and its own virtual Ethernet pair (veth).
Node.js App
127.0.0.1 = Container A
PostgreSQL
127.0.0.1 = Container B
When you tell your API container to connect to 127.0.0.1:5432, it looks for a database server listening on port 5432 inside its own container boundary. Because only your Node process is running there, the kernel immediately returns ECONNREFUSED.
To let containers talk to each other, Docker connects them via a virtual switch called a Bridge Network.
2. Default Bridge vs. User-Defined Bridge: The DNS Trap
This is where most engineers lose hours. Docker has two different styles of bridge networks, and they behave in fundamentally different ways when it comes to resolving hostnames.
Default Bridge (bridge)
Created automatically when Docker installs. Containers attached here can only reach each other by raw IP address or legacy --link flags. No embedded DNS resolution.
User-Defined Bridge
Created via docker network create or automatically by Docker Compose. Includes an embedded DNS server (127.0.0.11) that lets containers resolve each other by container name or network alias.
The Default Bridge Behavior
If you start two containers with plain docker run commands without specifying a network:
docker run -d --name db-store redis:alpine
docker run -it --name test-client alpine sh
Inside the test-client container, try pinging the Redis container by name:
/ # ping db-store
ping: bad address 'db-store'
The ping fails because Docker's default bridge network does not route queries to Docker’s internal DNS daemon. It sends DNS queries straight to whatever nameserver is listed in the host's /etc/resolv.conf (like 8.8.8.8 or your local router), which knows nothing about your local Docker container names.
Fixing It with a User-Defined Bridge
Always create a custom bridge network for containers that need to intercommunicate without Docker Compose:
# 1. Create a custom network
docker network create internal-net
# 2. Attach your containers to this network
docker run -d --name db-store --network internal-net redis:alpine
docker run -it --name test-client --network internal-net alpine sh
Now test the resolution again:
/ # ping -c 2 db-store
PING db-store (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.084 ms
64 bytes from 172.18.0.2: seq=1 ttl=64 time=0.076 ms
On user-defined networks, Docker assigns the IP 127.0.0.11 to its embedded DNS resolver inside the container's namespace. When test-client queries db-store, 127.0.0.11 answers with the internal IP 172.18.0.2.
bridge network in production. Always isolate them in a user-defined network.
3. Docker Drivers Compared: Which One Should You Pick?
Docker provides multiple network drivers. Choosing the wrong one introduces routing dead-ends or unnecessary security exposures.
| Driver | Use Case | DNS Resolution? | Container-to-Container Access |
|---|---|---|---|
| Bridge | Single-host multi-container apps (Default choice) | Yes (on user-defined) | Directly over private subnet (172.x.x.x) |
| Host | Performance-critical apps (eliminates NAT) | No (Uses host DNS) | Via localhost, shares host ports directly |
| Overlay | Multi-host Swarm setups / Nomad clusters | Yes | Encrypted VXLAN tunnels across physical nodes |
| Macvlan / Ipvlan | Legacy apps needing raw physical LAN IPs | Host-dependent | Containers appear as physical devices on your LAN |
| None | Air-gapped batch jobs / security sandboxing | No | Disabled (loopback interface only) |
When should you use the host driver?
Using --network host removes network isolation entirely. The container binds directly to your host's interfaces. If your app listens on port 8080, it binds directly to the host's port 8080.
--network host does not work the way developers expect on Docker Desktop. Because Docker Desktop runs inside a lightweight Linux virtual machine, host binds the container to the VM's network interface, not your Mac or Windows host interface.
4. The Port Confusion: -p vs. EXPOSE
A frequent point of confusion is thinking that ports must be published to the host for other containers to reach them.
Let's clear this up:
EXPOSE(in Dockerfile): Acts as metadata and documentation. It tells developers which ports the image authors intended to be open. It does not publish ports, nor does it create firewall rules.-p 5432:5432(Publish): Maps port 5432 on the host machine to port 5432 inside the container by addingiptables/nftablesNAT rules on the host.
(Browser, DBeaver)
Needs
-p 8080:8080
(Node.js / Python)
Port: 8080
(PostgreSQL)
Port: 5432 (No
-p needed!)
Containers sharing a user-defined network can talk to each other on any port the service is listening on, regardless of whether you used -p or EXPOSE.
If your Node container needs to talk to your PostgreSQL container on port 5432, you do not need -p 5432:5432 on the PostgreSQL container. You only need -p if you want to reach Postgres directly from your host machine (outside of Docker).
-p 3306:3306, -p 6379:6379) to the host in staging or production unless strictly necessary. Keeping them unmapped limits their exposure to the internal Docker bridge network.
5. Docker Compose Network Isolation: Why Cross-Project Routing Fails
By default, Docker Compose prefixes network names with the directory name of your project. This guarantees that running docker compose up in two different directories won't cause container name collisions.
However, this also means containers from two separate compose.yaml files cannot talk to each other out of the box.
Scenario: Separated Frontend and Backend Projects
Imagine you have an authentication service running in one Compose project and an analytics service in another. You want analytics to query auth-service.
Here is how you configure a shared external network between two separate Compose setups:
Step 1: Create the external bridge network manually
docker network create shared-services-tier
Step 2: Configure Project A (auth-stack/compose.yaml)
services:
auth-api:
image: auth-service:latest
container_name: auth-api
networks:
- shared-net
networks:
shared-net:
external: true
name: shared-services-tier
Step 3: Configure Project B (analytics-stack/compose.yaml)
services:
analytics-engine:
image: analytics:latest
environment:
- AUTH_URL=http://auth-api:8080
networks:
- shared-net
networks:
shared-net:
external: true
name: shared-services-tier
Now, both projects join shared-services-tier. The analytics container can query http://auth-api:8080 directly through Docker's internal DNS.
6. Practical Debugging Workflow: A Step-by-Step Runbook
When you encounter a "Connection refused" or "Could not resolve host" error between containers, run through this troubleshooting sequence instead of guessing.
Step 1: Verify Shared Networks
Use docker network inspect to verify that both containers are attached to the exact same network.
docker network inspect app-network
Look at the Containers JSON block in the output:
"Containers": {
"a8f7c9e0b1...": {
"Name": "backend-api",
"IPv4Address": "172.20.0.2/16"
},
"c4e2b1d3a5...": {
"Name": "postgres-db",
"IPv4Address": "172.20.0.3/16"
}
}
If one container is missing from this list, they are not on the same network bridge. Connect them dynamically using:
docker network connect app-network <container_name>
Step 2: Test DNS Resolution from Within the Container
Spawn an ephemeral debugging container attached to the same network using nicolaka/netshoot, a container image packed with network troubleshooting utilities:
docker run --rm -it --network app-network nicolaka/netshoot nslookup postgres-db
If the DNS returns an IP like 172.20.0.3, name resolution works. If it returns ** server can't find postgres-db: NXDOMAIN, your application is querying the wrong hostname or the target container was started with a different name/alias.
Step 3: Test TCP Socket Connectivity
Verify that the target service is actually accepting connections on the expected port:
docker run --rm -it --network app-network nicolaka/netshoot nc -zvw3 postgres-db 5432
Output results:
Connection to postgres-db 5432 port [tcp/*] succeeded!: The network path is clear. The problem is in your application layer (wrong credentials, bad database name, TLS mismatch).nc: connect to postgres-db port 5432 (tcp) failed: Connection refused: The container is reachable, but the process inside is either dead or binding only to127.0.0.1instead of0.0.0.0.
127.0.0.1. Inside a container, that means the server only accepts requests coming from inside its own local loopback. Change your server's bind address from localhost to 0.0.0.0.
7. Common Networking Mistakes to Avoid
Hardcoding Container IP Addresses
Docker does not guarantee IP persistence across restarts. When a container restarts, its DHCP lease on the bridge can change. Always address containers by their service name or container name via internal DNS, never by their 172.x.x.x IP.
Using localhost in Compose Configs
Writing DATABASE_URL=postgres://user:pass@localhost:5432/db in your web app’s environment config will fail. Replace localhost with the Compose service name (e.g., postgres://user:pass@db:5432/db).
Subnet Collisions with Corporate VPNs
Docker allocates bridge subnets from ranges like 172.17.0.0/16 to 172.31.0.0/16. If your corporate VPN routes through the same subnet, traffic to your containers will vanish. Fix this in /etc/docker/daemon.json using the default-address-pools key.
Frequently Asked Questions
How do I reach a service running on the host machine from inside a Docker container?
On Docker Desktop (Mac/Windows), use the special DNS name host.docker.internal. On Linux (Docker Engine 20.10+), add --add-host=host.docker.internal:host-gateway to your docker run command, or define extra_hosts in your Docker Compose file.
Why can't I ping containers using ICMP on my custom bridge?
Some base container images (like minimal Alpine or distroless images) strip out the ping binary or lack raw socket permissions. Always test with nc (netcat), curl, or use an admin container like nicolaka/netshoot instead of relying on ping.
Can two containers on different user-defined bridge networks communicate?
No, not directly. User-defined bridge networks provide network segmentation. If Container A needs to communicate with Container B, you must attach one of the containers to both networks using docker network connect <network_name> <container_name>.
What is the difference between network aliases and container names?
A container name is unique across the entire Docker daemon. A network alias is a scoped hostname valid only within a specific network. Multiple containers can share the same network alias to implement round-robin DNS load balancing inside that bridge.
Why does my container show port 5432 open in `docker ps`, but connections still drop?
docker ps only shows what port Docker is told to route or expose. If the underlying database engine failed during startup (e.g., bad config or failed authentication), the port might be mapped in the Docker routing table while the process itself is not actually accepting TCP sockets.
Wrapping Up
Docker networking issues almost always trace back to three root causes: using the default bridge network expecting automatic DNS resolution, trying to connect to localhost across namespace boundaries, or binding application servers to 127.0.0.1 instead of 0.0.0.0.
Once you standardize on user-defined networks, reference services by name, and keep netshoot on hand for verifying DNS and socket states, diagnosing connection drops becomes a quick, structured process rather than a guessing game.
Comments