zz.Pages deployment guide

Architecture

The dedicated zz.Pages Runner uses the normal Forgejo Docker executor backed by rootless Podman. Every job sees two fixed mounts:

/srv/zz-pages/inbox  -> /zz-pages/inbox   read/write
/run/zz-pages        -> /run/zz-pages     read-only

The host inbox is not enumerable by the git user. The Action creates an unlogged 256-bit random child, builds directly in that directory, then sends a small control request through zz-pages.sock:

Forgejo job container
  /zz-pages/inbox/<random>  ($ZZ_PAGES_OUTPUT)
             │ build writes here
             │
             └── zz-pages.sock: random ID + authorization metadata
                              │
                              ▼
zz-pages@<connection>.service (root)
  /srv/zz-pages/staging/<random>
                              │ rename
                              ▼
  /srv/www/<domain>/releases/<release>
  /srv/www/<domain>/current

The socket never carries site files. The service is started once per connection by systemd and logs through journald.

1. Prerequisites

This design requires rootless Podman. A rootful container can bypass ordinary directory read permissions and enumerate the shared inbox.

Install Python 3.11 or newer, Nginx and the existing rootless zz.Pages Runner. No third-party Python package is required. The service needs outbound HTTPS access to 1.1.1.1 for TXT queries. Confirm that the inbox, staging and web roots use one filesystem so publication can use rename:

command -v python3 nginx systemctl
getent passwd git
findmnt -T /srv/zz-pages/inbox
findmnt -T /srv/zz-pages
findmnt -T /srv/www

2. Install on the web host

Install directly from Git with one command:

curl -fsSL https://git.zz.ac/zz/pages/raw/branch/main/install.sh |
    sudo bash

The bootstrap script clones the main branch into a temporary directory, runs the full installer and removes the checkout. To install a tagged release, fetch the script from that tag and pass the same tag as its argument:

curl -fsSL https://git.zz.ac/zz/pages/raw/tag/v0.1.0/install.sh |
    sudo bash -s -- v0.1.0

From an existing repository checkout:

sudo ./deploy/install.sh

The installer:

Set the real Forgejo URL and deployment target:

git_site = "https://git.zz.ac"
web_host = "pages.zz.ac"
domain_suffix = "zz.ac"
keep_releases = 3
request_timeout = 10
dns_endpoint = "https://1.1.1.1/dns-query"

domain_suffix restricts publishing to proper subdomains of that DNS name. The suffix itself is not accepted. keep_releases controls the number of releases retained per domain. request_timeout is the timeout in seconds for Forgejo and DNS HTTP requests. dns_endpoint must be an HTTPS URL without a query string or fragment.

Enable the control socket:

sudo nginx -t
sudo systemctl enable --now zz-pages.socket
systemctl status zz-pages.socket
stat -c '%A %U %G' /srv/zz-pages/inbox

Read service logs with:

journalctl 'SYSLOG_IDENTIFIER=zz-pages'

3. Configure the zz.Pages Runner

Merge deploy/runner-zz-pages.example.yml into a dedicated Runner configuration:

runner:
  labels:
    - "zz-pages:docker://node:24-bookworm"

container:
  privileged: false
  options: >-
    --volume /run/zz-pages:/run/zz-pages:ro
    --volume /srv/zz-pages/inbox:/zz-pages/inbox:rw
  valid_volumes:
    - /run/zz-pages
    - /srv/zz-pages/inbox
  docker_host: "-"

The Runner itself connects to rootless Podman using its service environment. docker_host: "-" prevents the Podman socket from being injected into job containers.

Jobs share the inbox mount but cannot enumerate it. Each job knows only its own 64-character random name. Do not enable shell tracing around the deployment Action or print ZZ_PAGES_OUTPUT.

Restart the Runner user unit:

systemctl --user restart forgejo-pages-runner.service
journalctl --user -u forgejo-pages-runner.service

4. Configure a site

The domain owner creates routing and exactly one repository authorization record:

example.zz.ac. CNAME pages.zz.ac.
_zz-pages.example.zz.ac. TXT "repo=alice/website"

Only proper subdomains of the configured domain_suffix are accepted. All resolved addresses must be among the configured web_host addresses.

Add this workflow and replace example.zz.ac with the site domain:

name: Deploy with zz.Pages

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: zz-pages
    steps:
      - uses: https://data.forgejo.org/actions/checkout@v6
      - uses: https://git.zz.ac/zz/pages@v0.1.0
        with:
          domain: example.zz.ac
          build-command: |
            npm ci
            npm run build -- --outDir "$ZZ_PAGES_OUTPUT"

The Action creates ZZ_PAGES_OUTPUT before running build-command; the command must leave that directory in place and write the final static site there. Examples include:

hugo --destination "$ZZ_PAGES_OUTPUT"
mkdocs build --site-dir "$ZZ_PAGES_OUTPUT"
npm run build -- --outDir "$ZZ_PAGES_OUTPUT"

The Action removes its inbox directory on ordinary failure. systemd-tmpfiles-clean.timer removes abandoned entries after one day when a job or host terminates unexpectedly. The tmpfiles rule reapplies root:git 0730 after reboot.

5. Authorization and publication

For each request, the root service:

  1. validates the 256-bit inbox ID and repository, and requires the domain to be a proper subdomain of domain_suffix;
  2. verifies the Forgejo token has push permission to the repository;
  3. queries Cloudflare's JSON DoH endpoint for the single repo= DNS TXT authorization and verifies that the domain resolves to the configured web host;
  4. moves the random inbox into the root-only staging directory;
  5. allows relative symbolic links whose complete target chain stays inside the same release, while rejecting escaping, absolute, dangling or cyclic links, hardlinks and special files;
  6. changes the content to root ownership with directories 0755 and files 0644;
  7. moves it into /srv/www/<domain>/releases and atomically switches current;
  8. creates /etc/nginx/conf.d/zz-pages/<domain>.conf and validates Nginx on first deployment.

The shared Nginx snippet supplies ACME/TLS, HTTP/2, HTTP/3, dual-stack listeners and root /srv/www/$host/current. Content-only deployments do not require an Nginx reload.

If Nginx validation or the first reload fails, the service restores the previous current link and removes the new configuration and release. Service logs go to journald, while the final success or error is also returned to the Action. Changing the TXT record transfers the domain to another repository.

Manual inspection:

sudo readlink /srv/www/example.zz.ac/current
sudo nginx -t

By default, the service retains the three newest releases for each domain. Change keep_releases in config.toml to adjust this value. Use filesystem quotas if the zz.Pages Runner needs a storage limit.

6. Uninstall

Remove the service while preserving its configuration, generated site configurations, working data and published sites:

sudo zz-pages-uninstall

Remove the configuration, generated site configurations and working data as well:

sudo zz-pages-uninstall --purge

Both modes preserve published sites under /srv/www. The script removes the Nginx loader and reloads Nginx after validating the remaining configuration.