Docker Compose Networking: Making Services Talk in Production
How to properly configure Docker Compose networking for production. Custom networks, DNS resolution, isolation, and troubleshooting.
Jean-Pierre Broeders
Freelance DevOps Engineer
Docker Compose Networking: Making Services Talk in Production
Two containers that can't reach each other. Sounds like a trivial issue, but it comes up surprisingly often. Usually because the default Compose network behaves slightly differently than expected.
The Default Network
Docker Compose creates a bridge network automatically for each stack. All services in the same docker-compose.yml end up on that network and can reach each other by service name. So if there's an api and a postgres service, postgres://postgres:5432 just works from inside the api container. No IP addresses needed.
Sounds great. And for development, it is. But production has a few gotchas.
# Simple setup - everything on one network
services:
api:
image: myapp/api:latest
ports:
- "8080:8080"
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
redis:
image: redis:7-alpine
The problem: every service can talk to every other service. Redis can reach the database. The database can hit the API. There's zero isolation.
Custom Networks for Isolation
The fix is straightforward but often skipped: split traffic into logical networks.
services:
nginx:
image: nginx:alpine
ports:
- "443:443"
networks:
- frontend
api:
image: myapp/api:latest
networks:
- frontend
- backend
worker:
image: myapp/worker:latest
networks:
- backend
postgres:
image: postgres:16
networks:
- backend
redis:
image: redis:7-alpine
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
Nginx sits only on the frontend network, so it can't touch the database directly. The API sits on both networks — it's the bridge. The backend network has internal: true, meaning containers on it have no outbound internet access. Postgres can't call home to anywhere. Exactly what you want.
DNS Resolution and Aliases
A handy feature that doesn't get enough use: network aliases. They let a service have multiple names depending on the network.
services:
api:
image: myapp/api:latest
networks:
frontend:
aliases:
- app-backend
backend:
aliases:
- data-consumer
From nginx, the API is reachable as both api and app-backend. From postgres, as api and data-consumer. Seems like overkill until you're doing a migration. Some legacy service expects the hostname legacy-api? Add an alias and the old config keeps working without changes.
When DNS Resolution Breaks
Docker's built-in DNS works well, but there are pitfalls. The most common: a container starts faster than the service it depends on.
services:
api:
image: myapp/api:latest
depends_on:
postgres:
condition: service_healthy
networks:
- backend
postgres:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
networks:
- backend
depends_on with a health check condition prevents the API from starting before Postgres actually accepts connections. Without the condition, Docker only waits until the container is running — not until the service is ready. Big difference.
Another DNS-related trap: caching. Docker doesn't cache DNS responses aggressively, but applications sometimes do. Java apps are notorious for this. The JVM caches DNS lookups indefinitely by default (or for a very long time). When a container restarts and gets a new IP, the Java app keeps using the old address.
The fix lives in JVM configuration:
-Dsun.net.inetaddr.ttl=30
-Dsun.net.inetaddr.negative.ttl=10
Or in java.security:
networkaddress.cache.ttl=30
networkaddress.cache.negative.ttl=10
Communication Between Compose Stacks
Sometimes services run in separate docker-compose.yml files. A monitoring stack separate from the application stack, for example. They still need to talk to each other.
The approach: an external network shared by both stacks.
# In the application stack
networks:
shared:
name: monitoring-bridge
external: true
backend:
driver: bridge
# In the monitoring stack
networks:
shared:
name: monitoring-bridge
driver: bridge
The monitoring stack creates the network. The application stack references it with external: true. Services on the shared network find each other via DNS, regardless of which Compose file they belong to.
One thing to watch: the network must exist before the application stack starts. Otherwise docker compose up fails with an error about an unknown network. Always start the stack that creates the network first.
Troubleshooting
When containers can't reach each other, a systematic check is the fastest path to a fix.
# List existing networks
docker network ls
# Inspect a specific network
docker network inspect backend
# Check if DNS works from inside a container
docker exec -it api nslookup postgres
# Test connectivity
docker exec -it api nc -zv postgres 5432
| Symptom | Likely Cause | Fix |
|---|---|---|
| Connection refused | Service listening on localhost instead of 0.0.0.0 | Bind to 0.0.0.0 in app config |
| Name resolution failed | Containers on different networks | Put them on a shared network |
| Intermittent timeouts | DNS caching in the application | Lower TTL or disable DNS cache |
| Can't reach external services | Network set to internal: true | Use a separate network for outbound traffic |
Wrapping Up
Networking in Docker Compose doesn't have to be complicated. Split traffic with custom networks, use internal: true where possible, and test connectivity before it becomes a production issue. Five extra minutes during setup saves hours of debugging on a Friday night.
