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

ConfigAccess from hostAccess between containersWhen to use
ports: ["8000:8000"]localhost:8000api:8000Public API, debug tools
expose: ["6379"]cache:6379Internal 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.11 that 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

  1. Docker Compose Networking — Official reference
  2. Docker Network Drivers — Bridge, host, overlay
  3. Nginx Reverse Proxy — Reverse proxy config
  4. Caddy Server — Alternative to Nginx with auto-SSL
  5. Docker DNS Resolution — How internal DNS works
  6. Nginx Proxy for WebSocket/SSE — Configure Nginx for LLM streaming
  7. httpx — Async HTTP Client — HTTP client for communication between Python services
  8. Docker Compose Expose vs Ports — Official difference