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?

Discussion

Questions and notes from learners on this topic

Loading discussion…

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