Hosting a static site
hoster has no special type for static sites (ADR-0018). A site built by a static site generator is packed into a small nginx image in CI and deployed like any other service (ADR-0003). It gets everything other services get: private repositories and packages, deploys by digest, zero-downtime swaps, rollback to earlier images and logs in the UI. An idle nginx container uses a few megabytes of memory.
This documentation is deployed this way: its source, Dockerfile and stack file are in
site/, and the site and docs jobs of
release.yml build
and deploy it on every release.
Files to copy into the site’s repository are in
examples/static-site/:
| File | What it does |
|---|---|
Dockerfile | Copies the built site from dist/ into nginx-unprivileged, which runs as non-root and listens on 8080. |
default.conf | Serves the site, answers /healthz, serves 404.html for missing pages and turns off absolute redirects. |
release.yml | On every push to main: builds the site with aphid, builds and pushes the image, posts the digest to hoster. |
1. The stack file
version = 1
name = "blog"
[services.web]
image = "ghcr.io/acme/blog" # pushed by release.yml, no tag
[[services.web.routes]]
host = "blog.example.com"
port = 8080 # the container port nginx listens on
[deploy]
repository_id = 123456789 # gh api repos/acme/blog --jq .id
refs = ["refs/heads/main"]
environment = "production"
No health table is needed: the default probe is GET /healthz on the route’s port
(ADR-0051), and default.conf answers it. hoster also injects PORT=8080, which nginx ignores;
the listen line in default.conf and the route’s port must agree.
For a private package, enter a PAT with read:packages under Settings → Registries
(ADR-0025). Setting up the [deploy] rule and the production environment is the same as for
any app: see Deploying from CI, §1. For a site that deploys from main
rather than from tags, limit the environment to the main branch.
2. The workflow
release.yml is examples/github-actions/release.yml
with two changes: it runs on pushes to main instead of v* tags, and it builds the site before
building the image. The build step is aphid’s GitHub Action:
- uses: LHelge/aphid@v0.3.0
with:
version: v0.3.0 # the aphid binary; defaults to the latest release
config: aphid.toml
output: dist
Any other generator works the same way, as long as its output ends up in dist/ before
docker/build-push-action runs. Building outside the Dockerfile keeps the image to nginx plus
the site, and lets the generator use the runner’s caches.
Without the webhook
If deploying within a few minutes is good enough, the deploy step can be dropped. Add
track = "main" to the service, and the ghcr poller deploys whenever the main tag that
docker/metadata-action pushes gets a new digest (ADR-0043; every 5 minutes by default,
[poller] interval in hoster.toml). The workflow then needs neither id-token: write nor a
[deploy] rule. The webhook is still the better choice when you want the deploy in the run’s
log and a failed deploy to fail the run.
3. Things to know
- Redirects: nginx redirects a directory without a trailing slash (
/posts→/posts/). Withabsolute_redirect offtheLocationis relative, so it stays onhttps://and the public host instead of pointing athttp://<host>:8080. www→ apex: hoster has no host redirects in v1 (ADR-0051). Either route both hosts to the service, or add aserverblock forwwwindefault.confthat returns301 https://example.com$request_uriand routewww.example.comto the same port.- Under a prefix: the prefix is preserved by default (ADR-0010), so a site routed at
/docsmust be built with that base path (aphid’s, Hugo’s or Zola’s base URL setting). Alternatively, setstrip_prefix = trueon the route, but links in the site must then still include the prefix. - Caching:
default.confsets noCache-Control, so browsers revalidate withLast-Modified/ETag. If the generator fingerprints asset file names, alocation ~* \.(css|js|woff2)$ { expires 1y; }block is safe to add. - Architecture: build for the VPS’s architecture (
platforms:in the workflow). The nginx base image is multi-arch.