Docker & Container Orchestration

8 min read Module 8 of 10 Topic 23 of 30

What you'll learn

  • Write a multi-stage Dockerfile that produces a minimal, secure agent container
  • Manage secrets and environment variables safely in containerized environments
  • Set up a complete local development stack with Docker Compose
  • Optimize container startup time and implement proper health checks
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

The Production Docker Setup

A production agent service needs more than just docker run python main.py. It needs:

  • A minimal, secure base image
  • Non-root user for security
  • Proper signal handling for graceful shutdown
  • Health checks for orchestrator integration
  • Environment-based configuration

Multi-stage Dockerfile

# Dockerfile

# ──────────────── Stage 1: Build ────────────────
# python:3.12-slim-bookworm: debian slim is ~50MB vs full ~900MB; bookworm for latest security patches
FROM python:3.12-slim AS builder

# uv is ~10x faster than pip for dependency resolution and installation
COPY --from=ghcr.io/astral-sh/uv:0.5.6 /uv /usr/local/bin/uv

WORKDIR /build

# Copy dependency files first (for layer caching)
# Docker caches this layer: if pyproject.toml and uv.lock haven't changed, uv sync is skipped on rebuild
COPY pyproject.toml uv.lock ./

# Install dependencies into a virtual environment at /opt/venv
# --frozen ensures uv.lock is respected exactly: no silent upgrades
# --no-dev skips test and lint dependencies: they're not needed in production
# --no-editable installs the package normally, not as a symlink
RUN uv venv /opt/venv && \
    uv sync --frozen --no-dev --no-editable

# ──────────────── Stage 2: Runtime ────────────────
# fresh slim base: none of the build tools from Stage 1 carry over
FROM python:3.12-slim AS runtime

# Security: run as non-root: required by most Kubernetes security policies
# gid/uid 1000 is the conventional first non-root user ID on Linux
RUN groupadd --gid 1000 appuser && \
    useradd --uid 1000 --gid 1000 --no-create-home appuser

# COPY --from=builder only copies the final venv, not build tools: keeps image small
COPY --from=builder /opt/venv /opt/venv

# Set environment to use the venv
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \          # flush stdout/stderr immediately, essential for container log streaming
    PYTHONDONTWRITEBYTECODE=1 \   # don't write .pyc files, saves disk space in the container
    # Disable Python's signal handlers: uvicorn handles signals
    PYTHONPATH=/app

WORKDIR /app

# --chown sets file ownership to appuser at copy time: avoids a separate RUN chown layer
COPY --chown=appuser:appuser src/ ./src/

# USER 1000: switch to non-root before running; any process that breaks out of the container has no root
USER appuser

# Health check using the /health endpoint
# --start-period=60s gives the app time to initialize before the first check counts as a failure
# --retries=3 means 3 consecutive failures mark the container unhealthy
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5).raise_for_status()"

EXPOSE 8000

# Use exec form (JSON array) for CMD: ensures uvicorn receives OS signals directly, not via shell
# This is required for graceful shutdown: SIGTERM reaches uvicorn, which drains in-flight requests
CMD ["uvicorn", "src.api.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--workers", "2", \
     "--loop", "uvloop", \    # uvloop replaces asyncio's event loop with a faster C implementation
     "--access-log"]

.dockerignore

# .dockerignore: exclude files from Docker build context
# everything listed here is never sent to the Docker daemon: speeds up builds and prevents accidental inclusion
.git/
.venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.ruff_cache/
.env        # secrets must never enter the build context
.env.local
*.md
tests/
scripts/
Makefile
docker-compose*.yml

# These should never be in the image
**/*.key
**/*.pem
**/secrets/

Docker Compose for Local Development

# docker-compose.yml
version: "3.9"

services:
  agent-api:
    build:
      context: .
      target: runtime   # build only up to the runtime stage, skip any stage after it
    ports:
      - "8000:8000"   # host:container, access the API at localhost:8000
    environment:
      ENVIRONMENT: development
      LOG_LEVEL: DEBUG
    env_file:
      - .env  # loaded at runtime, not baked into image, secrets stay out of the image layers
    depends_on:
      postgres:
        condition: service_healthy   # wait for postgres healthcheck to pass before starting agent-api
      qdrant:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped   # auto-restart on crash but respect manual docker compose stop
    healthcheck:
      test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health', timeout=5).raise_for_status()"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s   # extra grace period on first start, model clients may be slow to initialize

  postgres:
    image: postgres:16   # relational + checkpoint state only; Qdrant below handles all vector search
    environment:
      POSTGRES_USER: agent
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}   # read from .env, never hardcode passwords
      POSTGRES_DB: agentdb
    volumes:
      - postgres_data:/var/lib/postgresql/data   # named volume persists data across container restarts
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U agent -d agentdb"]   # pg_isready checks if Postgres accepts connections
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"   # expose to host for local DB clients like psql or TablePlus

  qdrant:
    image: qdrant/qdrant:latest   # Qdrant is a vector database for semantic search and RAG
    volumes:
      - qdrant_data:/qdrant/storage
    ports:
      - "6333:6333"   # Qdrant REST API port
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
      interval: 15s
      timeout: 5s
      retries: 3

  redis:
    image: redis:7-alpine   # alpine variant is ~7MB vs ~30MB for the full image
    command: redis-server --requirepass ${REDIS_PASSWORD}   # require auth even on the local network
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]   # ping returns PONG if Redis is healthy
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "6379:6379"

  # LangSmith local dev proxy (optional)
  langsmith:
    image: langchain/langsmith:latest   # local LangSmith captures traces without sending data to the cloud
    ports:
      - "1984:1984"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}

volumes:
  postgres_data:   # named volumes are managed by Docker and survive container removal
  qdrant_data:
  redis_data:

Build and Run Commands

# Build the image: Docker reads Dockerfile from the current directory
docker build -t agent-service:latest .

# Check image size: numfmt --to=iec converts bytes to human-readable (MB/GB)
docker image inspect agent-service:latest --format '{{.Size}}' | numfmt --to=iec

# Run with environment variables: --env-file injects secrets at runtime, not into the image
docker run -p 8000:8000 \
  --env-file .env \
  --name agent-service \
  agent-service:latest

# Start the full stack: -d runs containers in the background (detached mode)
docker compose up -d

# View logs: -f follows the log stream in real time (like tail -f)
docker compose logs -f agent-api

# Shell into running container (for debugging): exec runs a command in an existing container
docker compose exec agent-api /bin/bash

# Stop everything: preserves volumes (data is safe)
docker compose down

# Stop and remove volumes (full reset): -v deletes all named volumes, wiping all local data
docker compose down -v

Makefile for Developer Ergonomics

# Makefile

.PHONY: build run test lint clean   # .PHONY tells make these are commands, not file names

build:
	docker build -t agent-service:latest .

run:
	docker compose up -d

stop:
	docker compose down

logs:
	docker compose logs -f agent-api

test:
	uv run pytest tests/ -v --asyncio-mode=auto   # --asyncio-mode=auto enables async tests without extra markers

lint:
	uv run ruff check src/ tests/
	uv run ruff format --check src/ tests/   # --check exits non-zero if files need reformatting (for CI)

shell:
	docker compose exec agent-api /bin/bash

clean:
	docker compose down -v
	docker image prune -f   # -f skips the confirmation prompt, removes dangling (untagged) images

deploy-staging:
	docker build -t agent-service:staging .
	docker tag agent-service:staging registry.example.com/agent-service:staging   # tag before push
	docker push registry.example.com/agent-service:staging

Production Container Hardening

Additional security layers to apply on top of the runtime stage for production deployments.

# Additional security for production
FROM runtime AS production

# No shell access: removes sh and bash so attackers can't run arbitrary commands even with container access
RUN rm -f /bin/sh /bin/bash

# Read-only filesystem (mount necessary volumes explicitly)
# docker run --read-only --tmpfs /tmp agent-service:production
# --read-only prevents any file writes; --tmpfs /tmp allows temp files in memory only

# Minimal capabilities
# docker run --cap-drop ALL --security-opt no-new-privileges agent-service:production
# --cap-drop ALL removes Linux capabilities (e.g. net_bind_service, sys_admin) the container doesn't need
# --no-new-privileges blocks privilege escalation via setuid binaries

# Resource limits in Docker Compose
# agent-api:
#   deploy:
#     resources:
#       limits:
#         cpus: '2.0'     # hard cap: container can't steal CPU from other services
#         memory: 4G      # OOM killer triggers if the container exceeds 4GB
#       reservations:
#         cpus: '0.5'     # guaranteed CPU: Compose won't schedule this container without 0.5 cores available
#         memory: 1G

A properly containerized agent service starts in under 30 seconds, restarts automatically on failure, and gives you a clear separation between the application and its infrastructure.

Knowledge Check

3 questions to test your understanding

1 Why use a multi-stage Dockerfile for your agent service?

2 How should API keys and secrets be provided to a containerized agent service?

3 What is the purpose of a Docker health check in an agent service container?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan