🔐 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.