Hosting this site on my own cluster
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 101in the runtime stage, butnginxinc/nginx-unprivilegedalready 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
FROMlines. 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, limits200m/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 plain200 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
ClusterImagePolicyor Kyverno rule. - SBOM at build time.
docker buildx build --sbom=true --provenance=trueand an attestation policy on the cluster. - GitOps. Right now
make releasefrom 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.yamland 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.