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/www2. 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 bashThe 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.0From an existing repository checkout:
sudo ./deploy/install.shThe installer:
- installs the Python application under
/opt/zz-pages; - creates the socket-activated system service;
- installs
/etc/nginx/conf.d/zz-pages.confto load site configurations from/etc/nginx/conf.d/zz-pages/; - creates
/srv/zz-pages/inboxasroot:git 0730, allowing the Runner to create and traverse random directories without listing the parent; - configures
systemd-tmpfilesto remove abandoned inbox and staging entries; - preserves an existing
/etc/zz-pages/config.toml.
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/inboxRead 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.service4. 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:
- validates the 256-bit inbox ID and repository, and requires the
domain to be a proper subdomain of
domain_suffix; - verifies the Forgejo token has push permission to the repository;
- queries Cloudflare's JSON DoH endpoint for the single
repo=DNS TXT authorization and verifies that the domain resolves to the configured web host; - moves the random inbox into the root-only staging directory;
- 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;
- changes the content to root ownership with directories
0755and files0644; - moves it into
/srv/www/<domain>/releasesand atomically switchescurrent; - creates
/etc/nginx/conf.d/zz-pages/<domain>.confand 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 -tBy 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-uninstallRemove the configuration, generated site configurations and working data as well:
sudo zz-pages-uninstall --purgeBoth modes preserve published sites under /srv/www. The
script removes the Nginx loader and reloads Nginx after validating the
remaining configuration.