secure k8 s

Hosting this site on my own cluster

18. Mai 2026 · self-hosting · helm · nginx · hardening · psa

A blog about Kubernetes security loses some credibility if it runs on someone else’s static-site hosting. So this site runs on my own cluster, behind my own ingress, in a namespace that enforces Pod Security Standards restricted. This post walks through the whole stack, from astro build to the cert showing up in the browser.

The stack at a glance

flowchart LR
  A[Astro 5<br/>content collections, MDX] -->|astro build| B[dist/]
  B -->|multi-stage Docker| C[nginxinc/nginx-unprivileged<br/>:linux/amd64+arm64]
  C -->|buildx push| D[Docker Hub<br/>nyrvex/securek8s]
  D -->|helm install| E[Hetzner RKE2 cluster]
  E -->|cert-manager + LE| F[https://securek8s.de]

No dynamic backend, no database, no CMS. The pipeline is Markdown → HTML → container image → Kubernetes Pod. Content is in git; git is the backup.

The container

Two stages. The build stage uses node:22-alpine and runs npm ci and astro build. The runtime stage uses nginxinc/nginx-unprivileged:1.27-alpine, copies dist/ into /usr/share/nginx/html, and runs as UID 101.

# syntax=docker/dockerfile:1.7
FROM node:22-alpine@sha256:... AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build

FROM nginxinc/nginx-unprivileged:1.27-alpine@sha256:... AS runtime
USER 101
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8088
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://127.0.0.1:8088/healthz || exit 1

Two details that matter:

  • USER 101 in the runtime stage, but nginxinc/nginx-unprivileged already runs as 101 by default. The redundancy is intentional — it makes the security posture readable from the Dockerfile alone, without trusting the base image.
  • Digest-pinned FROM lines. Tags float. Floating tags in the runtime layer mean my next rebuild could silently pull a different base image. Digest pins break that. The trade-off is that I have to lift the digests deliberately, so base image security updates become a process step instead of magic.

Image is multi-arch: linux/amd64 for the cluster, linux/arm64 for local development. I learned that the hard way after my first push from a Mac landed arm64-only on amd64 Hetzner workers and produced an instant ErrImagePull with the great error message no match for platform in manifest. The fix is one flag:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t docker.io/nyrvex/securek8s:$TAG \
  -t docker.io/nyrvex/securek8s:latest \
  --push .

The nginx.conf

The point of the runtime layer is to serve static files, fast, with strict headers, on a read-only root filesystem.

The read-only rootfs requires moving everything nginx wants to write into emptyDir mounts: /tmp for client body and proxy buffers, /var/cache/nginx for the file cache, /var/run for the PID file. nginx normally puts these in hardcoded paths the unprivileged base doesn’t override fully; the config does:

client_body_temp_path /tmp/client_body;
proxy_temp_path       /tmp/proxy;
fastcgi_temp_path     /tmp/fastcgi;
uwsgi_temp_path       /tmp/uwsgi;
scgi_temp_path        /tmp/scgi;
pid /tmp/nginx.pid;

The listen line is 8088 (unprivileged), and the response headers are the boring-but-correct set:

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'" 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=(), interest-cohort=()" always;
server_tokens off;

The CSP is strict — no unsafe-inline, no unsafe-eval. That’s free for a static site because Astro doesn’t inline scripts, doesn’t ship runtime CSS-in-JS, and the only client JS (Mermaid + a tiny copy-button) is bundled into same-origin files. No third-party scripts, no font CDN, no analytics. The CSP allows self and nothing else.

The Pod

securityContext:
  runAsNonRoot: true
  runAsUser: 101
  runAsGroup: 101
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]
  seccompProfile:
    type: RuntimeDefault

Six lines of “the obvious answer” but only because PSA restricted is keeping me honest. The namespace label

pod-security.kubernetes.io/enforce: restricted

is the enforcement; the container securityContext is the implementation. If I forgot one of those lines, admission would reject the pod at apply time, not silently at runtime.

A few other things on the Pod that are worth mentioning:

  • automountServiceAccountToken: false. A static-file server has no business with an API token. Default-mounting the token is a long-standing Kubernetes footgun.
  • Resource requests 50m/64Mi, limits 200m/128Mi. Generous for an idle nginx, but matches what I want the scheduler to plan around.
  • Probes on /healthz, which the nginx config returns as a plain 200 ok. The probe path is unauthenticated — fine for a public static site, would be a bug for anything sensitive.

The Helm chart

The chart that installs this lives at charts/securek8s/ in the same repo. Its shape is conventional:

charts/securek8s/
├── Chart.yaml
├── values.yaml
└── templates/
    ├── namespace.yaml
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── networkpolicy.yaml
    ├── pdb.yaml
    └── NOTES.txt

The namespace template is a tiny but loaded thing. It creates the target namespace with PSA labels in one shot:

apiVersion: v1
kind: Namespace
metadata:
  name: {{ .Release.Namespace }}
  labels:
    pod-security.kubernetes.io/enforce: {{ .Values.namespace.podSecurity.enforce | quote }}
    pod-security.kubernetes.io/audit:   {{ .Values.namespace.podSecurity.audit   | quote }}
    pod-security.kubernetes.io/warn:    {{ .Values.namespace.podSecurity.warn    | quote }}

It also runs into the Helm gotcha that, if the namespace doesn’t already exist, helm install --namespace ns fails because it can’t write the Release Secret into a namespace that isn’t there. The two-step workaround that worked for me was to kubectl create namespace securek8s and label it manually first, then helm install --set namespace.create=false. A cleaner fix is to add a helm.sh/hook: pre-install,pre-upgrade annotation on the namespace template so Helm creates it before bootstrapping the release. That’s on my list.

Deployment flow

There’s a Makefile that wraps the whole thing:

make image push REGISTRY=docker.io/nyrvex  # build + push multi-arch image
make helm-install                          # initial install (chart creates ns)
make release                               # rebuild + push + upgrade + watch rollout

make release is the one I use day-to-day. It re-tags with the current git SHA, pushes, runs helm upgrade --reuse-values --set image.tag=<sha>, and streams kubectl rollout status until the new ReplicaSet is healthy. With maxSurge: 1, maxUnavailable: 0 and a PodDisruptionBudget of minAvailable: 1, there’s no client-visible downtime.

What I haven’t done yet

A list of things that belong on a security site and aren’t here yet:

  • Image signing with cosign. The chart should verify the image at admission time via a ClusterImagePolicy or Kyverno rule.
  • SBOM at build time. docker buildx build --sbom=true --provenance=true and an attestation policy on the cluster.
  • GitOps. Right now make release from a laptop is the deploy path. ArgoCD watching this repo would be the right model, especially once the chart is signed too.
  • Cilium. As above. Would also fix the NetworkPolicy gap.
  • Separate dev/prod overlays. A values-staging.yaml and a staging hostname would let me catch admission-policy regressions before they reach the public site.

Each of those is a future post. Or a series.