πŸ“šBook Signing at KubeCon EU 2026Meet us at Booking.com HQ (Mon 18:30-21:00) & vCluster booth #521 (Tue 24 Mar, 12:30-1:30pm) β€” free book giveaway!RSVP Booking.com Event
Deployments beginner ⏱ 15 minutes K8s 1.31+

Hugo nginx Static Site on a k3s Cluster

Deploy a Hugo static site with nginx on k3s. Multi-stage build, Brotli compression, security headers, and automated redeployment on git push via Gitea Actions.

By Luca Berton β€’ β€’ πŸ“– 5 min read

πŸ’‘ Quick Answer: Build Hugo sites in CI, serve with nginx in a minimal container (<15MB). Brotli pre-compression + aggressive caching delivers sub-100ms TTFB for static content.

The Problem

You need a fast, secure static site serving layer that:

  • Serves pre-built Hugo output with optimal compression
  • Returns proper security headers (CSP, HSTS, X-Frame-Options)
  • Handles SPA-style 404 fallbacks for Hugo’s pretty URLs
  • Rebuilds and redeploys automatically on git push

The Solution

Multi-stage Docker build (Hugo β†’ nginx:alpine) deployed as a Kubernetes Deployment with resource limits and liveness probes.

Architecture

graph LR
    A[Hugo Source] -->|git push| B[Gitea Actions]
    B -->|build| C[Hugo CLI]
    C -->|output| D[public/]
    D -->|COPY| E[nginx:alpine Image]
    E -->|push| F[quay.io]
    F -->|deploy| G[k3s Pod]
    G --> H[HTTPRoute]
    H --> I[Users]

Step 1: Dockerfile (Multi-Stage Build)

# Dockerfile
FROM hugomods/hugo:exts-0.142.0 AS builder
WORKDIR /src
COPY . .
RUN hugo --minify --gc

FROM nginx:1.27-alpine
# Remove default config
RUN rm /etc/nginx/conf.d/default.conf
COPY --from=builder /src/public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/site.conf

# Pre-compress static assets
RUN apk add --no-cache brotli && \
    find /usr/share/nginx/html -type f \( -name "*.html" -o -name "*.css" -o -name "*.js" -o -name "*.svg" -o -name "*.xml" -o -name "*.json" \) \
    -exec brotli --best {} \; && \
    apk del brotli

EXPOSE 80

Step 2: nginx Configuration

# nginx.conf
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    
    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
    
    # Brotli pre-compressed files
    brotli_static on;
    gzip_static on;
    
    # Aggressive caching for assets
    location ~* \.(css|js|woff2|png|jpg|webp|avif|svg|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
    
    # HTML pages β€” short cache for fast updates
    location ~* \.html$ {
        expires 1h;
        add_header Cache-Control "public, must-revalidate";
    }
    
    # Hugo pretty URLs β€” try file, then directory/index.html, then 404
    location / {
        try_files $uri $uri/index.html =404;
    }
    
    # Custom 404 page
    error_page 404 /404.html;
    
    # Health check endpoint
    location /healthz {
        return 200 "ok";
        add_header Content-Type text/plain;
    }
}

Step 3: Kubernetes Deployment

# hugo-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hugo-nginx
  namespace: website
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hugo-nginx
  template:
    metadata:
      labels:
        app: hugo-nginx
    spec:
      containers:
        - name: nginx
          image: quay.io/myorg/website:latest
          ports:
            - containerPort: 80
          livenessProbe:
            httpGet:
              path: /healthz
              port: 80
            initialDelaySeconds: 5
            periodSeconds: 30
          readinessProbe:
            httpGet:
              path: /healthz
              port: 80
            initialDelaySeconds: 2
            periodSeconds: 10
          resources:
            requests:
              memory: 16Mi
              cpu: 10m
            limits:
              memory: 64Mi
              cpu: 100m
          securityContext:
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 101  # nginx user
            allowPrivilegeEscalation: false
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: cache
              mountPath: /var/cache/nginx
            - name: pid
              mountPath: /var/run
      volumes:
        - name: tmp
          emptyDir: {}
        - name: cache
          emptyDir: {}
        - name: pid
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: hugo-nginx
  namespace: website
spec:
  selector:
    app: hugo-nginx
  ports:
    - port: 80

Step 4: Gitea Actions Workflow

# .gitea/workflows/deploy.yaml
name: Build and Deploy Hugo Site
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: quay.io/myorg/website:${{ github.sha }},quay.io/myorg/website:latest

      - name: Restart deployment
        run: |
          kubectl set image deployment/hugo-nginx \
            nginx=quay.io/myorg/website:${{ github.sha }} \
            -n website

Common Issues

IssueCauseFix
403 ForbiddenRoot filesystem read-onlyMount tmpdir volumes for nginx
Brotli not workingModule not loadedUse nginx:alpine with brotli_static (pre-compressed)
Pod OOMKilledMemory limit too low64Mi is enough for static; check for memory leaks
Stale content after deployImage cachingUse SHA tags, not latest
Hugo build failsMissing submodulesfetch-depth: 0 + submodules: recursive

Best Practices

  1. Pin Hugo version in Dockerfile β€” reproducible builds across environments
  2. Pre-compress with Brotli at build time β€” zero CPU overhead at serve time
  3. Read-only root filesystem β€” nginx only needs tmp/cache/pid as writable
  4. Use SHA-based image tags β€” latest causes caching issues
  5. 16Mi request is enough β€” nginx serving static files is incredibly lightweight

Key Takeaways

  • Hugo + nginx:alpine = ~15MB final image β€” fast pulls, minimal attack surface
  • Brotli pre-compression saves 15-25% over gzip with zero runtime cost
  • Read-only root filesystem + non-root user = hardened serving layer
  • Automated git push β†’ build β†’ deploy takes <60 seconds end to end
  • Single pod handles thousands of req/s for static content β€” no need for HPA
#hugo #nginx #static-site #k3s #deployment #brotli
Luca Berton
Written by Luca Berton

Principal Solutions Architect specializing in Kubernetes, AI/GPU infrastructure, and cloud-native platforms. Author of Kubernetes Recipes and creator of CopyPasteLearn courses.

Kubernetes Recipes book cover

Want More Kubernetes Recipes?

This recipe is from Kubernetes Recipes, our 750-page practical guide with hundreds of production-ready patterns.

Luca Berton Ansible Pilot Ansible by Example Open Empower K8s Recipes Terraform Pilot CopyPasteLearn ProteinLens