user@homelab:~/docs$ cat docs-site.md
This site
Hugo, two languages, a non-root nginx image and pod security
# contents
Architecture
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 TunnelBuilding and deployment are covered in CI/CD and GitOps.
Layout
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 buildThe 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 | |
|---|---|---|
| URL | https://svejklab.dev/ | https://svejklab.dev/cs/ |
| content | content/en/ | content/cs/ |
| template strings | i18n/en.toml | i18n/cs.toml |
| front page tables | data/status_en.yaml | data/status_cs.yaml |
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:
- build: Alpine downloads Hugo from GitHub, verifies its SHA256 and renders the Markdown into a static site (
hugo --minify --gc), - 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):
/healthzfor 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
| Setting | Why |
|---|---|
namespace pod-security…/enforce: restricted | the cluster rejects pods that do not meet the strict profile |
runAsNonRoot, uid 101 | nginx does not run as root |
readOnlyRootFilesystem + emptyDir /tmp | the container cannot modify its filesystem |
capabilities: drop ALL, allowPrivilegeEscalation: false | no special kernel privileges |
seccompProfile: RuntimeDefault | restricted system calls |
automountServiceAccountToken: false | the pod has no Kubernetes API token |
imagePullSecrets: ghcr-pull | the image is pulled from a private registry |
| requests/limits | without swap a memory limit is a must |
readiness/liveness on /healthz | Kubernetes knows the site is alive |
rolling update with maxUnavailable: 0 | new 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:
cd ~/Projects/homelab/docs-site/site && hugo serverAccess
- public: https://svejklab.dev via Cloudflare Tunnel, see Public site,
- on the home network:
http://<server-ip>/(the Ingress has a host-less rule).