Ga naar inhoud

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