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.

my DevOps Odyssey

“Σα βγεις στον πηγαιμό για την Ιθάκη, να εύχεσαι να ‘ναι μακρύς ο δρόμος, γεμάτος περιπέτειες, γεμάτος γνώσεις.” - Kavafis’ Ithaka.