Why Containerize Your Context Management System
Context management systems involve multiple interconnected services: your application server, a database, a cache, an embedding service, and often a monitoring stack. Without containerization, setting up this stack consistently across development, staging, and production environments is error-prone and time-consuming. Docker solves this by packaging each service with its dependencies into portable, reproducible containers that run identically everywhere.
Beyond consistency, containerization enables horizontal scaling of individual components. If your context retrieval API is CPU-bound while your database has spare capacity, you can scale the API containers independently. This granular scaling is essential as your context system scales to handle production workloads.
Containerized deployments reduce environment-related production incidents by an estimated 60-80%, because the exact same container image that passed testing is what runs in production. "Works on my machine" stops being a problem.
Step 1: Write Production-Ready Dockerfiles
A production Dockerfile is different from a development one. Production images should be small, secure, and optimized for startup time.
Multi-Stage Build for Python Context API
# Stage 1: Build dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install \
-r requirements.txt
# Stage 2: Production image
FROM python:3.11-slim
# Security: run as non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
# Remove unnecessary files
RUN rm -rf tests/ docs/ .git/ __pycache__/
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["gunicorn", "app:app", \
"-b", "0.0.0.0:8000", \
"-w", "4", \
"-k", "uvicorn.workers.UvicornWorker", \
"--access-logfile", "-"]Dockerfile Best Practices
| Practice | Why It Matters | Implementation |
|---|---|---|
| Multi-stage builds | Reduces image size by 40-70% | Separate build and runtime stages |
| Non-root user | Limits container compromise blast radius | USER appuser directive |
| Health checks | Enables orchestrator self-healing | HEALTHCHECK instruction in Dockerfile |
| .dockerignore | Prevents secrets and unnecessary files in image | Exclude .git, .env, tests, docs |
| Pinned base images | Prevents unexpected changes from upstream | Use python:3.11.7-slim not python:3-slim |
| Layer ordering | Maximizes build cache utilization | Copy requirements.txt before source code |
The .dockerignore File
A proper .dockerignore is essential for security and build performance:
.git
.gitignore
.env
.env.*
__pycache__
*.pyc
tests/
docs/
*.md
.pytest_cache
.mypy_cache
docker-compose*.yml
Dockerfile*
.dockerignoreStep 2: Compose Your Full Stack
Docker Compose orchestrates your multi-container deployment. Here is a production-oriented compose file for a complete context management stack:
version: '3.8'
services:
context-api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://ctxuser:${DB_PASSWORD}@db:5432/context_db
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0
- EMBEDDING_SERVICE_URL=http://embeddings:8080
- LOG_LEVEL=info
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
restart: unless-stopped
networks:
- context-net
db:
image: pgvector/pgvector:pg16
environment:
- POSTGRES_DB=context_db
- POSTGRES_USER=ctxuser
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ctxuser -d context_db"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
restart: unless-stopped
networks:
- context-net
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 512mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: '1.0'
memory: 768M
restart: unless-stopped
networks:
- context-net
embeddings:
image: ghcr.io/huggingface/text-embeddings-inference:latest
command: --model-id BAAI/bge-base-en-v1.5 --port 8080
deploy:
resources:
limits:
cpus: '4.0'
memory: 4G
restart: unless-stopped
networks:
- context-net
volumes:
pg_data:
redis_data:
networks:
context-net:
driver: bridgeEnvironment Variables and Secrets
Never hardcode secrets in your compose file or Dockerfile. Use a .env file for local development and a proper secrets manager in production:
# .env file (never commit this)
DB_PASSWORD=your-strong-database-password
REDIS_PASSWORD=your-strong-redis-password
OPENAI_API_KEY=sk-your-api-keyFor production deployments, use Docker secrets or integrate with your cloud provider's secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault). See our zero-trust security guide for comprehensive secrets management strategies.
Step 3: Database Initialization
Use Docker's init script mechanism to set up your database schema automatically when the container first starts:
# init-db/01-extensions.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS vector;
# init-db/02-schema.sql
CREATE TABLE IF NOT EXISTS contexts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
context_type VARCHAR(50) NOT NULL,
content JSONB NOT NULL,
embedding vector(1536),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE,
metadata JSONB DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
version INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_contexts_user_type
ON contexts(user_id, context_type);
CREATE INDEX IF NOT EXISTS idx_contexts_embedding
ON contexts USING hnsw (embedding vector_cosine_ops);Place these SQL files in the init-db/ directory. PostgreSQL will execute them in alphabetical order on first startup. For schema migrations after initial deployment, use a migration tool like Alembic or Flyway.
Step 4: Build, Test, and Run
With your Dockerfile, compose file, and init scripts in place, bring up the full stack:
# Build and start all services
docker compose up -d --build
# Verify all services are healthy
docker compose ps
# Check logs for startup issues
docker compose logs -f context-api
# Run a quick health check
curl http://localhost:8000/health
# Run integration tests against the stack
docker compose exec context-api python -m pytest tests/integration/Development vs Production Compose Files
Use a compose override file for development-specific settings:
# docker-compose.override.yml (auto-loaded in dev)
services:
context-api:
build:
target: builder # use build stage with dev deps
volumes:
- .:/app # mount source for hot-reload
environment:
- LOG_LEVEL=debug
command: uvicorn app:app --reload --host 0.0.0.0 --port 8000
db:
ports:
- "5432:5432" # expose DB port for local tools
redis:
ports:
- "6379:6379" # expose Redis port for local toolsStep 5: Production Deployment Considerations
Logging
Configure centralized logging so you can aggregate logs from all containers in one place:
# In docker-compose.prod.yml
services:
context-api:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
tag: "context-api"For production, forward logs to a centralized system (ELK, Loki, CloudWatch) using a log driver or a sidecar container running Fluent Bit.
Networking and Security
- Never expose database ports to the host in production. Only the API port should be accessible externally.
- Use a reverse proxy (Nginx or Traefik) in front of your API for TLS termination, rate limiting, and request routing.
- Isolate networks: Put your API and reverse proxy on a frontend network, and your API, database, and cache on a backend network. The database and cache should not be reachable from the frontend network.
Backup Strategy
Automate database backups within your Docker deployment:
# Add to docker-compose.prod.yml
db-backup:
image: prodrigestivill/postgres-backup-local
environment:
- POSTGRES_HOST=db
- POSTGRES_DB=context_db
- POSTGRES_USER=ctxuser
- POSTGRES_PASSWORD=${DB_PASSWORD}
- SCHEDULE=@daily
- BACKUP_KEEP_DAYS=30
volumes:
- ./backups:/backups
depends_on:
- db
networks:
- context-netStep 6: Moving Beyond Docker Compose
Docker Compose is excellent for single-host deployments and small-scale production. As your context system grows, you will need container orchestration for multi-host deployments, auto-scaling, and self-healing.
When to Move to Kubernetes
- You need to scale beyond what a single host can handle
- You require zero-downtime deployments with rolling updates
- You need auto-scaling based on context retrieval load
- Your organization already runs a Kubernetes cluster
Managed Container Alternatives
If you do not want to operate Kubernetes, managed container services provide a middle ground:
- AWS ECS/Fargate: Run your Docker containers without managing servers. Integrates with RDS for PostgreSQL and ElastiCache for Redis.
- Google Cloud Run: Fully managed serverless containers. Best for stateless API workloads with variable traffic.
- Azure Container Apps: Built on Kubernetes but abstracts away cluster management. Good for teams wanting Kubernetes capabilities without operational overhead.
Before deploying to any production environment, validate your system's performance with the techniques in our load testing guide. Run load tests against your containerized stack to identify bottlenecks and establish performance baselines.
Monitoring Your Containerized Stack
Add Prometheus and Grafana to your compose stack for monitoring. Instrument your context API with Prometheus metrics (request count, latency histograms, error rates) and visualize them in Grafana. See our dashboard building guide for details on what metrics to track and how to set up alerting.
# Add to docker-compose.prod.yml
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- context-net
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
networks:
- context-netFrequently Asked Questions
How do I handle database migrations in a containerized environment?
Run migrations as a separate one-off container or init container, not as part of your application startup. This prevents multiple application replicas from running migrations simultaneously. Use a tool like Alembic with a migration lock to ensure only one migration runs at a time: docker compose run --rm context-api alembic upgrade head.
Should I use Docker volumes or bind mounts for database storage?
Use named Docker volumes for database storage in production. They are managed by Docker, perform better than bind mounts on most platforms, and work correctly with file permissions. Bind mounts are appropriate for development (to mount source code for hot-reload) but not for production data persistence.
How do I update my containers in production without downtime?
With Docker Compose, use docker compose up -d --no-deps --build context-api to rebuild and restart only the API container. For true zero-downtime updates, you need a load balancer in front of multiple API replicas and a rolling update strategy, which is where Kubernetes or a managed container service becomes valuable.
What is the recommended image size for a context management API container?
Aim for under 200MB. A Python 3.11-slim base with typical context management dependencies (FastAPI, asyncpg, redis, httpx) produces images around 150-180MB with multi-stage builds. Larger images slow down deployments and increase attack surface. Use docker images and docker history to identify what is consuming space and optimize accordingly.