$ cat ~/field-notes/docker-networking-explained.md
Docker Networking: Bridge, Host, Ports, and Container DNS
Most Docker networking bugs come from mixing up three different places: the container, the Docker bridge, and the host.
localhost always means "this network namespace." Inside a container, it means the container—not your laptop and not another container.
That one rule explains half the mystery.
The Default Shape
On a bridge network, each container gets its own network namespace, virtual interface, routing table, and IP address.
Container web Container db
eth0: 172.20.0.2 eth0: 172.20.0.3
│ │
└──── Docker bridge ──────────┘
│
Host machine
Containers on the same user-defined bridge can talk directly. Docker's embedded DNS resolves container or Compose service names to the current container IP.
services:
web:
build: .
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
ports:
- "127.0.0.1:8080:8080"
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
The web container connects to db:5432, not localhost:5432. The database has no published port because only other containers need it.
Container Ports vs Published Ports
These are different concepts:
container listens on 0.0.0.0:8080
host publishes 127.0.0.1:3000 → container:8080
ports:
- "127.0.0.1:3000:8080"
# host address : host port : container port
From the host, open http://127.0.0.1:3000. From another container on the same network, use http://web:8080.
Publishing a port does not change the port the process listens on inside the container.
EXPOSE 8080 in a Dockerfile is documentation and metadata. It does not publish the port to the host.
Why Binding to 127.0.0.1 Breaks Containers
Inside a container:
// Only reachable from this same container.
app.listen(8080, '127.0.0.1');
// Reachable through the container's eth0 interface and published ports.
app.listen(8080, '0.0.0.0');
The host's port forwarding sends traffic to the container interface, not its loopback device. Bind the application to 0.0.0.0 inside the container, then control outside exposure with the host-side publish address and firewall.
Publish Conservatively
These mappings have very different exposure:
ports:
# Available only from the Docker host.
- "127.0.0.1:5432:5432"
# Binds all host interfaces unless the daemon is configured otherwise.
- "5432:5432"
Do not publish a database merely so another container can reach it. Services on the same Compose network already communicate using container ports.
Publishing to all interfaces can expose development databases, Redis, and debug servers to the local network or internet. Bind explicitly to loopback when remote access is not required.
Use Names, Not Container IPs
Container IP addresses are implementation details. They change when containers are recreated.
# Useful for debugging, bad for configuration.
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' app-db-1
Use the stable service name db. Docker DNS returns the current address. This also makes scaled services possible because the name can resolve according to the runtime's service discovery behavior.
Reaching the Host
Sometimes a container needs a development service running on the host. localhost will not work because it points back into the container.
Docker environments commonly provide a host gateway name such as host.docker.internal. On Linux setups where it is not automatic, map it explicitly:
services:
web:
extra_hosts:
- "host.docker.internal:host-gateway"
Then connect to host.docker.internal:PORT. The host service must listen on an address reachable from the bridge; if it binds only to host loopback, platform behavior determines whether the gateway can reach it.
For production, avoid dependencies that reach back through the container host. Put services on explicit networks or use platform service discovery.
Network Modes
User-Defined Bridge
The default choice for a single Docker host. It provides isolation and DNS by service name.
docker network create app-net
docker run --network app-net --name db postgres:17
docker run --network app-net --name api my-api
Host Mode
The container shares the host network namespace. There is no separate container IP and no -p forwarding.
docker run --network host my-server
Host mode reduces isolation and creates direct port conflicts. It can be useful for specialized networking tools or performance testing, but it is rarely needed for normal web applications.
None
The container gets loopback only:
docker run --network none offline-job
Useful for jobs that should have no network access, though filesystem mounts and other capabilities still need their own restrictions.
Separate Frontend and Backend Networks
Compose can limit which services share a network:
services:
proxy:
image: nginx
networks: [frontend]
ports: ["80:80"]
api:
build: .
networks: [frontend, backend]
db:
image: postgres:17
networks: [backend]
networks:
frontend: {}
backend:
internal: true
The proxy cannot connect directly to the database. The database has no route to external networks through the internal bridge. This is useful defense in depth, not a substitute for authentication.
Debug From the Correct Namespace
First confirm the process is listening inside the container:
docker exec web ss -lntp
docker exec web curl -v http://127.0.0.1:8080/health
Then test service discovery and the container-to-container path:
docker exec web getent hosts db
docker exec web sh -c 'nc -vz db 5432'
Finally inspect the host mapping:
docker port web
docker inspect web --format '{{json .NetworkSettings.Ports}}'
curl -v http://127.0.0.1:3000/health
If it works inside the container but not from the host, inspect port publishing and the host firewall. If DNS resolves but the connection is refused, the target process is probably not listening on the expected interface or port.
The Bottom Line
Docker networking is normal networking split across namespaces.
The rules:
- Inside a container,
localhostmeans that container - Use service names and container ports for container-to-container traffic
- Publish ports only for traffic that must enter through the host
- Bind applications to
0.0.0.0inside containers - Bind host publications to
127.0.0.1when remote access is unnecessary - Never hardcode container IP addresses
- Prefer user-defined bridge networks for application stacks
- Debug inside the source container before debugging the host mapping
Keep the three locations—container, bridge, host—separate in your head, and the packet path becomes predictable.