Specification 22
Deploying a site
This specification describes how a site built with HERON gets onto a server and stays there: containers, update scripts, CI, the nginx minimum, moving from an old site and backups.
It is written for whoever runs the server or the site's repository. The site-specific details of a particular project live in that project's own documents, not here.
In plain words
A built site is a set of ready pages. To be seen by everyone, it has to live on a server: a computer that is always on and answers visitors. This page describes how a HERON site lives there.
The site travels as an image: the finished pages packed together with a small web server, like a sealed box. The server runs the box; a new version of the site is a new box that replaces the old one. If something goes wrong, the old box is still there and comes back.
Example. Starting a site image on a server and updating it later:
docker run -d --name my-site -p 80:8080 --restart unless-stopped ghcr.io/you/my-site:prod
docker pull ghcr.io/you/my-site:prod && docker rm -f my-site && docker run -d --name my-site -p 80:8080 --restart unless-stopped ghcr.io/you/my-site:prod
The first command starts the site. The second downloads the new version and swaps the box. With git and GitHub Actions the new image is built for you on every push; see publishing with git.
When you need this page. When you set up a server, automate updates, move an old site or plan backups.
Status today
Status as of 2026-10-04. The deployment path that works today is the site image:
scripts/site.sh SITE image|smoke|push ENVin the HERON repository (delivery, outputs); after that the image runs like any nginx container. The «build on the server» scheme from the sections containers and scripts (composewith the builder,install.sh,deploy.sh,deploy.conf) is the target: these scripts are not in the HERON repository yet. The sections on CI, nginx and moving apply to both paths.
In practice the step-by-step guide is Build and publish, and the ways to store and publish a site are explained in Publishing.
What is deployed
One site is one folder and one repository. It holds content, data, images, the theme and the config. There is no HERON code in it: HERON arrives as the image ghcr.io/OWNER/heron:VERSION, and the version is declared in site.yaml.
Two consequences follow from this, and they shape the whole deployment:
- updating HERON is changing one line in
site.yamland rebuilding, not merging someone else's code into the site's repository; - rolling HERON back is reverting that line.
Containers
Two, both light. A third one (relay) appears only on sites with forms.
Builder: the HERON image. It runs once, builds the site into dist/ and exits. Between builds it does not run and uses no resources. There is no need to build it on the target machine: it is pulled from the registry.
Web: nginx serving dist/. The only process that runs all the time. The image is nginxinc/nginx-unprivileged: the master process does not run as root and listens on 8080; a proxy faces the outside.
There is no database. There is no application. Serving static files is the only thing the site does in production.
services:
build:
image: ghcr.io/OWNER/heron:0.1.0
profiles: ["build"] # not started together with web
user: "${UID}:${GID}"
network_mode: "none" # the build needs no network
read_only: true
tmpfs: ["/tmp"]
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
volumes:
- .:/site
command: ["build", "--strict"]
web:
image: nginxinc/nginx-unprivileged:1.27-alpine
restart: unless-stopped
volumes:
- ./dist:/usr/share/nginx/html:ro
# HERON builds the config: languages, 404, 301 and 410 maps are already inside
- ./dist/.heron-nginx.conf:/etc/nginx/conf.d/default.conf:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 30s
timeout: 3s
retries: 3
dist/ is mounted into nginx read-only. The builder writes there, the web container only reads.
The builder does not get .env: the build has no secrets and runs without network. The security model is analysed in delivery, security.
Two scripts
Both live in the project folder on the server and run from there. Style: set -e, clear steps, a clear failure.
install.sh: once, at the first installation
- Checks that
dockeranddocker composeare present, and their versions. - Creates
dist/,cache/,logs/with the right permissions. - Copies
.env.exampleto.envif it does not exist and stops, asking you to fill it in. It does not start with empty secrets (this matters for sites with relay; on a static site the file stays empty). - Checks that
deploy.confis filled in. - Pulls the HERON image of the required version from the registry.
- Builds the site for the first time.
- Starts nginx.
- Prints what comes next: check the site, set up the proxy, issue a certificate.
deploy.sh: on every update
- Reads
deploy.confand.env. git pullof the right branch.- Compares the HERON version in
site.yamlwith the locally available image; if they differ, pulls the right tag. - Builds the site into a temporary folder
dist.new/, without touching the running one. - Runs
heron check --strict. Errors stop the process, anddist/stays as it was. - Atomic swap:
dist→dist.old,dist.new→dist, nginx reload. - If any step fails,
dist.oldcomes back and a clear message is printed. - Removes
dist.oldafter a successful check.
The key point: the build goes to the side, the swap is atomic. A broken commit never leaves the site half updated. Rolling back is returning the previous folder, not rebuilding.
Two configuration files
deploy.conf: environment parameters, kept in git:
REPO_BRANCH=main
SITE_DIR=/opt/site
DOMAIN=example.com
KEEP_RELEASES=3
.env: secrets, not in git, permissions 600. On a static site it is empty: secrets appear with relay. The rules for handling it are in the contract, .env.
The split is a principle: deploy.conf can be shown to anyone, .env to no one.
CI
On an event in the site's repository:
heron check --strict: fails on errors and on warnings.heron buildinto a temporary folder.- Check of the redirect map: every old URL answers 301 to an address that answers 200.
- Lighthouse on the built site; the site sets the thresholds.
- Only if everything is green:
deploy.sh.
Tests of HERON itself run in the CI of the HERON repository, not of the site: the site consumes a ready image and does not have to know how it was built.
The cut cache in CI. Without it, every build is cold: on a site with a hundred pictures that is minutes of CPU time for any text change. The cache lives in .heron-cache/ next to the build folder and is stored in the runner's cache like any folder:
- uses: actions/cache@v4
with:
path: .heron-cache
key: heron-media-${{ hashFiles('media/**', 'theme/theme.yaml') }}
restore-keys: heron-media-
The theme is in the key for a reason: widths, ratios and formats are part of every record's hash, and a change to theme.yaml makes the whole cache worthless.
The cache affects only speed. If it is lost, stale or not restored, the build is simply cold, and the result is byte-for-byte the same. So cache misses need no fixing, and build.workers in CI should be set by hand: cores there are shared with other jobs.
If a site uses build.updated_from: git, the history must reach the build. GitHub Actions clones the repository shallow (only the last commit), and then the history formally exists but has no dates in it: HERON falls back to file times, and after a clone all files are dated with the clone time. The whole site will claim it was updated today, on every build. One line fixes it:
- uses: actions/checkout@v7
with:
fetch-depth: 0
The same goes for building from an archive or a copy of the folder without .git: dates switch to file times. If dates matter, the history must sit next to the content at build time.
Step 3 is not a formality. It is the only thing that protects the search index during a move from an old system.
nginx
The minimum the config must have:
- static files served with a long cache for
assets/andmedia/, a short one for HTML; - gzip and brotli;
- 301 from
redirect_fromand 410 fromgoneanswered without touching files. The maps are already written into the config HERON builds with the site (dist/.heron-nginx.conf);redirects.mapandgone.mapindist/are human-readable copies; try_fileswith a trailing slash, one canonical form of URL;/healthzfor the healthcheck;- headers:
X-Content-Type-Options,Referrer-Policy,Content-Security-Policy, HSTS; - nothing is executed: the site is static, PHP and any handler are absent as a class;
- dot files are closed.
A strict CSP makes sense: the site's own scripts are a few kilobytes, there are no external ones. script-src 'self' with no exceptions. Sites with a form add connect-src for the relay address, and nothing more.
Moving from an existing site
The order that keeps a site from dropping in search. The specific redirect rules are the site's business; this is only the sequence.
Preparation. The new site is built and started on a service subdomain. Everything is checked: pages, languages, markup, speed.
Redirect map. The full list of old addresses is taken from the live sitemap. For each one, redirect_from is written in the matching md file; what no longer exists (archives, service paths, doorway pages) goes into gone in site.yaml, as exact addresses or prefixes (site.yaml). The test from CI, step 3, passes on the service domain.
The difference between 301 and 410 is a matter of principle: 301 tells a search engine «the address moved, here is the new one», 410 says «this is gone, drop it from the index». Service entities of the old system are the second kind, and pretending they are temporarily unavailable makes no sense.
Example. The old site had the page /our-services/roofing.html; in the new site it is content/en/services/roof.md. Its front matter keeps the old address, and HERON writes a real 301 for it:
redirect_from:
- /our-services/roofing.html
Switching. The proxy is pointed to the new container. The old stack stays up but unreachable from outside, in case of a rollback.
After. The sitemap is resubmitted in Search Console. For a week, watch the crawl error report. If it is quiet, the old stack is shut down and its folders are archived.
The practical guide to moving, with examples: SEO and languages.
Backup
Back up only what cannot be restored by a build.
Needed: the repository with content and images, which is the source of truth. .env. The nginx config.
Not needed: dist/ is rebuilt in seconds. cache/ even more so. The HERON image is in the registry and pinned by version.
The whole site is restored from git with one command. That is the main practical advantage over systems where a restore means bringing a database back.
What comes later
Prepared in the structure, not built now:
heron-relay: a third container on sites with forms. It receives POST requests, validates them against the schema in site.yaml, filters spam and sends them to email, a CRM webhook and Telegram. No database; SQLite only as a retry spool. Secrets come from a mounted .env, only into this container: nginx knows nothing about them.
Own analytics: receiving events instead of an external counter. A separate relay endpoint, a separate decision about storage.
Admin panel: a web editor for md files that triggers a rebuild. The rule without which it must not be built: the admin panel edits files and commits them to git, it does not write to a database. Otherwise HERON turns into exactly the kind of system sites are leaving.
None of the three requires rewriting what is done now, provided the boundaries from the build specification and delivery are kept. Current plans are on the roadmap.
Source
This page mirrors docs/spec/22-deploy.md from the prod branch of the HERON repository (document 3 of 4; dated 2026-09-08, revised 2026-09-14). The repository is updated first, and this page follows right after. The latest version is always here: 22-deploy.md on GitHub.