user@homelab:~/docs$ cat docs-site.md

This site

Hugo, two languages, a non-root nginx image and pod security

# contents

Architecture

text
Markdown (docs-site/site/content/{en,cs}/*.md)
   │  hugo --minify                     ← build stage: alpine + Hugo verified via SHA256
   ▼
static HTML/CSS/JS
   │  COPY into nginx-unprivileged      ← runtime stage: uid 101, port 8080
   ▼
ghcr.io/<owner>/homelab-docs:<tag>      ← GitHub Actions
   │  Flux → Deployment → Service → Ingress (Traefik)
   ▼
https://svejklab.dev                    ← Cloudflare Tunnel

Building and deployment are covered in CI/CD and GitOps.

Layout

text
docs-site/
├── site/
│   ├── hugo.toml             # configuration, languages en (default) + cs
│   ├── content/en/           # English pages
│   ├── content/cs/           # Czech pages (same file names)
│   ├── i18n/                 # template strings (en.toml, cs.toml)
│   ├── data/status_*.yaml    # front page tables
│   ├── static/images/        # diagrams (SVG)
│   ├── layouts/              # custom "terminal" theme
│   └── assets/               # CSS and JS
├── nginx/default.conf        # security headers, /healthz
└── Dockerfile                # multi-stage build

The theme is custom, with no external themes or CDNs. The site loads nothing from third-party domains.

Two languages

Hugo has built-in multilingual support. Pages are paired by identical file names (content/en/docs/07-ssh.md ↔ content/cs/docs/07-ssh.md), and the switch in the header always leads to the same page in the other language.

English (default)Czech
URLhttps://svejklab.dev/https://svejklab.dev/cs/
contentcontent/en/content/cs/
template stringsi18n/en.tomli18n/cs.toml
front page tablesdata/status_en.yamldata/status_cs.yaml
toml @ git
defaultContentLanguage = "en"
defaultContentLanguageInSubdir = false   # English at /, Czech at /cs/

Each page carries hreflang links in its head, so search engines know the pages are language versions of each other.

Image

The Dockerfile has two stages:

  1. build: Alpine downloads Hugo from GitHub, verifies its SHA256 and renders the Markdown into a static site (hugo --minify --gc),
  2. runtime: the generated HTML is copied into nginx-unprivileged, which runs without root on port 8080.

The final image contains only nginx and static files; no Hugo and no source content.

nginx (nginx/default.conf):

  • /healthz for the readiness and liveness probes,
  • security headers: Content-Security-Policy (own scripts and styles only), X-Content-Type-Options, Referrer-Policy, Permissions-Policy,
  • CSS and JS carry a content fingerprint in their names, so they are cached forever; HTML is always revalidated (no-cache).

Pod security

SettingWhy
namespace pod-security…/enforce: restrictedthe cluster rejects pods that do not meet the strict profile
runAsNonRoot, uid 101nginx does not run as root
readOnlyRootFilesystem + emptyDir /tmpthe container cannot modify its filesystem
capabilities: drop ALL, allowPrivilegeEscalation: falseno special kernel privileges
seccompProfile: RuntimeDefaultrestricted system calls
automountServiceAccountToken: falsethe pod has no Kubernetes API token
imagePullSecrets: ghcr-pullthe image is pulled from a private registry
requests/limitswithout swap a memory limit is a must
readiness/liveness on /healthzKubernetes knows the site is alive
rolling update with maxUnavailable: 0new versions without downtime

Editing content

Content lives in docs-site/site/content/{en,cs}/ and is deployed with git push, see CI/CD and GitOps. Local preview:

shell @ mac
cd ~/Projects/homelab/docs-site/site && hugo server

Access