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
101high 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 |