HERON

Guide 06 · for whoever publishes

Build and deploy

How to build a site for production, pack it into an image and keep several sites on different HERON versions. For whoever publishes: a person or a builder agent.

The same site folder can leave HERON as plain files or as a ready image. This page covers both, plus the checks that run before a site goes live.

In plain words

Building is like baking: the folder with your texts is the dough, heron build is the oven, and the finished site is the bread. You can hand the bread over as it is — a folder dist/ with ready pages — or packed in a box — an image that any server with Docker can run. This page explains both and the checks before the bread leaves the kitchen.

Which one to choose. Free hosting such as GitHub Pages or an ordinary hosting plan takes the folder. Your own server with Docker takes the image, and updates become «download the new image, restart». If you are unsure, start with the folder; the page Publishing compares the ways.

Example. heron build . --env prod --strict makes dist/ with every page, the sitemap, llms.txt and a ready nginx config. Copy that folder to the hosting, and the site is online.

You need this page when the site is ready to go online, and when you keep several sites on one server.

Two outputs: a folder or an image

The dist/ folder An nginx image
Made by heron build scripts/site.sh SITE image
Inside HTML, images, maps, .heron-nginx.conf nginx-unprivileged, the static files, a config for this site
Goes to any static hosting, your own nginx any host with Docker, Kubernetes
301, 410, your own 404 connect .heron-nginx.conf to your nginx already on

The output is chosen by the command, not when the site is created: one folder builds both ways. In detail: two outputs in the specification.

The site environment

Development and production are a build flag, not a different HERON image:

--env dev (default) --env prod
indexing closed: noindex and Disallow: / open
counters from analytics no yes
build label (version, time, commit) the theme can show it not shown

--strict turns warnings into errors and is required for production. To override them one by one: SITE_INDEXABLE=false, SITE_ANALYTICS=false give a production-like preview closed to search engines.

A domain per environment. A development build at its own address must point to itself, not to the live domain. For that, in site.yaml:

site:
  domain: example.com          # the live one
  domains:
    dev: dev.example.com       # canonical, sitemap, hreflang, robots point here in a dev build

The environment name is the same as in --env: one switch controls both indexing and the domain.

One site

H="docker run --rm -v $PWD:/site -w /site --user $(id -u):$(id -g) ghcr.io/fmstm/heron:prod"
$H build my-site --env prod --strict

my-site/dist/ is the finished site. Upload the folder to static hosting (then 301 and 410 are set up with the hosting's own tools from dist/redirects.map and dist/gone.map) or serve it with your own nginx, as below.

Your own nginx on a server

dist/.heron-nginx.conf is a ready server {} block together with the 301 and 410 maps. It is connected in the http context, like any site file. An example for Debian or Ubuntu with nginx from apt:

sudo cp -r my-site/dist /var/www/my-site
sudo cp my-site/dist/.heron-nginx.conf /etc/nginx/sites-enabled/my-site.conf
# in the file, set root to /var/www/my-site and listen/server_name for your host
sudo nginx -t && sudo systemctl reload nginx

Map hash sizes. The file starts with map_hash_bucket_size and map_hash_max_size: HERON counted them from the longest address in redirect_from and gone. Without them a long Cyrillic address does not fit into a bucket the size of a processor cache line, and nginx does not start: could not build map_hash, you should increase map_hash_bucket_size: 64. The build report shows the values it counted.

If the host's /etc/nginx/nginx.conf already sets these directives, repeating them in the site file gives directive is duplicate. Then in site.yaml:

nginx:
  hash_sizes: off     # auto (default) writes them into the site config; off means the host sets them

The build report says how much is needed, for example «the 301/410 maps need map_hash_bucket_size ≥ 256». It goes into http { … } of /etc/nginx/nginx.conf:

http {
    map_hash_bucket_size 256;
    map_hash_max_size 2048;
    # … the rest as it was
}

If nginx is installed on the machine that builds, HERON runs nginx -t on the built config itself and writes the result into the report; a failure is a warning, and an error with --strict.

To look at the site locally, run heron serve my-site: the same 404 a visitor sees, a build into a temporary folder, the site folder stays clean. serve does not imitate 301 and 410; smoke checks those on the image.

An image from the site repository

A site usually lives in its own repository, with no copy of HERON next to it. The image is built by HERON's reusable workflow, so you do not write your own Dockerfile:

# .github/workflows/image.yml in the site repository
name: image
on:
  push:
    branches: [main]

jobs:
  image:
    uses: FMSTM/HERON/.github/workflows/site-image.yml@prod
    permissions:
      contents: read
      packages: write
    with:
      env: dev          # dev or prod
      engine: prod      # the HERON tag: prod, beta or 0.1.0

The workflow builds the site with HERON from the image, packs dist/ into nginx by the recipe of the same HERON version, starts the image, runs the smoke check and sends it to ghcr.io/OWNER/REPOSITORY:ENV. Parameters: path is the site folder inside the repository, image and tag give your own image name, strict is true or false (by default only prod is strict), push: false builds and checks without sending. Until beta becomes prod, call @beta with engine: beta.

Several sites: scripts/site.sh

From a clone of the HERON repository:

git clone https://github.com/FMSTM/HERON.git && cd HERON && HERON_NOTES=en ./scripts/site.sh mysite new ~/sites/my-site

new creates the site folder, in the terminal with the same questions as heron new, and sets up env/.env.mysite.dev and .prod. The folder can already exist and be under git. After that everything is one command:

./scripts/site.sh mysite check dev     # report without building
./scripts/site.sh mysite build dev     # → out/mysite-dev
./scripts/site.sh mysite serve dev     # nginx on localhost:$PORT
./scripts/site.sh mysite stop          # stop the preview
./scripts/site.sh mysite build prod
./scripts/site.sh mysite image prod    # the image IMAGE_NAME:IMAGE_TAG
./scripts/site.sh mysite smoke prod    # start the image, check the answer codes
./scripts/site.sh mysite push prod     # to REGISTRY
./scripts/site.sh mysite init          # catch the folder up after editing site.yaml

The content folder is mounted read-only: everything the build produces lives in out/, the content is never touched.

The site settings are env/.env.SITE.ENV and they stay out of git. A sample with explanations: env/.env.example.

Variable What it sets
SITE_PATH the path to the site folder
SITE_ENV dev or prod: what the site will be
STRICT strict build
ENGINE which HERON builds it (below)
IMAGE_NAME, IMAGE_TAG the name of the site image
REGISTRY, REGISTRY_CONFIG the registry for push and the Docker config for it; empty means push is forbidden
PORT the preview port
HERON_NOTES the language of the comments and notes init puts in: ru or en

Form secrets (SMTP, webhooks) do not go here: the build is static, and anything it sees may end up in the HTML.

The smoke check

site.sh SITE smoke ENV starts the built image and checks the answers (the check itself is scripts/smoke-site.sh, the same script the site image workflow calls):

  • the root and robots.txt answer 200, a missing address answers 404;
  • a language section answers 200 and has its own 404;
  • the service config next to the static files answers 404;
  • every address from redirects.map answers 301 to where it was declared;
  • every exact address from gone.map answers 410.

If it fails, the container is stopped and the nginx logs are printed. In detail: checks and the smoke run.

HERON versions

The site pins HERON itself, in the first line of site.yaml:

heron: ">=0.1,<0.2"

A HERON outside the range refuses to build (E012). Updating HERON for a site means changing this line, on purpose and one site at a time.

Which HERON site.sh builds with is set by ENGINE in the env file:

Value When
empty the exact version from site.yaml if it is exact there; otherwise the sources next to it; otherwise :prod
ghcr.io/fmstm/heron:prod stable, the usual choice
ghcr.io/fmstm/heron:0.1.0 pin to a version
source build from the sources next to it, only when you are changing HERON itself

Tags of the HERON image:

Tag What it is
:X.Y.Z an exact version, never changes
:prod what live sites run on
:beta the next version, tested on live sites
:dev HERON development; not meant for sites

--version shows which image you have: the branch, the commit and the build time, for example heron, version 0.1.0 (beta, 55249f0, 2026-10-08 19:12 UTC). The version number is one per development branch, and the commit shows whether the beta is fresh. From the sources it is (source, COMMIT).

There is no moving :X.Y tag on purpose: a site must be able to pin to something that will not change under it tomorrow.

Checklist before going live

  1. 1

    site.yaml

    Domain, languages, heron: with a range, counters.
  2. 2

    Strict build

    build --env prod --strict passes without errors.
  3. 3

    Empty sections

    The «Not filled in» summary is empty or accepted on purpose.
  4. 4

    Smoke check

    smoke prod is green: 200, 404, every 301 and 410.
  5. 5

    After going live

    Submit the sitemap in Search Console.

Source

This page mirrors docs/guide/06-deploy.md from the prod branch. The repository is updated first, so the file there is always the latest version.

Reference: Deployment and Distribution specifications.