Static Sites in Kubernetes - The Baked-In Approach

Running a static site generator in Kubernetes might sound like overkill. But once you have a cluster, why not use it?

The challenge: Most tutorials show Pelican running with --listen mode or mounted volumes. That works for development, but production needs something more robust.

The Problem with Volume Mounts

A typical first attempt looks like this:

volumes:
  - name: content
    hostPath:
      path: /app/

This approach has issues:

  • Content must exist on the specific node
  • No portability across cluster nodes
  • Pelican's development server isn't production-ready
  • Updates require SSH access to the node

The Baked-In Approach

Instead of mounting content at runtime, we embed it in the container image during build time.

The architecture becomes:

git push  build image  push to registry  update deployment

Each deployment is an immutable artefact. Rolling back means deploying a previous image tag.

Multi-Stage Dockerfile

The build happens in two stages:

FROM python:3.12-slim AS builder

WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY content/ ./content/
COPY themes/ ./themes/
COPY pelican-plugins/ ./pelican-plugins/
COPY pelicanconf.py publishconf.py ./

RUN pelican content -o output -s publishconf.py

FROM nginx:alpine
COPY --from=builder /build/output /usr/share/nginx/html

The first stage installs Pelican and generates HTML. The second stage copies only the output to a minimal nginx image. The final image contains no Python code, no source files, and is just static HTML served by nginx.

Kubernetes Deployment

The deployment is straightforward:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pelican
spec:
  replicas: 1
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    spec:
      containers:
      - name: pelican
        image: registry.example.com/blog:20260129-120000
        ports:
        - containerPort: 80
        livenessProbe:
          httpGet:
            path: /
            port: 80
        readinessProbe:
          httpGet:
            path: /
            port: 80

Key points:

  • RollingUpdate with maxUnavailable: 0 ensures zero downtime
  • Health probes verify that site is actually serving
  • Image tags use timestamps for easy identification

Automating Deployment

A git post-receive hook triggers the build:

while read oldrev newrev refname; do
    branch=$(echo "$refname" | sed 's|refs/heads/||')

    if [ "$branch" = "main" ]; then
        TAG=$(date +%Y%m%d-%H%M%S)

        docker build -f Dockerfile.k8s \
            -t registry.example.com/blog:$TAG .
        docker push registry.example.com/blog:$TAG

        kubectl set image deployment/pelican \
            pelican=registry.example.com/blog:$TAG
    fi
done

Push to main, grab a coffee, the site is updated.

Production nginx Configuration

Pelican's built-in server is fine for previewing. Production deserves proper caching:

server {
    listen 80;
    root /usr/share/nginx/html;

    gzip on;
    gzip_types text/plain text/css application/javascript;

    location ~* \.(css|js|png|jpg|svg)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location ~* \.html$ {
        expires 1h;
        add_header Cache-Control "public, must-revalidate";
    }
}

Static assets get long cache times. HTML files get shorter times, so content updates propagate quickly.

Testing Locally

Before deploying, test the production build:

docker build -f Dockerfile.k8s -t blog-test .
docker run --rm -p 8080:80 blog-test

Visit http://localhost:8080 and verify everything renders correctly. This catches issues like missing assets or broken links before they reach production.

Trade-offs

This approach has costs:

  • Build time: Each push rebuilds the entire site (~30 seconds for a small blog)
  • Image size: Each image contains all content (~50MB for nginx:alpine + HTML)
  • Registry storage: Old images accumulate (mitigate with retention policies)

The benefits:

  • Immutability: Every deployment is reproducible
  • Portability: Runs on any node in the cluster
  • Rollback: Previous versions are just kubectl set image away
  • No runtime dependencies: nginx serves files, nothing else needed

Conclusion

Deploying Static Sites in Kubernetes — Pelican blog to immutable container image to zero-downtime cluster deployment

Static sites and Kubernetes pair well when you treat content as build-time configuration rather than runtime data. The baked-in approach trades build complexity for operational simplicity.

Photo by Birger Strahl on Unsplash