Module 2: Local & Container Deployment
5. Networking and Communication Between Services
Overview
In this capsule you'll understand how Docker Compose connects services to each other: internal networks, name resolution, ports, and communication patterns between containers. By the end, you'll know why redis://cache:6379 works and how to configure networking for more complex scenarios.
Context: When you write REDIS_URL=redis://cache:6379, "cache" is the service name in Compose. Docker creates an internal network where each service is reachable by its name. Understanding this is key for debugging and for scenarios like adding LocalStack (M4) or configuring reverse proxies.
Compose's Default Network
How it works
When you run docker compose up, Docker automatically creates a bridge network where all services can communicate by name:
Internal network: module-02_default
├── api → IP: 172.18.0.3
├── cache → IP: 172.18.0.2
└── worker → IP: 172.18.0.4
api can reach cache as:
redis://cache:6379 ✅ (service name)
redis://172.18.0.2:6379 ✅ (IP, but not recommended)
# See created networks
docker network ls
# Inspect the network
docker network inspect module-02_default
# From inside a container, verify connectivity
docker compose exec api ping cache
# PING cache (172.18.0.2): 56 data bytes
# 64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.082 ms
How Docker creates the network
The network name follows the pattern {directory-name}_default. If your project is in ~/projects/ai-app/, the network will be called ai-app_default.
# Before docker compose up
docker network ls
# NETWORK ID NAME DRIVER SCOPE
# abc123 bridge bridge local
# def456 host host local
# After docker compose up (in the ai-app/ directory)
docker network ls
# NETWORK ID NAME DRIVER SCOPE
# abc123 bridge bridge local
# def456 host host local
# ghi789 ai-app_default bridge local ← New
# When you run docker compose down, the network is removed
docker compose down
docker network ls
# The ai-app_default network no longer exists
Every time you run docker compose up, the containers get new IPs. That's why you always use service names, never hardcoded IPs.
DNS Resolution in Docker Networks
How it works internally
Docker runs an embedded DNS server at 127.0.0.11 inside each container. When your app makes a request to cache, this DNS resolves the name to the container's current IP:
# Inside the api container, verify DNS
docker compose exec api cat /etc/resolv.conf
# nameserver 127.0.0.11
# ndots:0
# Resolve a name manually
docker compose exec api nslookup cache
# Server: 127.0.0.11
# Name: cache
# Address 1: 172.18.0.2 cache.ai-app_default
# See all the IPs on the network
docker network inspect ai-app_default --format='{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}'
# ai-app-cache-1: 172.18.0.2/16
# ai-app-api-1: 172.18.0.3/16
DNS and replicas
If you scale a service to multiple replicas, DNS returns all the IPs (round-robin):
# Scale workers
docker compose up -d --scale worker=3
# DNS now returns 3 IPs for "worker"
docker compose exec api nslookup worker
# Name: worker
# Address 1: 172.18.0.4
# Address 2: 172.18.0.5
# Address 3: 172.18.0.6
Resolution times and caching
# api/main.py — Careful with connection caching
import redis
# ✅ Correct: redis-py resolves DNS on each reconnection
cache = redis.from_url("redis://cache:6379")
# ❌ Potential problem: resolve DNS only once at startup
import socket
cache_ip = socket.gethostbyname("cache") # 172.18.0.2
# If cache restarts, it gets a new IP → this connection breaks
Ports vs Expose vs No Configuration
Detailed comparison
| Config | Access from host | Access between containers | When to use |
|---|---|---|---|
ports: ["8000:8000"] | ✅ localhost:8000 | ✅ api:8000 | Public API, debug tools |
expose: ["6379"] | ❌ | ✅ cache:6379 | Internal services (Redis, Postgres) |
| No config | ❌ | ✅ (if the image exposes the port) | Services that already define EXPOSE in the Dockerfile |
Ports: expose to the host
services:
api:
ports:
- "8000:8000" # Accessible from YOUR MACHINE (host:container)
cache:
expose:
- "6379" # Only accessible from OTHER CONTAINERS
# No ports → not accessible from your machine
redis-commander:
ports:
- "8081:8081" # Accessible from your machine (debug tool)
profiles:
- debug
Your machine (host):
├── localhost:8000 → api ✅ (port mapping)
├── localhost:6379 → cache ❌ (no port mapping, only expose)
└── localhost:8081 → redis-commander ✅ (if the debug profile is active)
Internal network (between containers):
├── api → cache:6379 ✅ (expose works internally)
├── api → redis-commander:8081 ✅ (they all see each other internally)
└── cache → api:8000 ✅ (bidirectional)
The detail about expose
expose in Docker Compose is mostly documentation. Inside the Compose network, containers can always communicate over the ports the process listens on, with or without expose. The real difference is:
# These two configurations have the SAME effect between containers
services:
cache:
image: redis:7-alpine
# Redis listens on 6379 (defined in the image)
# Other containers can reach it at cache:6379
cache-explicit:
image: redis:7-alpine
expose:
- "6379"
# The result is identical: accessible at cache-explicit:6379
expose is useful as documentation: it says "this service listens on this port" without exposing it to the host.
Advanced port mappings
services:
api:
ports:
# host:container
- "8000:8000" # Direct map
- "127.0.0.1:8000:8000" # Only accessible from localhost (more secure)
- "8001:8000" # Different port on host vs container
api-dev:
ports:
- "8000-8003:8000-8003" # Port range (useful with workers)
# Binding to 127.0.0.1 is more secure on servers:
# Only your machine can access it, not other machines on the network
ports:
- "127.0.0.1:8000:8000" # localhost only
- "0.0.0.0:8000:8000" # The whole network (default, less secure)
Custom Networks
When you need custom networks
# Scenario: separate frontend from backend
services:
nginx:
networks:
- frontend
- backend
api:
networks:
- backend
cache:
networks:
- backend
networks:
frontend:
backend:
# nginx can talk to api (both on backend)
# nginx can receive external traffic (frontend)
# cache is NOT accessible from frontend (only backend)
For this guide: the default network is enough
For typical AI apps (API + cache + vector store), Compose's default network covers the case. Custom networks are necessary when:
- You have a reverse proxy (Nginx) that shouldn't see internal services
- You have multiple apps on the same host that shouldn't communicate
- You need isolation for security
Custom networks with configuration
networks:
backend:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
driver_opts:
com.docker.network.bridge.name: "ai-backend"
services:
api:
networks:
backend:
ipv4_address: 172.28.0.10
You rarely need static IPs. Use them only if an external service requires a fixed IP.
Service Discovery
Compose's internal DNS
# In your Python code, services are referenced by name
import os
REDIS_URL = os.environ.get("REDIS_URL", "redis://cache:6379")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://vectordb:6333")
LOCALSTACK_URL = os.environ.get("AWS_ENDPOINT_URL", "http://localstack:4566")
Each name (cache, vectordb, localstack) resolves to the corresponding container's IP via Docker's internal DNS.
Service discovery in practice
# api/config.py — Centralize the service URLs
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# All URLs use Compose service names
redis_url: str = "redis://cache:6379"
qdrant_url: str = "http://vectordb:6333"
postgres_url: str = "postgresql://app:password@postgres:5432/aiapp"
embeddings_service_url: str = "http://embeddings-worker:8001"
# For local development WITHOUT Docker:
# redis_url: str = "redis://localhost:6379"
# qdrant_url: str = "http://localhost:6333"
# The .env for local development (without Docker) uses localhost
REDIS_URL=redis://localhost:6379
QDRANT_URL=http://localhost:6333
# Docker Compose overrides with service names
# docker-compose.yml
services:
api:
environment:
- REDIS_URL=redis://cache:6379
- QDRANT_URL=http://vectordb:6333
Networking Patterns for AI Apps
Pattern 1: API Gateway → Inference Services
In AI apps with multiple models or services, a gateway centralizes access:
# docker-compose.yml
services:
gateway:
build: ./gateway
ports:
- "8000:8000"
depends_on:
chat-service:
condition: service_healthy
embeddings-service:
condition: service_healthy
chat-service:
build: ./services/chat
expose:
- "8001"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
embeddings-service:
build: ./services/embeddings
expose:
- "8002"
vectordb:
image: qdrant/qdrant:latest
expose:
- "6333"
cache:
image: redis:7-alpine
expose:
- "6379"
External traffic:
User → localhost:8000 → gateway
Internal traffic:
gateway → chat-service:8001 (generates responses)
gateway → embeddings-service:8002 (generates embeddings)
chat-service → cache:6379 (response cache)
embeddings-service → vectordb:6333 (semantic search)
❌ User CANNOT access chat-service directly
❌ User CANNOT access vectordb directly
# gateway/main.py — Routes requests to internal services
import httpx
from fastapi import FastAPI
app = FastAPI()
CHAT_URL = "http://chat-service:8001"
EMBEDDINGS_URL = "http://embeddings-service:8002"
@app.post("/chat")
async def chat(request: dict):
async with httpx.AsyncClient() as client:
response = await client.post(f"{CHAT_URL}/generate", json=request)
return response.json()
@app.post("/search")
async def search(request: dict):
async with httpx.AsyncClient() as client:
# Generate embedding and search in the vector store
response = await client.post(
f"{EMBEDDINGS_URL}/search", json=request
)
return response.json()
Pattern 2: Isolation by network layers
services:
nginx:
networks: [public, api-net]
ports:
- "80:80"
api:
networks: [api-net, data-net]
embeddings-worker:
networks: [data-net]
vectordb:
networks: [data-net]
cache:
networks: [data-net]
networks:
public:
api-net:
data-net:
Who can talk to whom:
nginx → api ✅ (both on api-net)
nginx → vectordb ❌ (nginx is not on data-net)
api → vectordb ✅ (both on data-net)
api → cache ✅ (both on data-net)
Pattern: Reverse Proxy with Nginx
Basic configuration
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
api:
condition: service_healthy
api:
build: ./api
expose:
- "8000" # Internal only, Nginx does the proxying
Complete Nginx config for an AI app
# nginx.conf
upstream api_backend {
server api:8000;
}
server {
listen 80;
server_name _;
# Logs
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# High timeouts for LLM requests (they can take 30s+)
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
# Request size for uploads (images, documents)
client_max_body_size 50M;
location / {
proxy_pass http://api_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://api_backend/health;
# No buffering for health checks
proxy_buffering off;
}
# Streaming for LLM responses (Server-Sent Events)
location /chat/stream {
proxy_pass http://api_backend/chat/stream;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
}
# Basic rate limiting
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://api_backend/api/;
}
}
# In the http block (if you use a full nginx.conf):
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# ...
}
The proxy_read_timeout: 120s timeout is critical for AI apps. A request to an LLM can take 30-60 seconds. Nginx's default is 60s, which can cause timeouts on complex requests.
Troubleshooting
Problem 1: "Connection refused when connecting to another service"
# Verify that the service is running
docker compose ps
# Verify that they're on the same network
docker network inspect module-02_default
# Verify that the port is correct
docker compose exec api curl http://cache:6379
# Redis doesn't speak HTTP, but verify that the port responds
docker compose exec api redis-cli -h cache ping
Problem 2: "I can't access the service from my machine"
You need ports (not just expose):
# ❌ Only accessible internally
expose:
- "6379"
# ✅ Accessible from your machine
ports:
- "6379:6379"
Problem 3: "Port conflict"
# Error: Bind for 0.0.0.0:8000 failed: port is already allocated
# Another process uses port 8000
# Option 1: Change the host port
ports:
- "8001:8000" # Your machine on 8001, container on 8000
# Option 2: Find and stop the process using the port
lsof -i :8000
kill <PID>
Problem 4: "DNS doesn't resolve the service name"
# "Could not resolve host: cache" inside a container
# 1. Verify that the service is defined in the same docker-compose.yml
docker compose ps
# If cache doesn't appear, it doesn't exist in this Compose file
# 2. Verify that they're on the same network
docker compose exec api cat /etc/resolv.conf
# It should show nameserver 127.0.0.11
# 3. Verify DNS resolution
docker compose exec api nslookup cache
# If it fails, the service is probably not running
# 4. Common cause: a typo in the service name
# docker-compose.yml says "redis-cache" but your code says "cache"
Problem 5: "Nginx returns 502 Bad Gateway"
# 502 = Nginx can't connect to the backend
# 1. Verify that api is running and healthy
docker compose ps
docker compose logs api --tail 20
# 2. Verify from the nginx container
docker compose exec nginx curl http://api:8000/health
# If it fails, the API is not responding
# 3. Common cause: api started but is in start_period (not healthy yet)
# Solution: add depends_on with service_healthy
services:
nginx:
depends_on:
api:
condition: service_healthy
# 4. Common cause: the upstream name doesn't match
# nginx.conf says "proxy_pass http://backend:8000" but the service is called "api"
Hands-On Exercises
Exercise 1: Add Nginx as a reverse proxy
Configure Nginx in front of your API so traffic enters on port 80 and gets redirected to the API on port 8000.
See solution
# docker-compose.yml
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
api:
condition: service_healthy
api:
build: ./api
expose:
- "8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 15s
# nginx.conf
server {
listen 80;
location / {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
docker compose up -d
curl http://localhost:80/health
# Should return the API's health check
Exercise 2: Verify connectivity between services
From the api container, verify that you can reach cache and vice versa.
See solution
# Ping between services
docker compose exec api ping -c 3 cache
docker compose exec cache ping -c 3 api
# Verify DNS resolution
docker compose exec api nslookup cache
docker compose exec api nslookup vectordb
# Verify that the health endpoint works
docker compose exec api curl -s http://localhost:8000/health
# Verify the full network
docker network inspect $(docker compose ps -q api | head -1 | xargs docker inspect --format='{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}')
Exercise 3: Separate networks
Create two networks: public (nginx + api) and private (api + cache). Verify that nginx CANNOT reach cache directly.
See solution
services:
nginx:
networks: [public]
api:
networks: [public, private]
cache:
networks: [private]
networks:
public:
private:
docker compose exec nginx ping cache
# ping: bad address 'cache' — Can't resolve, correct
docker compose exec api ping cache
# PING cache... — It can, correct
Exercise 4: Networking for an AI pipeline with a gateway
Configure a Docker Compose with a gateway that routes requests to two internal services (chat and embeddings). Only the gateway should be accessible from the host. Use networks to isolate the data services.
See solution
# docker-compose.yml
services:
gateway:
build: ./gateway
ports:
- "8000:8000"
networks: [public, services]
depends_on:
chat-service:
condition: service_healthy
chat-service:
build: ./services/chat
expose:
- "8001"
networks: [services, data]
environment:
- REDIS_URL=redis://cache:6379
embeddings-service:
build: ./services/embeddings
expose:
- "8002"
networks: [services, data]
environment:
- QDRANT_URL=http://vectordb:6333
cache:
image: redis:7-alpine
expose:
- "6379"
networks: [data]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
vectordb:
image: qdrant/qdrant:latest
expose:
- "6333"
networks: [data]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
networks:
public:
services:
data:
# Verify isolation
docker compose exec gateway ping cache
# ❌ Doesn't resolve (gateway is not on the data network)
docker compose exec chat-service ping cache
# ✅ Works (both on the data network)
# Only the gateway is accessible from the host
curl http://localhost:8000/health # ✅
curl http://localhost:8001/health # ❌ Connection refused
Summary
- Docker Compose creates a default network where all services communicate by name.
- Docker runs an internal DNS at
127.0.0.11that resolves service names to container IPs. - ports exposes to the host (your machine); expose is only accessible between containers; without config, internal ports work the same between containers.
- Services are referenced by name (not IP):
redis://cache:6379. The IPs change on each restart. - Custom networks are for isolating services (e.g.: separating a public gateway from private data services).
- Nginx as a reverse proxy is the pattern for exposing an API. Configure high timeouts for LLM requests.
- For multi-service AI apps, use a gateway pattern with isolated internal services.
Additional Resources
- Docker Compose Networking — Official reference
- Docker Network Drivers — Bridge, host, overlay
- Nginx Reverse Proxy — Reverse proxy config
- Caddy Server — Alternative to Nginx with auto-SSL
- Docker DNS Resolution — How internal DNS works
- Nginx Proxy for WebSocket/SSE — Configure Nginx for LLM streaming
- httpx — Async HTTP Client — HTTP client for communication between Python services
- Docker Compose Expose vs Ports — Official difference