HERON

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 ENV in 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 (compose with 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.yaml and 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

  1. Checks that docker and docker compose are present, and their versions.
  2. Creates dist/, cache/, logs/ with the right permissions.
  3. Copies .env.example to .env if 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).
  4. Checks that deploy.conf is filled in.
  5. Pulls the HERON image of the required version from the registry.
  6. Builds the site for the first time.
  7. Starts nginx.
  8. Prints what comes next: check the site, set up the proxy, issue a certificate.

deploy.sh: on every update

  1. Reads deploy.conf and .env.
  2. git pull of the right branch.
  3. Compares the HERON version in site.yaml with the locally available image; if they differ, pulls the right tag.
  4. Builds the site into a temporary folder dist.new/, without touching the running one.
  5. Runs heron check --strict. Errors stop the process, and dist/ stays as it was.
  6. Atomic swap: dist → dist.old, dist.new → dist, nginx reload.
  7. If any step fails, dist.old comes back and a clear message is printed.
  8. Removes dist.old after 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:

  1. heron check --strict: fails on errors and on warnings.
  2. heron build into a temporary folder.
  3. Check of the redirect map: every old URL answers 301 to an address that answers 200.
  4. Lighthouse on the built site; the site sets the thresholds.
  5. 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/ and media/, a short one for HTML;
  • gzip and brotli;
  • 301 from redirect_from and 410 from gone answered without touching files. The maps are already written into the config HERON builds with the site (dist/.heron-nginx.conf); redirects.map and gone.map in dist/ are human-readable copies;
  • try_files with a trailing slash, one canonical form of URL;
  • /healthz for 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.