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:
| Approach | Mechanism | Best for |
|---|---|---|
| Network (default) | TCP/UDP over a bridge | APIs, microservices |
| Internal DNS | Automatic name resolution | Service discovery |
| Shared volume | Shared filesystem | File processing, batch |
| Sidecar | Helper container | Logging, proxies, sync |
| Host socket | docker.sock, UNIX sockets | Management tools |
The network approach is the most common and most flexible. It’s the main focus of this article.
Approach 1: Via Docker Networks (Recommended) #
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: ResponseNetwork 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 --> CThe 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| SideCommon 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 #
| Aspect | Network | Shared Volume | Sidecar | UNIX Socket |
|---|---|---|---|---|
| Latency | Sub-ms | Disk I/O | Network to localhost | Local |
| Throughput | High | Depends on disk | High | Very high |
| Use case | APIs, microservices | File processing | Logging, proxies | Tool management |
| Scalability | Excellent | Limited | Medium | Poor |
| Isolation | Per network | Per volume | Per pod | Very low |
| Security risk | Low | Low | Medium | High |
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 --> DBPayversion: "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 --> DBservices:
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| CTService 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.
Defaultdepends_ondoes NOT wait for the service to be ready. Withoutcondition: service_healthy,depends_ononly 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.