Skip to content

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:

python3 scripts/ci/public-docs.py check --site <copied-html-directory>

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:

  1. 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-pull is Ready. The homelab-docs namespace doesn't exist yet, so check the generated ExternalSecret and ghcr-pull Secret in an existing namespace, such as default.
  2. Add ./homelab-docs to kubernetes/apps/staging/kustomization.yaml. The apps Flux Kustomization doesn't depend on staging-registry-auth. Both reconcile at the same time, so the first pulls can fail until the Secret exists.
  3. On talos-test, wait until the homelab-docs ExternalSecret is Ready and the ghcr-pull Secret exists in that namespace. Then check Deployment readiness.
  4. Check that Certificate homelab-docs-tls becomes Ready. It uses the existing staging-ingress-letsencrypt ClusterIssuer and DNS-01 for homelab-docs.staging.local.isabelsoler.es. Issuance needs no new A record or Pangolin challenge route.
  5. Route docs.dev.igresc.com in Pangolin on the Oracle edge to HTTPS at 10.0.20.90:443. Preserve Host: docs.dev.igresc.com, set the upstream TLS server name to homelab-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.
  6. 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.

docs/repository/documentation-image.md