Container-to-Container Communication #

One of Docker’s main strengths is running many containers that communicate with each other. Container-to-container communication is the foundation of microservices architecture — each service runs in its own container, talks over the network, and can still be maintained independently.

Many developers run containers every day but don’t truly understand how containers find each other, what the different approaches are (network, volume, sidecar), and which fits their case. This article covers container-to-container communication in depth: common patterns, frequent anti-patterns, and best practices for production architectures.

What Is Container-to-Container Communication? #

Container-to-container communication is the mechanism where two or more containers exchange data with each other — whether via HTTP APIs, gRPC, TCP sockets, or other channels. Docker provides several mechanisms for this, each with different trade-offs.

flowchart LR
    A[Container A] -->|HTTP/gRPC/TCP| B[Container B]
    A -.->|via volume| C[(Shared Volume)]
    A -.->|via stdin/stdout| D[Process pipe]
    A -.->|via socket| E[Host socket]

The main approaches:

ApproachMechanismBest for
Network (default)TCP/UDP over a bridgeAPIs, microservices
Internal DNSAutomatic name resolutionService discovery
Shared volumeShared filesystemFile processing, batch
SidecarHelper containerLogging, proxies, sync
Host socketdocker.sock, UNIX socketsManagement tools

The network approach is the most common and most flexible. It’s the main focus of this article.


The most common way: both containers on the same network, talking to each other by hostname.

docker network create app-net

docker run -d --name db --network app-net postgres
docker run -d --name api --network app-net my-api

Now api can access db via db:5432:

docker exec api sh -c 'wget -qO- http://db:5432 || echo "connected"'
sequenceDiagram
    participant A as Container: api
    participant DNS as Docker DNS (127.0.0.11)
    participant B as Container: db

    A->>DNS: gethostbyname("db")
    DNS-->>A: 172.18.0.3
    A->>B: TCP connect db:5432
    B-->>A: Postgres handshake
    A->>B: SQL query
    B-->>A: Response

Network approach characteristics:

  • Automatic discovery — Docker’s internal DNS resolves container names.
  • Isolation — containers on different networks can’t see each other.
  • Performance — layer-2 forwarding over the bridge, low latency.
  • Scalability — easy to add new containers.
Always use user-defined networks for inter-container communication. The default bridge has no internal DNS — you’d have to hardcode IPs, which is fragile. User-defined networks = internal DNS + isolation + scaling-friendly.

Docker Compose Example #

version: "3.9"
services:
  web:
    image: nginx
    networks:
      - frontend
  api:
    image: my-api
    networks:
      - frontend
      - backend
  db:
    image: postgres
    networks:
      - backend
  cache:
    image: redis
    networks:
      - backend

networks:
  frontend:
  backend:
    internal: true

Diagram:

flowchart LR
    subgraph Frontend["frontend network"]
        W[web]
        A[api]
    end
    subgraph Backend["backend network (internal)"]
        A
        D[(db)]
        C[(cache)]
    end
    W --> A
    A --> D
    A --> C

The api container is on two networks at once — a bridge between frontend and backend. This is a common security segmentation pattern: web can talk to api, but not directly to db.


Approach 2: Via Shared Volumes #

For file-based workloads, shared volumes are a more natural approach than networking.

# Create a shared volume
docker volume create shared-data

# Writer container writes to the volume
docker run -d --name writer \
  -v shared-data:/data:rw \
  my-writer

# Reader container reads from the volume
docker run -d --name reader \
  -v shared-data:/data:ro \
  my-reader
flowchart LR
    W[Container: writer] -->|writes file| V[(Volume: shared-data)]
    V -->|reads file| R[Container: reader]

This approach fits:

  • ETL pipelines — extract, transform, load via files.
  • Image processing — input → process → output.
  • Video encoding — upload → encode → output.
  • Log aggregation — log shippers read from a shared directory.
Don’t use shared volumes for persistent data. Shared volumes are for temporary work; important data belongs in named volumes or host bind mounts. A shared volume disappears when the last container mounting it is unmounted.

Approach 3: Via stdin/stdout (Process Pipes) #

For containers that interact directly through stdin/stdout (e.g. CLI tools), Docker supports piping via container orchestration.

# Container A's output becomes container B's input
docker run --rm -i my-generator | docker run --rm -i my-processor

This approach is rarely used in production, but useful for:

  • Quick scripting — chaining several tools.
  • CI/CD steps — piping between containers.
  • One-shot transformations — not long-running services.

Approach 4: The Sidecar Pattern #

A sidecar is a helper container running alongside the main container, providing additional functionality.

flowchart LR
    subgraph Pod["Application Pod"]
        App[App Container]
        Side[Sidecar Container]
    end
    App <-.->|localhost| Side

Common sidecar examples:

  • Log shippers — read logs from a shared volume, send to central logging.
  • Metrics exporters — read metrics from localhost, expose to Prometheus.
  • Service mesh proxies — Envoy/Linkerd as sidecars for mTLS.
  • File sync — sync files from the container to S3.
# Kubernetes-style sidecar
services:
  app:
    image: my-app
    volumes:
      - app-logs:/var/log/app
  
  log-shipper:
    image: fluent-bit
    volumes:
      - app-logs:/var/log/app:ro
    environment:
      - FLUENT_BIT_OUTPUT=s3
    depends_on:
      - app

volumes:
  app-logs:

log-shipper and app share the app-logs volume. The shipper reads logs, the app writes them. No network call needed to transfer data.

Sidecar + shared volume is the dominant pattern in Kubernetes — a Pod has many containers sharing a network namespace and volumes. This pattern is now also adopted in Docker Compose, especially for management tools.

Approach 5: UNIX Sockets / Host Sockets #

For containers needing direct control of the Docker daemon or shared Unix sockets.

# Container with Docker socket access
docker run -d \
  -v /var/run/docker.sock:/var/run/docker.sock \
  portainer/portainer
# Custom UNIX socket between containers
docker run -d --name app -v /tmp/app.sock:/tmp/app.sock my-app
docker run -d --name sidecar -v /tmp/app.sock:/tmp/app.sock my-sidecar

This approach is powerful but dangerous — a container with Docker socket access can spawn other containers, delete images, or even escape the host. Only for trusted tools (Portainer, Watchtower, etc.).

Mounting /var/run/docker.sock = the container is root-equivalent on the host. Whoever controls this container has effective control over the entire host. Audit every container that mounts docker.sock.

Approach Comparison #

AspectNetworkShared VolumeSidecarUNIX Socket
LatencySub-msDisk I/ONetwork to localhostLocal
ThroughputHighDepends on diskHighVery high
Use caseAPIs, microservicesFile processingLogging, proxiesTool management
ScalabilityExcellentLimitedMediumPoor
IsolationPer networkPer volumePer podVery low
Security riskLowLowMediumHigh

For 90% of microservice cases, network + user-defined bridge is the best choice. File processing uses volumes, and UNIX sockets are only for specialized management tools.


Common Pattern: API Gateway + Microservices #

The most common production pattern: an API gateway receives client requests, then routes to the appropriate microservices.

flowchart TB
    Client[Client / Browser]
    Gateway[API Gateway]
    Auth[auth-service]
    User[user-service]
    Order[order-service]
    Payment[payment-service]
    DBUser[(user-db)]
    DBOrder[(order-db)]
    DBPay[(payment-db)]

    Client -->|HTTPS| Gateway
    Gateway --> Auth
    Gateway --> User
    Gateway --> Order
    Gateway --> Payment
    User --> DBUser
    Order --> DBOrder
    Payment --> DBPay
version: "3.9"
services:
  gateway:
    image: traefik
    ports:
      - "443:443"
    networks:
      - edge
  
  auth:
    image: auth-service
    networks:
      - edge
      - backend
  
  user:
    image: user-service
    networks:
      - edge
      - backend
  
  order:
    image: order-service
    networks:
      - edge
      - backend

networks:
  edge:
  backend:
    internal: true

Traefik on the edge network, microservices on both edge and backend, databases only on backend (not exposed).


Common Pattern: Event-Driven with a Message Broker #

For asynchronous communication, containers talk via a message broker (Kafka, RabbitMQ, Redis Streams).

flowchart LR
    API[API Service] -->|publish| Q[Message Broker<br/>Kafka / RabbitMQ]
    Q -->|subscribe| W1[Worker 1]
    Q -->|subscribe| W2[Worker 2]
    Q -->|subscribe| W3[Worker 3]
    W1 --> DB[(Database)]
    W2 --> DB
    W3 --> DB
services:
  api:
    image: my-api
    environment:
      KAFKA_BROKER: kafka:9092
  
  worker:
    image: my-worker
    environment:
      KAFKA_BROKER: kafka:9092
    deploy:
      replicas: 3
  
  kafka:
    image: bitnami/kafka
    networks:
      - broker
  
  db:
    image: postgres
    networks:
      - data

networks:
  broker:
    internal: true
  data:
    internal: true

The API publishes events to Kafka, workers subscribe and process. Workers can scale independently of the API. Database and broker are both internal — no outside access.


Anti-Patterns in Container-to-Container Communication #

1. Hardcoding IPs #

# ✗ Anti-pattern: hardcoded IP
docker run -e DATABASE_HOST=172.18.0.3 my-api
# The IP can change on restart!
# ✓ Solution: use the service name
docker run -e DATABASE_HOST=db my-api
# DNS resolves automatically

2. Going Through the Host (round-trip) #

# ✗ Anti-pattern: container A -> host -> container B
# A talks to host:8080, the host forwards to B
# Extra hops, latency, and complexity
# ✓ Solution: directly on the same network
# A talks to B without going through the host

3. Default Bridge for Multi-Container Setups #

# ✗ Anti-pattern: all containers on the default bridge
docker run -d --name db postgres
docker run -d --name api my-api
# api can't resolve "db" - must use an IP
# ✓ Solution: user-defined network
docker network create app-net
docker run -d --name db  --network app-net postgres
docker run -d --name api --network app-net my-api

4. All Services on One Big Network #

# ✗ Anti-pattern: one network for everything
docker network create all-services
docker run --network all-services --name db postgres
docker run --network all-services --name api my-api
docker run --network all-services --name admin pgadmin
# admin can access db directly - a least-privilege violation
# ✓ Solution: segmentation
docker network create backend --internal
docker network create frontend
# admin, db, cache on backend
# web, api on frontend
# api on both (as a bridge)

5. Port Mapping for Internal Communication #

# ✗ Anti-pattern
services:
  api:
    ports:
      - "8080:8080"  # just so web can reach it
  web:
    # web must access localhost:8080 - works but not clean
# ✓ Solution: network sharing
services:
  api:
    # no ports
  web:
    # no ports
# Both on the Compose default network - web accesses api:8080

Patterns for Special Use Cases #

Reverse Proxy for HTTP Traffic #

services:
  traefik:
    image: traefik
    network_mode: host
  
  app1:
    image: my-app1
    # no ports
  
  app2:
    image: my-app2
    # no ports

Traefik on host networking for performance, apps on bridge. Traefik auto-discovers via Docker labels.

Database Replication #

services:
  postgres-primary:
    image: postgres
    environment:
      POSTGRES_REPLICATION_MODE: master
  
  postgres-replica:
    image: postgres
    environment:
      POSTGRES_REPLICATION_MODE: slave
      POSTGRES_MASTER_HOST: postgres-primary

Primary-replica communication over the internal network — no -p to the host.

Service Mesh (Advanced) #

flowchart LR
    A[App A] -->|localhost:15001| P1[Envoy Sidecar]
    B[App B] -->|localhost:15001| P2[Envoy Sidecar]
    P1 <-->|mTLS| P2
    P1 -->|collect| CT[Control Plane]
    P2 -->|collect| CT

Service meshes (Istio, Linkerd) inject a sidecar into each service. Inter-service communication goes through the sidecar, which handles mTLS, retries, and observability. Complexity increases, but so do control and observability.


Best Practices for Container-to-Container Communication #

MUST:
  ✓ Always use user-defined networks for multi-container setups
  ✓ Use service names, not IPs
  ✓ Segment networks (frontend/backend/data)
  ✓ Use a message broker for async communication
  ✓ Internal networks for services that don't need internet access

MUST NOT:
  ✗ Hardcode IP addresses
  ✗ Use the default bridge for multi-container setups
  ✗ Expose database or internal service ports
  ✗ Go through the host for internal communication
  ✗ Put all services on one big network

Health Check and Graceful Shutdown Patterns #

Good container-to-container communication isn’t just about “being able to talk”, but also about how to talk correctly while services are starting up or shutting down.

Health Checks #

# Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO- http://localhost:8080/health || exit 1
# docker-compose.yml
services:
  api:
    image: my-api
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 10s

With health checks, Docker can detect when a service is ready to accept connections. This matters for inter-service communication sensitive to startup time.

depends_on with Health Checks (Compose) #

services:
  api:
    image: my-api
    depends_on:
      db:
        condition: service_healthy
    networks:
      - app-net
  
  db:
    image: postgres
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - app-net

api won’t start until db passes its health check. Without this, api could start first and crash because it can’t connect to a db that isn’t ready.

Default depends_on does NOT wait for the service to be ready. Without condition: service_healthy, depends_on only waits for the container to start, not for the service inside to be listening. For systems needing correct ordering, always add health checks.

Graceful Shutdown #

When a container is stopped, SIGTERM is sent. Applications must handle this by closing existing connections before exiting.

# Python - handle SIGTERM
import signal
import sys

def handle_sigterm(*args):
    print("Shutting down gracefully...")
    # Close DB connections, finish in-flight requests
    sys.exit(0)

signal.signal(signal.SIGTERM, handle_sigterm)

Docker sends SIGTERM, then SIGKILL after 10 seconds (default). Applications that handle SIGTERM properly will shut down without cutting mid-flight connections.

services:
  api:
    image: my-api
    stop_grace_period: 30s  # give 30 seconds
    stop_signal: SIGTERM

Summary #

  • Container-to-container communication is the foundation of microservices architecture. There are five approaches: network, shared volume, sidecar, stdin/stdout pipes, and UNIX sockets.
  • Network is the most common and most flexible approach. Use user-defined networks + internal DNS + segmentation.
  • Shared volumes fit file processing, log aggregation, and workloads working on static data.
  • The sidecar pattern is a helper container running alongside the main app. Dominant in Kubernetes for logging, metrics, and service meshes.
  • UNIX sockets are powerful but dangerous — only for trusted tools (Portainer, Watchtower).
  • Popular production patterns: API Gateway + microservices with network segmentation (edge/backend/data).
  • Event-driven via message brokers (Kafka, RabbitMQ) for async communication and worker scaling.
  • Anti-patterns: hardcoding IPs, going through the host for internal communication, default bridge for multi-container setups, all services on one network, port mapping for internal traffic.
  • Service meshes (Istio, Linkerd) are an evolution of the sidecar pattern — automatic mTLS, observability, retry logic. Complex, but for large architectures, very useful.
  • The main principle: network segmentation, service discovery via DNS, minimal port mapping, internal networks for services that don’t need to be public.

← Previous: Internal DNS   Next: Network Isolation →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact