← Back to DevBytes

Docker Compose Networking: Best Practices and Common Pitfalls

Understanding Docker Compose Networking

Docker Compose networking is the system that enables containers defined in a docker-compose.yml file to communicate with each other, the host machine, and external networks. When you run docker compose up, Compose automatically creates a default bridge network for your application stack unless you specify otherwise. This network is isolated from other Compose projects and the host's default bridge network, providing a secure, self-contained communication layer.

Each container joins this default network and becomes reachable by other containers using its service name as a hostname. This built-in DNS resolution is powered by Docker's embedded DNS server at 127.0.0.11, which resolves container names to their internal IP addresses within the Compose network. Understanding how this works—and how to customize it—is essential for building robust, multi-service applications.

Why Docker Compose Networking Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In a microservices architecture, containers rarely operate in isolation. A typical web application stack might include a frontend web server, a backend API, a database, a caching layer, and a message broker. These services need to discover and communicate with each other reliably. Docker Compose networking provides:

Ignoring networking best practices leads to common problems: ports accidentally exposed to the public internet, services unable to connect to databases because of network misconfiguration, DNS caching issues with short-lived containers, and hard-to-debug connectivity failures in production-like environments.

Default Networking Behavior

Consider a basic Compose file with three services:

# docker-compose.yml
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
  
  api:
    image: node:18-alpine
    command: node server.js
    environment:
      - DATABASE_URL=postgres://db:5432/mydb
  
  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_PASSWORD=secret

When you run docker compose up, Docker creates a network named <project_name>_default (where project_name is derived from the directory name unless overridden). All three containers join this network. The api service can reach the db service simply by using the hostname db on port 5432. The web service can proxy requests to api:3000. The ports mapping on web exposes port 8080 on the host, but the db container has no ports exposed externally—it's only accessible within the Compose network.

This default behavior is convenient but can become problematic as stacks grow. A monolith Compose file with 10+ services all on the same default network means any container can potentially talk to any other, which may not be desirable from a security standpoint.

Custom Networks: Explicit Control

For production-grade setups, you should define custom networks explicitly. This gives you control over network drivers, subnet ranges, and which services can communicate with each other.

Creating a Custom Bridge Network

# docker-compose.yml
services:
  web:
    image: nginx:alpine
    networks:
      - frontend
    ports:
      - "8080:80"
  
  api:
    image: node:18-alpine
    networks:
      - frontend
      - backend
    environment:
      - DATABASE_URL=postgres://db:5432/mydb
  
  db:
    image: postgres:15-alpine
    networks:
      - backend
    environment:
      - POSTGRES_PASSWORD=secret

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge

Here, two networks are defined: frontend and backend. The web service can only reach the api service (both are on frontend). The db service is isolated on backend and can only be reached by the api service, which is attached to both networks. This is a classic three-tier architecture pattern: the web layer cannot directly access the database, reducing the attack surface.

Network Aliases for Multiple Endpoints

When a service needs to be reachable under different hostnames, use network aliases:

services:
  api:
    image: node:18-alpine
    networks:
      frontend:
        aliases:
          - api.example.local
          - rest-api
      backend:
        aliases:
          - internal-api

Other containers on the frontend network can now resolve api.example.local or rest-api in addition to the default service name api.

Advanced Networking Configuration

Static IP Assignment

For services that require predictable IP addresses (legacy applications, certain monitoring tools), you can assign static IPs within a custom network's subnet:

networks:
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16
          gateway: 172.28.0.1

services:
  db:
    image: postgres:15-alpine
    networks:
      backend:
        ipv4_address: 172.28.0.10
    environment:
      - POSTGRES_PASSWORD=secret

The ipam (IP Address Management) block lets you define the subnet and gateway. Each service can then receive a fixed ipv4_address. Use this sparingly—service name DNS resolution is almost always preferable.

Using External Networks

Sometimes you need a Compose stack to connect to an existing Docker network created outside of Compose, perhaps to communicate with containers from another project or a shared infrastructure service:

# First, create the network manually:
# docker network create --driver bridge shared-infra

# docker-compose.yml
services:
  app:
    image: myapp:latest
    networks:
      - default
      - shared-infra

networks:
  default:
    driver: bridge
  shared-infra:
    external: true

The external: true flag tells Compose not to create this network but to expect it to already exist. This is powerful for connecting multiple Compose projects or integrating with manually managed infrastructure containers.

Disabling the Default Network

If you want complete control and don't need the automatic default network, you can disable it and rely entirely on custom networks:

services:
  app:
    image: myapp:latest
    networks:
      - custom-net

networks:
  custom-net:
    driver: bridge
  default:
    # This explicitly disables the default network
    driver: null

This ensures no container accidentally relies on the implicit default network.

Port Mapping and Exposure Deep Dive

Understanding the difference between ports and expose is critical. The ports directive maps a container port to a host port (or a specific host IP), making the service accessible from outside the Docker network. The expose directive simply documents that a port is available for inter-container communication within the network—it does not publish the port to the host.

services:
  api:
    image: node:18-alpine
    expose:
      - "3000"           # Informational only, no host mapping
    ports:
      - "127.0.0.1:3000:3000"   # Bind to localhost only
      - "3001:3000"             # Bind to all host interfaces on port 3001

Binding to 127.0.0.1 is a security best practice—it ensures the port is only accessible from the host machine itself, not from the wider network. Use this for databases, admin panels, or any service that shouldn't be internet-facing.

Common Pitfalls and How to Avoid Them

Pitfall 1: "Connection Refused" Between Services

Symptom: Your application logs show connection errors like ECONNREFUSED when trying to reach another service by its hostname.

Common causes and fixes:

Pitfall 2: Port Already in Use

Symptom: docker compose up fails with "port is already allocated" or "address already in use."

Fix: Check what's using the port on the host with lsof -i :PORT or netstat -tulpn. Either stop the conflicting process, or change the host-side port in the Compose file:

ports:
  - "8081:80"   # Change host port from 8080 to 8081

Pitfall 3: DNS Caching Issues with Recreated Containers

Symptom: After restarting a single service with docker compose restart, other services can't resolve it or connect to a stale IP.

Explanation: Docker's internal DNS has a TTL, and some application runtimes cache DNS lookups indefinitely (Node.js dns.lookup uses the system resolver which caches; Python's socket.getaddrinfo behaves similarly).

Fix: Use docker compose down && docker compose up for full network recreation, or implement connection retry logic that re-resolves hostnames on failure. For production, consider using network aliases and ensuring your application resolves names dynamically.

Pitfall 4: Accidentally Exposing Services to the World

Symptom: A database port is unexpectedly reachable from the internet.

Cause: Using ports: - "5432:5432" without a bind address exposes the port on 0.0.0.0 (all interfaces).

Fix: Always bind to localhost unless external access is explicitly required:

ports:
  - "127.0.0.1:5432:5432"

For services that don't need host access at all, simply omit the ports directive and rely on internal networking.

Pitfall 5: Network Name Collisions Across Projects

Symptom: Two different Compose projects interfere with each other's networking.

Cause: If both projects define a network with the same name but external: false (the default), Docker Compose scopes the network name to the project. However, if you use external: true and reference the same network name, containers from different projects can see each other—which may or may not be intended.

Fix: Be explicit about network names and use the name property for custom networks to avoid ambiguity:

networks:
  backend:
    name: project-a-backend
    driver: bridge

Pitfall 6: Using "links" Instead of Networks

Symptom: Compose files using the legacy links directive, which is deprecated and creates implicit dependencies that are hard to debug.

Fix: Remove links entirely and rely on custom networks. All containers on the same network can reach each other by service name automatically.

Pitfall 7: Ignoring Network Mode: host

Symptom: A container using network_mode: host bypasses all Compose networking, making it unreachable by service name from other containers.

Explanation: host mode shares the host's network stack directly. The container binds to host ports and has no isolated IP. It cannot participate in the Compose network's DNS.

Fix: Avoid network_mode: host unless absolutely necessary (e.g., for services that need to listen on many random ports or require low-level network access). If you must use it, other services must connect via localhost and the host port mapping, not via the service name.

Health Checks and Startup Order

Networking is useless if a dependent service isn't ready. Docker Compose provides depends_on with conditions to wait for container health:

services:
  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_PASSWORD=secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
  
  api:
    image: node:18-alpine
    depends_on:
      db:
        condition: service_healthy
    environment:
      - DATABASE_URL=postgres://db:5432/mydb

The condition: service_healthy (available in Compose v3.9+ with Docker Engine 24+) ensures the api container doesn't start until PostgreSQL is actually accepting connections, not just when the container process has started. For older versions, implement retry logic in your application entrypoint script.

Networking in Swarm Mode (Overlay Networks)

When deploying Compose stacks to Docker Swarm (docker stack deploy), bridge networks aren't supported across nodes. You need overlay networks instead:

networks:
  frontend:
    driver: overlay
    attachable: true   # Allows non-swarm containers to attach (useful for debugging)
  backend:
    driver: overlay
    internal: true     # Prevents external traffic from entering this network

The internal: true flag creates a network that cannot be accessed from outside the swarm, providing an extra layer of isolation. Overlay networks enable multi-host communication and include encrypted control plane traffic by default.

Debugging Network Issues

When connectivity problems arise, use these commands to inspect the network state:

# List all networks and see which belong to Compose
docker network ls

# Inspect a specific Compose network
docker network inspect <project_name>_default

# See which containers are attached and their IPs
docker compose ps --format "table {{.Service}}\t{{.Name}}\t{{.Image}}\t{{.Ports}}"

# Enter a container and test DNS resolution
docker compose exec api nslookup db
docker compose exec api ping -c 2 db
docker compose exec api curl -v http://web:80

# View live logs across all services
docker compose logs -f --tail=50

If DNS resolution fails inside a container but works elsewhere, check whether the container is on the correct network with docker inspect <container_id> --format '{{json .NetworkSettings.Networks}}'. A common gotcha is a container attached only to the host network or a different Compose project's network.

Production-Grade Compose Networking Checklist

Conclusion

Docker Compose networking is a deceptively simple system that becomes nuanced as applications scale. The default bridge network with automatic service-name DNS resolution works beautifully for development and small stacks, but production environments demand explicit network segmentation, careful port exposure, and robust startup ordering. By defining custom networks, binding ports to localhost, implementing health checks, and avoiding common pitfalls like legacy links or host networking mode, you can build containerized applications that are both secure and resilient. Remember: the network configuration you write in your Compose file directly shapes the communication topology of your entire application. Invest time in getting it right, and you'll avoid hours of debugging cryptic connection errors down the line.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles