Single-binary Go service that renders markdown pages from a runtime volume mount. Targeted at public, no-auth, no-WAF deployment behind a TLS ingress; security posture is defense-in-depth at every layer: - goldmark with no WithUnsafe — raw HTML in author markdown is stripped - CSP without 'unsafe-inline', plus HSTS, COOP, CORP, Permissions-Policy - static handler rejects non-GET/HEAD, directory listings, dotfiles, traversal - content loader rejects symlinks that escape the content root, dotfiles, and .md files larger than 1 MiB - per-page template trees (cloned from layout) so define-blocks don't collide between home/category/page - SIGHUP triggers atomic library swap — live edits on volume, no rebuild Locale layout content/<locale>/<category>/<slug>.md. Categories without _index.md still appear on the home page with a humanized name. Search is a ~70-line vanilla JS scan over /search.json?lang=<locale>; swap for a real indexer if the corpus ever balloons. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
automc-tutorials
Public-facing tutorials site for the automc Minecraft platform. Renders markdown from a mounted volume as a localized static-ish web app. No auth, no database, no state — exposed to the open internet behind TLS, hardened for zero-trust.
Stack
- Go +
net/http+html/template— single binary - goldmark — markdown → HTML (GFM + footnotes + auto heading IDs)
- Plain CSS — no framework, dark mode via
prefers-color-scheme - Vanilla JS — ~70-line client-side substring search, no dependency
- Content lives on a mounted volume; reload via SIGHUP
Layout
cmd/automc-tutorials/main.go — entry: flags, signals, SIGHUP reload
internal/content/ — loader + path-traversal guard for content/<locale>/<category>/<slug>.md
internal/render/ — goldmark wrapper (HTML escaping on)
internal/server/ — mux, handlers, embedded templates + /static/ bundle
content/<locale>/<category>/ — markdown source. _index.md is the category landing page (optional).
Adding or editing a tutorial
The image carries only the binary. Markdown sits on a volume mounted at /content (default in the image; override with CONTENT_DIR). Workflow:
-
Open an admin shell on the host that owns the PVC (or shell into a pod that mounts the same RWX volume).
-
Drop / edit
.mdfiles under<volume>/<locale>/<category>/. -
Add YAML frontmatter:
--- title: Page title summary: One-sentence preview shown on category list. order: 1 --- -
Write markdown. GFM tables, fenced code, footnotes, autolinks, task lists all work. Raw HTML in markdown is escaped — don't try to embed
<script>or<iframe>, they'll render as text. -
Reload without restart:
kill -HUP $(pidof automc-tutorials)inside the container, orkubectl exec deploy/automc-tutorials -- kill -HUP 1.
No rebuild, no rollout. The atomic library swap means in-flight requests keep serving the old library; new requests see the new one.
Localization
Each top-level directory under the content root is a locale. Pages are independent per locale — translations are not auto-mapped. The header has a locale switcher that just changes /<locale>/ in the URL.
If a page exists in en but not cs, switching to cs lands on the cs home (no automatic redirect, no fallback render).
Search
Client-side substring search over title, summary, and body. Index served at GET /search.json?lang=<locale> — flat array of {path, title, summary, body}. static/search.js does a case-insensitive linear scan with crude scoring (title hit = 100, summary = 10, body = 1) and shows the top 8.
This is fine for a few hundred pages. If the corpus ever balloons, swap the in-browser scan for a real indexer — the endpoint contract is stable.
Configuration
| Var | Default | Purpose |
|---|---|---|
ADDR |
:8080 |
HTTP listen address |
CONTENT_DIR |
/content |
Path to content root (volume mount in production, ./content in dev) |
DEFAULT_LOCALE |
en |
Locale used when none in URL (/ → /<locale>/) |
Zero-trust posture
The service is intended to sit behind TLS (k8s ingress) on the open internet, with no auth and no WAF. Hardening:
- No HTML in markdown.
goldmark.WithUnsafe()is not enabled, so raw HTML in.mdis escaped. - CSP restricts everything to
'self';'unsafe-inline'is absent (templates carry no inline styles or scripts). Frame ancestors blocked, base/form-action pinned to self. - HSTS, COOP, CORP, Permissions-Policy all set conservatively.
- No directory listing on
/static/; only GET/HEAD; hidden files (/.foo) blocked. - Content loader rejects symlinks that escape the content root, hidden files, and
.mdfiles larger than 1 MiB. - HTTP timeouts are set on read header, read body, write, and idle.
- Distroless static, runs as 65532:65532, no shell, no package manager.
- No outbound calls — the service never reaches out to anything.
Build
make build # binary
make dev # go run, no build artifact
make test # go test ./...
make docker # Docker image
Deployment
Single binary, distroless image, port 8080, no persistent state. Deploy as a normal k8s Deployment + Service + Ingress for tutorials.timemachine.center. Mount the content PVC read-only at /content. No DB, no auth, no secrets.
Repo
Standalone Gitea repo at git.timemachine.center/Timemachine/automc-tutorials. Sibling of the automc orchestration repo.