Specification 23
Delivery: HERON apart from your sites
This specification fixes how HERON reaches your sites: HERON is delivered separately from the sites it builds. One artifact in two packages, versions, commands, the two outputs (a folder or an image), the nginx config built with the site, themes and plugins, forms and the security of the build.
Read it when you choose a version, build an image, set up a server or wonder why HERON refuses to do something «for convenience».
In plain words
HERON itself and your sites are kept apart. HERON is a tool you download (a Docker image or a Python package); your site is a folder that names which HERON version may build it. A new HERON release never changes a live site by surprise: each site moves to a new version only when you change one line.
HERON can give you the result in two ways: a folder of ready files (for any hosting, GitHub Pages or Cloudflare Pages) or an image (the files plus a web server, for your own server). The pages inside are the same.
Example. The first line of site.yaml pins the version, and the tag of the image chooses which HERON you run:
heron: ">=0.1,<0.2" # this site builds with any 0.1.x
docker run --rm -v "$PWD":/site -w /site ghcr.io/fmstm/heron:prod build . --env prod --strict && ls dist
prod is the stable version recommended for live sites. Other tags are described in HERON versions.
When you need this page. When you choose a version, build an image, set up a server, or wonder why HERON refuses to do something «for convenience».
Engine version and site environment are different things
The site environment (SITE_ENV) answers the question of what the SITE will be: dev is closed to indexing and built without counters, prod is open. The HERON version answers the question of which CODE built it.
These axes are independent and must not be tied together. A person who needs a site closed to indexing, to work on content in peace, should not get a raw development branch of HERON and someone else's bugs because of that. A dev site built with the stable HERON is a normal, expected combination.
Example. The same stable HERON builds a draft and the live version; only the environment differs:
heron build . --env dev && heron build . --env prod --strict
The first build is closed to search engines and has no counters, the second is the one visitors see.
A raw HERON is taken only by those who work on HERON itself, and they do it explicitly: ENGINE=local, ENGINE=dev, ENGINE=beta.
Why separately
The earlier decision (engine, content and theme in one repository, split after launch) was right for a monolith and wrong for a pip package. A site does not contain HERON, it installs it. The cost of splitting at the start is three lines of configuration; the cost of splitting later is rewriting the Dockerfile, CI, documentation and untangling the commit history.
The practical reason is stronger than the theoretical one: there are several sites. A copy of the core in every repository means as many diverging engines. A parser fix made on one site would not reach the others; the public repository would lag behind what the sites really run on; and a link to it from those sites would work against the project.
Artifacts
There is exactly one artifact, in two packages of the same code.
| Package | Address | What for |
|---|---|---|
| Image | ghcr.io/OWNER/heron:VERSION |
building on any machine without installing Python; production and CI |
| Package | pip install heron |
local work: editing content without Docker for every paragraph |
Inside the image: Python, the heron package, ENTRYPOINT ["heron"] and an unprivileged user. Nothing else.
Themes and plugins are separate portable units, delivered as packages or git links. They are not baked into the HERON image.
Versions and compatibility
The first line of site.yaml declares the required version:
heron: ">=0.1,<0.2"
HERON checks itself against this requirement before anything else and, if they do not match, stops with E012, naming its own version and the required one. This is the one thing that keeps three sites from drifting apart onto incompatible versions.
Versioning is semantic. A breaking change to the folder contract, the format of site.yaml or theme.yaml, or the set of hooks is a minor version before 1.0 and a major one after. Changing the contract without changing the version is a release error, not a detail.
What the tags prod, beta and dev mean in practice: HERON versions.
Commands
HERON's commands (new, init, check, serve, build, page) are described in the build specification; that is its whole surface. Packing into an image is not among them: the host does it (outputs).
Two entrances, one exit
# the folder did not exist or holds only git
heron new my-site # in a terminal it asks for the name, domain, languages
heron new . # the same in the current folder
heron build my-site # → my-site/dist/
# changed the languages or the theme in site.yaml
heron init my-site # complete the folders to match
# a folder with content already exists
heron init ~/SOURCE/ready-content
heron check ~/SOURCE/ready-content
heron build ~/SOURCE/ready-content --env prod --out /tmp/site
The same with the image: docker run … ghcr.io/fmstm/heron:prod COMMAND (README, «Quick start»), and for several sites, scripts/site.sh (outputs).
init is the same layout, but for an existing folder: it looks at what is there, adds what is missing, overwrites nothing without --force and prints a report of what it found and added.
Cloning the HERON repository into a site folder is not a way to install it and is not supported. There are exactly two ways: the image and the pip package. A clone of the HERON repository next to the sites is needed only for scripts/site.sh.
new: questions in a terminal, flags without one
A person setting up a site does not yet know which keys site.yaml has, so in a terminal new asks for what a site cannot start without: the language of notes, the name, the domain, the languages, the default language. The answers go into site.yaml, and it stays the only source of truth: everything else is edited there, and heron init catches the folder structure up.
An agent, CI or docker run without -it has nobody to answer questions, so there new is non-interactive and deterministic: values come from the flags --name, --domain, --lang en,ru, --notes, otherwise from defaults. It is one path, not two: the questions only collect the same arguments as the flags and call the same code. -y turns questions off in a terminal too.
The language of notes is --notes ru|en or the HERON_NOTES variable; the default is en, and the choice is remembered in build.notes. In Docker, the questions need -it:
docker run --rm -it -v "$PWD":/site -w /site --user $(id -u):$(id -g) ghcr.io/fmstm/heron:beta new .
What new and init put in place
SITE/
├── site.yaml HERON version, name, domain, languages, theme, menu with the home page
├── .gitignore dist/, .heron-cache/
├── heron-readme.md an explanatory note, and one in every folder below
├── content/LANG/ index.md and 404.md with a full SEO block, for every language in site.yaml
├── data/ media/ static/ plugins/ empty
└── theme/ skeleton: theme.yaml, base.html, templates/ (home, page, 404),
modules/, partials/ (header, footer, analytics), i18n/, assets/
There is no build tooling in the site folder (Dockerfile, compose, build.sh, .env) and there must not be (site folder): it lives in the HERON repository.
Two outputs: a folder and an image
The output is chosen by the build command, not when the site is created.
A folder. heron build puts the finished site into dist/ (or into --out). It can be served by any nginx, uploaded to shared hosting or put on static hosting; HERON is no longer needed. Next to the HTML lies .heron-nginx.conf, the server config for this site (below).
An image. The host builds an nginx image with the site inside from the HERON repository:
./scripts/site.sh SITE build ENV # → out/SITE-ENV
./scripts/site.sh SITE image ENV # Dockerfile.site: nginx-unprivileged + static files + config
./scripts/site.sh SITE smoke ENV # start the image and check the answer codes
./scripts/site.sh SITE push ENV # to the registry from REGISTRY
The build context of the image is the already built folder out/SITE-ENV, not the content folder: neither .git nor source pictures can physically get into the image, and there is no HERON in the running container. Settings live in env/.env.SITE.ENV (an example with explanations is env/.env.example): the path to the content, SITE_ENV, the HERON version (ENGINE), the image name, the registry.
From the site's repository, the image is built by the reusable workflow FMSTM/HERON/.github/workflows/site-image.yml: a build with HERON from the image, Dockerfile.site from the same HERON branch, a smoke check with scripts/smoke-site.sh, and a push to the registry. How to connect it: build and publish, and the practical guide publishing with git.
HERON has no packing command of its own, and must not have one: the docker socket is never passed into the build container (access to the socket equals root on the host), so docker build is run by the host.
The 404 page and serving
Static files cannot answer 404 on their own: the server returns its default page by default, and nobody ever sees the one the theme drew. That is why the nginx config is built together with the site: HERON puts it into dist/.heron-nginx.conf, and the image moves it into conf.d and removes it from the root. The list of language locations comes from site.yaml: the default language lives in the root, the others in their folders, and a hand-written list would drift away from the folder contents the first time someone changes the default language. The rules:
- locations follow
site.languages: the root is the default language, pluslocation ^~ /LANG/for every other language. A single-language site gets one root location; languages not in the list are not in the config; - before the locations come the 301 maps (
redirect_from) and 410 maps (gone): the answer goes out before any file is touched; - for an address that does not exist,
404.htmlfrom the site root is served; - an address with a language prefix gets the 404 of its language:
/ru/…→/ru/404/index.html; the default language has no prefix folder, so its addresses get the root 404; - the answer code stays 404. A placeholder with code 200 gets into the index and breeds duplicates, and that costs more than any convenience;
- the 404 page itself is marked
internal: it cannot be opened by a direct link like an ordinary page; - gzip for text, styles, scripts, svg, maps and markup; HTML gets
Cache-Control: no-cache(a change is visible at once, and a freshness check is almost free),/media/and/assets/getpublic, max-age=86400: cut names do not change when their content is replaced, so caching «forever» would show the old picture; - map hash sizes at the top of the file, in the http context, before the first map:
map_hash_bucket_sizeandmap_hash_max_size, calculated from the keys of the 301/410 maps (regular expressions are not hashed). The bucket is a power of two ≥ max(64, align8(8 + 2 + longest key in bytes) + 8), with headroom to the next power if the key is closer than 16 bytes to the border;max_sizeis a power of two ≥ max(2048, keys × 2). By default nginx takes the bucket from the CPU cache line, and a long Cyrillic address does not fit, so whether the config starts would depend on the hardware.nginx: hash_sizes: offinsite.yamlmeans the directives are not written (the host's nginx.conf sets them), and the report names the values needed. If nginx is present on the build machine, the built config is checked withnginx -t; the reusable site image workflow runsnginx -ton stock nginx before pushing the image; - hidden files are not served at all: service files lie next to the static ones, and the folder may be copied to someone else's server by hand;
/.well-known/stays reachable, otherwise a certificate cannot be issued.
heron serve serves the same 404 by the same language rule: the local preview must not differ from what a visitor sees. It builds not into the site folder but into a temporary folder on the machine: the content folder is mounted read-only and belongs to whoever edits the text, and the preview has no right to leave dist or a cache in it. heron serve does not imitate the 301 and 410 maps: it serves files. Server answers for redirect_from and gone are checked on the image (scripts/site.sh SITE smoke ENV) or on any nginx with the built .heron-nginx.conf.
Build stamp
The question «why is my change not visible» almost always means «yesterday's static files are being served», not «the build broke». That is why the build environment carries the HERON version, the build time and, if git is nearby, the short commit of the content: env.version, env.built_at, env.revision and the ready line env.stamp.
Whether to show it is the theme's choice, but it must not be shown in production: it is service information, not part of the site. The theme prints the stamp under the condition not env.is_prod.
Checks and the smoke run
There is one set of checks, and it lives in one place, scripts/check.sh: the core boundary across the whole tree, formatting, the linter, tests. CI calls the same script. Two sets, a local one and «as in CI», drift apart on the first day: locally the linter ran, and the failure was in formatting.
./scripts/site.sh SITE smoke ENV starts the built image, waits for an answer and checks the codes: the root and robots.txt answer 200, a missing address 404, a language section 200 and 404 on its own missing address, the service config next to the static files 404, every address from redirects.map 301 to where it is declared to go, and every exact address from gone.map 410. Cyrillic in addresses is encoded before the request. The container is stopped, and the logs are shown on failure.
The check exists not out of love for automation: «the container dies on start» was once caught in a client's browser. File permissions, a config that was not picked up and a crashed nginx all look the same from outside: an empty page.
Image tags
CI writes the branch, the commit and the build time into the image (HERON_CHANNEL, HERON_REVISION, HERON_BUILT), and heron --version shows them: 0.1.0 (beta, 55249f0, 2026-10-08 19:12 UTC). The CI smoke check makes sure the image carries the mark of exactly the commit that was built.
Published tags: the branch name, the exact version from a vX.Y.Z tag, latest for a tag, and the short sha. There is no moving X.Y on purpose: a site declares its requirement for HERON in site.yaml and must be able to pin itself to something that will not move under it tomorrow.
Resolving themes and plugins
One mechanism for both, and no second one: otherwise you would have to remember which works where.
Resolution order; the first one found wins:
- locally in the site folder:
theme/for the theme,plugins/NAME/for a plugin; - an installed package:
heron-theme-NAME; for a plugin, an entry point of theheron.pluginsgroup; - a git link, if given explicitly in
site.yaml.
Local always wins. A theme is developed right inside the site, without publishing on every CSS change; it can be moved into a package later without changing a single line of content.
Plugins are ordinary pip packages, installed into the site image with one Dockerfile line. HERON does not invent its own package format or loader. The local plugins/ folder is for one-off plugins of a particular site that there is no point in publishing.
Theme not found: E013. A plugin from plugins: is not installed or failed to load: E014.
What can be extended and what cannot
The set of hooks and what they mean: build, extension points.
The main distinction that must not be blurred:
- Build time: build hooks. Your own section types, your own links, your own Jinja filters, your own generators. They work inside the eight stages of the pipeline.
- Run time: everything that happens when a visitor is already on the site. HERON has no hooks here and cannot have them: by then it has finished and exited.
«A forms plugin for a static generator» is a contradiction in terms. Receiving a POST lives in heron-relay.
Forms and heron-relay
A separate image, one process, no database. It appears on sites that have forms; a site without forms does not deploy it at all.
The flow: a static page sends a POST → relay validates it against the form description in site.yaml → spam filtering → it sends the entry to the receivers.
forms:
callback:
fields: [name, phone, message]
sinks: [mail, crm, telegram]
sinks:
mail: {to: info@example.com, smtp_env: SMTP_URL}
crm: {webhook: https://crm.example.com/hook, secret_env: CRM_SECRET}
telegram: {chat_id_env: TG_CHAT}
Spam protection: a honeypot field, a minimum time to fill in the form, a rate limit per address. A captcha (Turnstile) is optional, switched on in the config, off by default.
Receivers are independent: an unavailable CRM does not cancel the email. An error from any receiver is not returned to the visitor as a sending error: they did their part.
About a database. Relay stays stateless. SQLite is connected only as a retry spool when a receiver is down: the request is not lost and is sent later. It is not a store of requests; the store is the CRM.
The line that must not be crossed: as soon as content or site settings go into a database, HERON turns into WordPress. Files are the only source of truth.
Where relay and optional data connections stand today: roadmap.
Security model
A static site builder is a process that needs nothing. So we take everything away.
docker run --rm \
--network=none \
--read-only --tmpfs /tmp \
--cap-drop=ALL --security-opt=no-new-privileges \
--user "$(id -u):$(id -g)" \
-v "$PWD:/site" \
ghcr.io/OWNER/heron:0.1.0 build --strict
| Measure | What it gives |
|---|---|
--network=none |
the build reads and writes only local files. It needs no network at any stage. A compromised dependency can neither leak nor fetch anything. As a side effect it disciplines the design: pulling in a font or a picture «at build time» is physically impossible |
--user $(id -u):$(id -g) |
dist/ is created by the owner of the folder, not by root. The image has USER heron, but the flag matters more: without it, on a Linux VPS the build result belongs to someone else's uid. The image must not depend on a particular $HOME, otherwise it breaks with an arbitrary uid |
--read-only + tmpfs |
only /site/dist and /site/.heron-cache are writable: the build and the cut cache. The cache survives rm -rf dist on purpose: it holds everything expensive to compute, and deleting it is safe at any moment |
--cap-drop=ALL |
an HTML generator needs no capability at all |
no-new-privileges |
no escalation through suid |
no .env in the build |
the build has no secrets; .env is mounted only into relay |
| no docker socket | never, under any circumstances |
Run time: nginxinc/nginx-unprivileged, listening on 8080, not as root. The official nginx starts its master process as root for port 80; that is not needed here, there is a proxy in front.
Raw HTML in markdown is off by default (build.allow_raw_html: false, error E015). When content is moved from WordPress, a script tag from some widget comes along, and it is better for the build to fail on it than to paste it in silently.
Supply chain: dependencies are locked (uv.lock), the base image is pinned by digest rather than by tag, and the image is built in CI from the public repository. Then heron:0.1.0 is reproducible, and you can prove it holds the same as git.
The core boundary is checked by a machine. The HERON repository has a pre-commit hook that forbids a commit mentioning any of the sites built with HERON, client names or phone numbers. The list is stored as hashes (scripts/boundary-hashes.txt), so the names themselves never reach the public repository; adding a new site means editing that file, not the hook. Discipline does not scale, a hook does.
Repositories
| Repository | Contents | Visibility |
|---|---|---|
HERON |
heron/ (contracts, core, modules, cli, scaffold), tests/, docs/, scripts/site.sh, Dockerfile, Dockerfile.site |
public |
| the site's repository | content, data, images, theme, without build tooling | up to the site owner |
To change HERON next to a site, HERON is installed editable (uv pip install -e ../HERON) or built from source with ENGINE=source in scripts/site.sh: changes are visible at once, without publishing an image.
Out of scope
- Multisite in one folder. One folder is one site, strictly. A multisite doubles the complexity of path resolution for savings that do not exist across three different sites.
- Admin panel. Postponed until there is a real need. The rule for when it appears: it edits files and commits them, it does not write to a database.
- Incremental builds. Not needed at the scale of a hundred pages.
serverebuilds everything.
Source
This page mirrors docs/spec/23-distribution.md from the prod branch of the HERON repository (document 4 of 4; dated 2026-09-14, sections 3–5 revised 2026-10-04). The repository is updated first, and this page follows right after. The latest version is always here: 23-distribution.md on GitHub.