Build and publish the documentation image
The public image serves a curated Zensical site with nginx. The final image
contains the checked static artifact and nginx, with no Python or repository
sources. The complete private documentation remains available as Markdown in
Git and through the normal zensical build --clean --strict CI check.
Public sources
docs/public-pages.json is the publication allowlist and navigation definition.
The public site is a portfolio, so it includes most pages: the architecture and
platform pages, all ADRs, the runbooks, the service guides, and these
repository pages. Adding a page requires reviewing its complete content and
referenced assets for personal data and credentials.
scripts/ci/public-docs.py assembles those pages and their referenced assets
under tmp/public-docs/docs, with a separate zensical.toml and navigation.
That file copies the theme, Markdown extensions, extra, and extra_css from
the main zensical.toml without pymdownx.snippets, so both sites look the
same. The assembly also copies the listed stylesheets, which must be .css
files under docs, and the template overrides in theme.custom_dir
(overrides/), which must be .html files.
It clears that build directory before each assembly. Markdown links to excluded
pages become plain labels in the assembled copy. The repository sources stay
unchanged. Includes, raw HTML, reference-style links, unsupported asset types,
and paths outside docs fail assembly.
The build uses only the assembled directory. Removing a page from navigation
alone does not prevent publication, as the Zensical navigation documentation
explains. The artifact check compares HTML, search locations, and sitemap URLs
with the allowlist. It rejects private page references, unexpected pages and
downloads, missing public pages, and symlinks. Generated theme assets must match the
file names and SHA-256 hashes in scripts/ci/public-docs-assets.json. A Zensical
upgrade requires reviewing and updating that inventory. Zensical's generated 404.html
is the only extra HTML page. The checker expects the search format of the pinned
Zensical version and fails if that format changes.
The docs jobs in .github/workflows/validate.yaml and validate-push.yaml
build both sites. Dockerfile runs the public build and artifact check before
copying its result into the runtime image. The image workflow therefore fails
before publication if that check fails. CI regression tests inject forbidden
pages into HTML, search, and sitemap to verify the boundary.
Publication review
Internal addresses, host names, and internal DNS names are public. They
document topology and grant no access. Bucket, project, and service account
names are public for the same reason. The allowlisted pages and
hardware/netbox-devices.csv contain no personal names beyond the owner's
handle, email addresses, phone numbers, home address, home public IP, secret
values, or credentials. The CSV's contact, owner, serial-number, asset-tag,
and geographic-coordinate fields are empty.
These pages stay private. The table names them by title because the artifact check rejects any private page path in the public HTML.
| Page | Reason |
|---|---|
| Kubernetes and GitOps | Contains a personal name and an unresolved incident journal. |
| Operate Hermes Ops | Describes an unauthenticated internal service. |
| Retire LanCache | A finished one-time procedure. |
| Glossary | Includes the private CONTEXT.md. Public builds disable includes. |
Build locally
Install requirements-docs.txt, then run:
zensical build --clean --strict
python3 scripts/ci/public-docs.py build
task docs:image
task docs:image:run
Open http://127.0.0.1:8080. Stop the container with Ctrl+C.
The runtime listens on 8080 as UID and GID 101. A read-only root filesystem
needs only a writable /tmp, capped at 16 MiB in the staging Deployment.
Unknown paths return HTTP 404.
To check files copied from a built container, run:
Publish to GHCR
.github/workflows/docs-image.yaml builds relevant pull requests without
publishing. Pushes to main, v* tags, and manual runs publish to
ghcr.io/igresc/homelab-docs using the job's GITHUB_TOKEN.
Full sha-<commit> tags identify builds. Only a current branch build updates
its mutable tags, and only the default branch updates latest.
The package remains private. No publishing PAT is required.
The old Gitea workflow remains during forge retirement. It uses the same Dockerfile, so its images also contain the allowlisted site, but staging pulls from GHCR.
Activate staging
kubernetes/apps/staging/homelab-docs is deliberately absent from both staging
Kustomizations. Its Deployment must pin an existing curated GHCR SHA tag or
digest before activation. It has probes, resource limits, no mounted service
account token, and a read-only root filesystem.
The Deployment pulls with the cluster-wide ghcr-pull Secret. A
ClusterExternalSecret in kubernetes/infrastructure/staging/registry-auth
creates it in every namespace. It templates a kubernetes.io/dockerconfigjson
from kv/infra/github/ghcr-pull, properties username and token, using
External Secrets templating.
Any staging workload that needs a private GHCR image lists
imagePullSecrets: [{name: ghcr-pull}]. The ClusterSecretStore
openbao-ghcr-pull authenticates to production OpenBao as the
external-secrets/ghcr-pull-reader service account, through the existing
jwt-staging-grafana mount, role staging-ghcr-pull, audience vault.
terraform/staging_registry_secrets.tf limits that role to the pull path and
token self-service. The later JWKS migration must move this store and role
with the other staging users.
Activation requires these separately approved changes:
- Review a fresh full OpenTofu plan. Apply the GHCR pull policy and JWT role,
resolving unrelated drift separately. Confirm the pull credential exists in
OpenBao and has GHCR package read access without printing its values.
Check that ClusterSecretStore
openbao-ghcr-pullis Ready. Thehomelab-docsnamespace doesn't exist yet, so check the generated ExternalSecret andghcr-pullSecret in an existing namespace, such asdefault. - Add
./homelab-docstokubernetes/apps/staging/kustomization.yaml. TheappsFlux Kustomization doesn't depend onstaging-registry-auth. Both reconcile at the same time, so the first pulls can fail until the Secret exists. - On
talos-test, wait until thehomelab-docsExternalSecret is Ready and theghcr-pullSecret exists in that namespace. Then check Deployment readiness. - Check that Certificate
homelab-docs-tlsbecomes Ready. It uses the existingstaging-ingress-letsencryptClusterIssuer and DNS-01 forhomelab-docs.staging.local.isabelsoler.es. Issuance needs no new A record or Pangolin challenge route. - Route
docs.dev.igresc.comin Pangolin on the Oracle edge to HTTPS at10.0.20.90:443. PreserveHost: docs.dev.igresc.com, set the upstream TLS server name tohomelab-docs.staging.local.isabelsoler.es, and keep upstream certificate verification enabled. Pangolin owns the public hostname's TLS certificate. The staging certificate protects the backend connection. Traefik routes both hostnames to the app. Do not use an HTTP upstream on port 80, because staging's global HTTPS redirect would loop at the edge. - Check the public home page, search, and sitemap. Confirm a private page returns HTTP 404.
Rollback restores the previous Pangolin target, then removes ./homelab-docs
from the staging apps overlay. The app holds no data. The cluster-wide pull
Secret stays for other workloads.
The separate cv/mkdocs-igresc k3s workload and its docs.igresc.com route
remain unchanged.