Skip to content

Publish a static website

Purpose

Publish an HTML bundle through the KH3 standard: a persistent artifact on CT 101, a read-only rootless Podman Caddy backend, and a reverse-proxy route on ingress CT 103.

Use this guide for a static site that has no application server or database. The validated example is KH3 development site.

Prerequisites and impact

  • An approved hostname, owner, criticality, recovery priority, and retention requirement.
  • A repository or other documented source for the site.
  • An approved unused CT 101 high port.
  • Access through ssh pvessh, plus authorized Technitium, Caddy, and Forgejo administration where applicable.
  • A maintenance window and backups of the affected Quadlet and Caddyfile.

Creating directories and inspecting configuration are low risk. Starting a new backend, changing DNS, editing ingress Caddy, restarting Caddy, or exposing a site changes production and requires explicit authorization. Never copy TLS keys, Cloudflare tokens, runner tokens, or environment values into the repository.

Target layout

Layer Pattern
Source Site repository or documented source archive
CI job mount /opt/podman/volumes/forgejo/runner-data:/runner-data
CT 101 artifact /opt/podman/volumes/forgejo/runner-data/publish/<site>
Runtime Rootless Podman as podsvc
Backend unit <site>-static.service from <site>-static.container
Backend mount Artifact path to /srv:ro
Backend listener Approved CT 101 high port to container 8080
Ingress CT 103 Caddy reverse_proxy 192.168.2.20:<port>
Internal DNS <hostname> -> 192.168.2.3

CT 103 terminates TLS but does not store the site artifact. This keeps content deployment separate from the shared ingress service.

Procedure

1. Record and validate the proposed mapping

Add the site to the website catalog and record its hostname, source repository, artifact path, backend unit, port, owner, and recovery priority. Check that the port is unused:

ssh pvessh 'pct exec 101 -- ss -H -lnt'
ssh pvessh 'pct exec 101 -- runuser -l podsvc -c "podman ps --format '\''{{.Names}} {{.Ports}}'\''"'

Expected: the selected port does not appear in either output.

2. Build and inspect the artifact before publication

Run the site's documented build. Confirm that it produces an index.html and does not contain secrets, source maps with credentials, private configuration, or raw environment files:

test -f dist/index.html
find dist -maxdepth 2 -type f -print | sort

Replace dist only when the repository documents another build directory. Expected: dist/index.html and only intended public assets.

3. Configure the CI publication target

Mount the existing runner-data allow-listed root into the job:

container:
  volumes:
    - /opt/podman/volumes/forgejo/runner-data:/runner-data

Publish only after the build succeeds:

grep -q ' /runner-data ' /proc/self/mountinfo || {
  echo "runner-data is not mounted"
  exit 1
}
test -f dist/index.html
target=/runner-data/publish/example-site
mkdir -p "$target"
find "$target" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
cp -a dist/. "$target/"
test -f "$target/index.html"
chmod -R a+rX,u+w "$target"

Replace example-site with the recorded site identifier. Expected: the job fails rather than writing to ephemeral storage when the bind mount is absent.

4. Define the CT 101 static backend

Create /home/podsvc/.config/containers/systemd/<site>-static.container from this reviewed shape:

[Unit]
Description=Static backend for example.kh3group.com
After=network-online.target
Wants=network-online.target

[Container]
Image=docker.io/library/caddy:2-alpine
ContainerName=example-site-static
Network=kh3-backend.network
Volume=/opt/podman/volumes/forgejo/runner-data/publish/example-site:/srv:ro
Exec=caddy file-server --root /srv --listen :8080
PublishPort=30085:8080
AutoUpdate=registry

[Install]
WantedBy=default.target

Replace the example names and port with the recorded values. Keep the artifact mount read-only. No secret is required in this backend.

After approval, reload the podsvc user manager and start only the new unit:

ssh pvessh 'pct exec 101 -- runuser -l podsvc -c \
  "XDG_RUNTIME_DIR=/run/user/2000 systemctl --user daemon-reload"'
ssh pvessh 'pct exec 101 -- runuser -l podsvc -c \
  "XDG_RUNTIME_DIR=/run/user/2000 systemctl --user enable --now example-site-static.service"'

Expected: the unit becomes active (running).

5. Validate the backend

ssh pvessh 'pct exec 101 -- runuser -l podsvc -c \
  "XDG_RUNTIME_DIR=/run/user/2000 systemctl --user status example-site-static.service --no-pager"'
ssh pvessh 'pct exec 103 -- curl -sS -o /dev/null \
  -w "%{http_code}\n" http://192.168.2.20:30085/'

Expected: active unit and HTTP 200. Resolve a backend failure before editing ingress.

6. Add DNS and ingress

Create the approved internal Technitium A record:

example.kh3group.com -> 192.168.2.3

Add only a reverse proxy block to /opt/caddy/config/Caddyfile on CT 103:

example.kh3group.com {
    import tls_cloudflare
    encode zstd gzip
    reverse_proxy 192.168.2.20:30085
}

Do not add a CT 103 root or file_server directive. Validate with the existing restricted environment loaded without printing it:

ssh pvessh 'pct exec 103 -- sh -c \
  "set -a; . /opt/caddy/env/caddy.env; set +a; \
  /usr/local/bin/caddy validate --config /opt/caddy/config/Caddyfile"'

Expected: Valid configuration. After separate restart authorization, restart Caddy and confirm it is active.

7. Validate the complete path

ssh pvessh 'pct exec 102 -- dig +short @192.168.2.2 example.kh3group.com A'
ssh pvessh 'pct exec 103 -- curl -sS -o /dev/null \
  -w "%{http_code} verify=%{ssl_verify_result}\n" \
  --resolve example.kh3group.com:443:127.0.0.1 \
  https://example.kh3group.com/'
curl -sS -o /dev/null -w '%{http_code} verify=%{ssl_verify_result}\n' \
  https://example.kh3group.com/

Expected: internal DNS returns 192.168.2.3; local-SNI and intended client paths return 200 verify=0.

Routine operation and updates

Normal content deployments replace the artifact contents and require no backend or ingress restart. Check status and logs with:

ssh pvessh 'pct exec 101 -- runuser -l podsvc -c \
  "XDG_RUNTIME_DIR=/run/user/2000 systemctl --user status example-site-static.service --no-pager"'
ssh pvessh 'pct exec 101 -- runuser -l podsvc -c \
  "podman logs --tail=100 example-site-static"'

Back up the source repository, workflow, Quadlet, ingress route, DNS mapping, and any artifact retained for rapid recovery. Rebuildable artifacts do not replace source backups.

Rollback and recovery

For bad content, republish a known-good source revision. For a failed new route, restore the dated Caddyfile, validate it, and restart Caddy only within the approved window. Remove the DNS record after the route is withdrawn.

Disable the new backend only after ingress no longer references it:

ssh pvessh 'pct exec 101 -- runuser -l podsvc -c \
  "XDG_RUNTIME_DIR=/run/user/2000 systemctl --user disable --now example-site-static.service"'

Retain the artifact and Quadlet until rollback is confirmed and the applicable retention period ends.

Troubleshooting

Symptom Likely cause Diagnosis Remedy
CI reports invalid volume Runner did not load its allow-list Inspect runner logs and explicit --config /config.yml Correct runner configuration; do not choose another host path
Backend 404 Missing index.html or wrong mount Inspect artifact and Quadlet Republish or correct the reviewed mount
Backend 403 Path traversal permissions Use namei -l and stat Restore least-privilege read/execute access
Public 502 Backend unavailable or wrong port Curl backend from CT 103 Repair backend or correct upstream
TLS failure DNS, SNI, issuance, or trust problem Test with --resolve without -k; inspect recent logs Correct the failed TLS layer
Old content with 200 CI did not refresh shared artifact Compare commit, job, and artifact times Correct publication; do not restart Caddy