Hugo End-to-End Security

๐Ÿ” Hardened Hugo + NGINX Static Site on Kubernetes (PSS Restricted)

This guide provides a production-grade, defense-in-depth setup for deploying a Hugo static site using NGINX inside Kubernetes with:

  • Non-root container
  • Read-only root filesystem
  • Dropped Linux capabilities
  • Seccomp RuntimeDefault
  • No privilege escalation
  • Secure NGINX configuration
  • Writable ephemeral mounts only where required
  • NetworkPolicy isolation
  • Image scanning & signing
  • GitOps-ready deployment

Secure Dockerfile

# ---------- Stage 1: Build Hugo site ----------
FROM hugomods/hugo:0.157.0 AS builder

WORKDIR /site
COPY . .
RUN hugo --minify

# ---------- Stage 2: Hardened NGINX ----------
FROM nginx:1.28-alpine

# Remove default config
RUN rm /etc/nginx/conf.d/default.conf

# Create writable directories required by nginx
RUN mkdir -p /var/cache/nginx \
    && mkdir -p /var/run \
    && chown -R nginx:nginx /var/cache/nginx /var/run /usr/share/nginx/html

# Copy static site
COPY --from=builder /site/public /usr/share/nginx/html

# Copy hardened nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Fix ownership and permissions
RUN chown -R nginx:nginx /usr/share/nginx/html \
    && chmod -R 755 /usr/share/nginx/html

# Run as non-root
USER nginx

EXPOSE 8080

CMD ["nginx", "-g", "daemon off;"]

Hardened nginx.conf

server {
    listen 8080 default_server;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    server_tokens off;

    access_log /dev/stdout;
    error_log  /dev/stderr warn;

    gzip_static on;

    # Security headers
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    location ~* \.html$ {
        add_header Cache-Control "no-cache";
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Hardened Kubernetes Deployment

apiVersion: v1
kind: Namespace
metadata:
  name: uclab
  labels:
    pod-security.kubernetes.io/enforce: restricted
apiVersion: apps/v1
kind: Deployment
metadata:
  name: uclab
  namespace: uclab
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: uclab
  template:
    metadata:
      labels:
        app: uclab
    spec:
      automountServiceAccountToken: false

      imagePullSecrets:
        - name: forgejo-registry

      securityContext:
        runAsNonRoot: true
        runAsUser: 101
        runAsGroup: 101
        fsGroup: 101
        seccompProfile:
          type: RuntimeDefault

      containers:
        - name: nginx
          image: forgejo.uclab.dev/affragak/uclab:338e164

          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL

          ports:
            - containerPort: 8080
              protocol: TCP

          resources:
            requests:
              cpu: 50m
              memory: 32Mi
            limits:
              cpu: 200m
              memory: 64Mi

          readinessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10

          livenessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 30

          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: nginx-cache
              mountPath: /var/cache/nginx
            - name: nginx-run
              mountPath: /var/run

      restartPolicy: Always

      volumes:
        - name: tmp
          emptyDir: {}
        - name: nginx-cache
          emptyDir: {}
        - name: nginx-run
          emptyDir: {}

NetworkPolicy (Zero Trust Model)

Allow ingress only from cloudflared pods:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: uclab-restrict
  namespace: uclab
spec:
  endpointSelector:
    matchLabels:
      app: uclab

  ingress:
    - fromEndpoints:
        - matchLabels:
            app: cloudflared
      toPorts:
        - ports:
            - port: "8080"
              protocol: TCP

  egress:
    # Allow DNS to kube-dns
    - toEndpoints:
        - matchLabels:
            k8s:k8s-app: kube-dns
      toPorts:
        - ports:
            - port: "53"
              protocol: UDP
            - port: "53"
              protocol: TCP

Image Security

Scan Image

Use Trivy in CI:

trivy image forgejo.uclab.dev/affragak/uclab:338e164

Sign Image

cosign sign forgejo.uclab.dev/affragak/uclab:338e164

Verify in Cluster (optional admission policy)

Use Kyverno or Cosign policy-controller.


CI/CD Best Practices

  • Build images
  • Scan image
  • Sign image
  • Push to private registry
  • Deploy via GitOps
# .forgejo/workflows/build.yml
name: Build and Deploy
on:
  push:
    branches: [main]
env:
  REGISTRY: forgejo.uclab.dev
  IMAGE: forgejo.uclab.dev/affragak/uclab
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Hugo repo (with submodules)
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Set image tag
        run: |
          SHORT_SHA=$(git rev-parse --short HEAD)
          echo "TAG=${SHORT_SHA}" >> $GITHUB_ENV

      - name: Login to Forgejo registry
        run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin

      - name: Build and push image
        run: |
          docker build -t ${{ env.IMAGE }}:${{ env.TAG }} .
          docker push ${{ env.IMAGE }}:${{ env.TAG }}

      - name: Scan image with Trivy
        run: |
          # Install via package manager instead of curl
          apt-get install -y wget apt-transport-https gnupg
          wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor > /usr/share/keyrings/trivy.gpg
          echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" > /etc/apt/sources.list.d/trivy.list
          apt-get update && apt-get install -y trivy
          trivy image \
            --exit-code 0 \
            --severity HIGH,CRITICAL \
            --format table \
            --ignore-unfixed \
            ${{ env.IMAGE }}:${{ env.TAG }}

      - name: Sign image with Cosign
        env:
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
        run: |
          curl -sLO https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
          chmod +x cosign-linux-amd64
          mv cosign-linux-amd64 /usr/local/bin/cosign
          echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > /tmp/cosign.key
          cosign sign --yes --key /tmp/cosign.key \
            --registry-username ${{ secrets.REGISTRY_USER }} \
            --registry-password ${{ secrets.REGISTRY_TOKEN }} \
            ${{ env.IMAGE }}:${{ env.TAG }}
          rm /tmp/cosign.key

      - name: Setup SSH for GitHub
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.GH_DEPLOY_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan github.com >> ~/.ssh/known_hosts

      - name: Clone k3s repo and update image tag
        run: |
          git clone [email protected]:affragak/pi5cluster.git /tmp/pi5cluster
          cd /tmp/pi5cluster
          git config user.name "forgejo-ci"
          git config user.email "[email protected]"
          DEPLOY_FILE="blog/base/uclab/deployment.yaml"
          sed -i "s|image: ${{ env.IMAGE }}:.*|image: ${{ env.IMAGE }}:${{ env.TAG }}|" $DEPLOY_FILE
          git add $DEPLOY_FILE
          git diff --staged --quiet && echo "No changes to commit" && exit 0
          git commit -m "chore: deploy hugo ${{ env.TAG }}"
          git push

Pod Security Compliance Checklist

โœ… runAsNonRoot
โœ… No privilege escalation
โœ… Drop ALL capabilities
โœ… RuntimeDefault seccomp
โœ… readOnlyRootFilesystem
โœ… No service account token
โœ… Minimal resource limits
โœ… NetworkPolicy enforced

This configuration aligns with Kubernetes Pod Security Standard: restricted.


Optional Advanced Hardening

  • Use distroless or Chainguard NGINX
  • Enforce Content-Security-Policy header
  • Enable read-only root filesystem at cluster level
  • Use admission controller to enforce securityContext
  • Enable audit logging
  • Use runtime security (Falco, Cilium Tetragon)

Final Security Posture

You now have:

  • Secure supply chain
  • Hardened container runtime
  • Minimal attack surface
  • Zero-trust networking
  • PSS restricted compliance
  • GitOps automation ready

This is production-grade static workload security.