Docker Production Migration: From Development Server to Gunicorn Multi-Container¶
Context: This article documents lessons learned migrating openit.chat from
mkdocs serve(development-only) to a production-ready multi-container Docker setup with gunicorn, shared volumes, and Traefik routing.
The Challenge¶
Moving a documentation site from development (mkdocs serve) to production requires more than just swapping the server. You need:
- Separation of concerns: Building and serving are different responsibilities
- Persistent static files: Built artifacts must survive container restarts
- No direct port exposure: Traffic routes through a reverse proxy
- Fault isolation: Build failures shouldn't crash the live service
Architecture: Three Services, One Goal¶
services:
builder: # Runs mkdocs build, writes to shared volume
mkdocs: # Dev: local development with hot-reload
server: # Prod: gunicorn serves static files via Traefik
1. Builder Container (Dockerfile.builder)¶
Runs independently on a schedule or manually:
FROM educationwarehouse/edwhale-3.13:latest
RUN uvenv install --no-cache mkdocs
RUN uvenv inject --no-cache mkdocs mkdocs-material
WORKDIR /docs
CMD ["/root/.local/bin/mkdocs", "build", "-d", "/build/site"]
Key insight: The builder writes to /build/site mounted as a shared volume. If mkdocs fails, the production server keeps serving the previous build.
2. Server Container (Dockerfile.server)¶
Minimal, focused on serving static files:
FROM educationwarehouse/edwhale-3.13:latest
RUN uvenv install --no-cache gunicorn
WORKDIR /app
COPY app.py /app/
CMD ["/root/.local/bin/gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:application"]
3. WSGI Application (app.py)¶
Simple static file server with directory traversal protection:
from pathlib import Path
from http import HTTPStatus
import mimetypes
SITE_DIR = Path("/build/site")
def application(environ, start_response):
path = environ.get("PATH_INFO", "").lstrip("/")
# Default to index.html
if not path or path.endswith("/"):
path = path.rstrip("/") + "/index.html"
# Security check
if ".." in path:
start_response(str(int(HTTPStatus.FORBIDDEN)),
[("Content-Type", "text/plain")])
return [b"Forbidden"]
file_path = SITE_DIR / path
if file_path.is_file():
content_type, _ = mimetypes.guess_type(str(file_path))
content_type = content_type or "application/octet-stream"
with open(file_path, "rb") as f:
content = f.read()
start_response(str(int(HTTPStatus.OK)),
[("Content-Type", content_type)])
return [content]
start_response(str(int(HTTPStatus.NOT_FOUND)),
[("Content-Type", "text/plain")])
return [b"Not Found"]
Key Learnings¶
1: Use Shared Volumes, Not COPY¶
❌ Wrong: Hardcoding files in the image
COPY docs/ /docs/
RUN mkdocs build
✅ Right: Mount source and output volumes
volumes:
- ./docs:/docs # Source code
- site_build:/build/site # Built output
Why: Allows independent updates without rebuilding images.
2: Never Expose Ports Directly¶
❌ Wrong:
ports:
- "8000"
✅ Right: Traefik labels only
labels:
- "traefik.enable=true"
- "traefik.http.routers.${NAME_SERVICE}-${PROJECT}-secure.rule=Host(`${HOSTINGDOMAIN}`)"
- "traefik.docker.network=broker"
Why: Centralizes routing, enables SSL/TLS termination, allows dynamic configuration.
3: Test Components in Isolation¶
When docker-compose networking behaves strangely, test the image directly:
docker run --rm \
-v ./docs:/docs \
-v openit_site_build:/build \
openit-builder:test
This immediately reveals whether the problem is the image or the orchestration.
4: Error Handling in Automation¶
Tasks must fail fast and explicitly:
@task
def update(c):
"""Build and restart only if successful."""
try:
build(c) # Raises on failure
c.run("docker compose restart server")
print("✓ Deployed successfully")
except invoke.exceptions.UnexpectedExit as e:
print(f"✗ Build failed: {e}")
raise # Propagate, don't swallow
5: Stale State Ruins Debugging¶
Always use docker compose down --remove-orphans before testing. Leftover containers or volumes from previous runs create phantom issues.
Deployment Workflow¶
# Build documentation
ew local.build
# Automatic: If build succeeds, restart server
ew local.update
# Manual oversight
docker compose logs -f server
Production Checklist¶
- [ ] Builder and server are separate services
- [ ] Shared volume persists across restarts
- [ ] No direct port exposure (Traefik only)
- [ ] Error handling propagates failures
- [ ] Tests run in isolation before committing
- [ ] Stale containers cleaned before testing
Conclusion¶
Multi-container Docker architecture decouples building from serving, improving reliability, debuggability, and operational flexibility. The key is using volumes for data flow, reverse proxies for routing, and clear error handling for automation.
Attribution: OpenIT.chat community discussion, December 2025
Keywords: Docker, Production, Gunicorn, Traefik, DevOps, Static Site Hosting