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

March 12, 20266 min. read

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
SymptomLikely CauseFix
Connection refusedService listening on localhost instead of 0.0.0.0Bind to 0.0.0.0 in app config
Name resolution failedContainers on different networksPut them on a shared network
Intermittent timeoutsDNS caching in the applicationLower TTL or disable DNS cache
Can't reach external servicesNetwork set to internal: trueUse 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.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch