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.