# Deployment How Volumen runs in production. ## Topology ```mermaid flowchart TD net["Internet"] -->|HTTPS| nginx nginx["nginx :443, TLS termination and reverse proxy"] -->|"proxy_pass http://[::1]:9091"| app app["volumen, one static binary, loopback only"] --> data app --> etc data["/var/lib/volumen holding posts/ (with media/ and .revisions/ inside) and users.toml, templates.toml, tokens.toml, webhooks.toml, secret.key"] etc["/etc/volumen/config.toml"] ``` nginx terminates TLS on the public hostname and proxies `/api/volumen`, `/admin` and `/media` to the backend on loopback; everything else on that hostname is the public site, served separately. The engine owns one content directory (posts, media and revisions inside it) and four files beside it: `users.toml`, `templates.toml`, `tokens.toml` and `webhooks.toml`. There is one process per content directory. ## Requirements - **A supported platform**: the release builds cover Linux on `amd64`, `arm64`, `loong64`, and `riscv64`, and FreeBSD on `amd64` and `arm64`. The binary is static (`CGO_ENABLED=0`), so no runtime libraries are needed. - **Go 1.27.1** or newer only when building from source: the module declares it. - **nginx** and a TLS certificate (e.g. via certbot) for the public hostname. - A dedicated system user, `volumen` by default, for the service. The documentation creates it by hand; nothing in the binary creates users. - **systemd** for the managed service (optional; an rc.d script, a supervisor, or a plain foreground run all work). - Nothing else: no interpreter, no virtual environment, no package manager. The deployment itself is one command: start `volumen serve` and open `/admin`, where the first-run wizard creates the administrator account. The commented configuration template, `config.toml.example`, is committed at the repository root, and the session secret is generated by the server. ## Build ```sh just build # writes bin/volumen for the host platform ``` Releases are built by the tag pipeline for Linux (`amd64`, `arm64`, `loong64`, `riscv64`) and FreeBSD (`amd64`, `arm64`), with `CGO_ENABLED=0` and no build tags, so every artefact is a static binary. `-trimpath` keeps the checkout's own path out of the binary and `-buildvcs=true` records the revision it came from, so the same tree builds the same bytes from any directory and `volumen version` still names the tag or the commit. The version is recorded by the toolchain at build time; nothing injects it. ## Run Installing the binary is a copy, and the installation is the wizard: start `volumen serve`, open `/admin`, and the first-run screen creates the administrator account, the interface language and the colour scheme. A system deployment writes the config by copying `config.toml.example`; a per-user one needs no config at all. Any account can then add a second factor from Settings, Security: scan the offered QR with an authenticator application and confirm one code. The recovery codes shown at that moment open the account when the application is lost; they work once each and are not shown again, so they belong in a password manager the moment they appear. ### From a Gitea release Every release on [sourcedock.dev](https://sourcedock.dev/petrbalvin/volumen/releases) ships platform binaries and a `checksums.txt` with their SHA-256 digests. Asset names follow `volumen---`: ```sh VERSION=1.0.0 BASE="https://sourcedock.dev/petrbalvin/volumen/releases/download/v${VERSION}" curl -fLO "${BASE}/volumen-${VERSION}-linux-amd64" curl -fLO "${BASE}/checksums.txt" # Verify the download against the published digest. sha256sum --ignore-missing -c checksums.txt sudo install -m 0755 "volumen-${VERSION}-linux-amd64" /usr/local/bin/volumen volumen version ``` Replace `linux-amd64` with `linux-arm64`, `linux-loong64`, `linux-riscv64`, `freebsd-amd64`, or `freebsd-arm64` as needed. The release binary carries its version, so `volumen version` confirms what you installed. ### From source ```sh git clone https://sourcedock.dev/petrbalvin/volumen.git cd volumen just install sudo install -m 0755 bin/volumen /usr/local/bin/volumen ``` The binary reports the version the toolchain recorded at build time: `volumen version` names the commit of the checkout it was built from, and a release build names its tag. There is no version flag to pass and nothing to inject. ### Bootstrap the deployment There is no bootstrap command. A deployment is a directory for its state, a configuration file copied from the template when the defaults do not fit, a supervisor to keep the server running, and one visit to `/admin`, where the first-run wizard creates the first account. The wizard stays open until that account exists: on a machine reachable from the network, start the service and claim the installation straight away. ### System install (root, systemd) ```sh # 1. Service account and its group. sudo useradd --system --user-group --home-dir /var/lib/volumen \ --shell /usr/sbin/nologin volumen # 2. The config: copy the commented template from the repository root. sudo mkdir -p /etc/volumen /var/lib/volumen/posts/media sudo cp config.toml.example /etc/volumen/config.toml # 3. Edit the copy for production behind a reverse proxy: # host = "::1", env = "production", trust_proxy = true, # cookie_secure = true, # trusted_proxies = ["::1", "127.0.0.1"] # Leave [admin].session_key empty: the server generates its secret into # /var/lib/volumen/secret.key on first start and keeps it there. # 4. Own the data directory so the service can write posts, media, accounts # and its secret. sudo chown -R volumen:volumen /var/lib/volumen # 5. Install the unit (the Service unit section below), then: sudo systemctl daemon-reload sudo systemctl enable --now volumen.service # 6. Open http://localhost:9091/admin/ and complete the wizard. ``` The two proxy settings carry weight: `trust_proxy = true` takes the client address from the proxy's `X-Forwarded-For`, and `cookie_secure = true` keeps the session cookie on HTTPS. The config is deployment state and stays mode `0600`. ### Per-user install (no root) ```sh volumen serve ``` With no config file the server runs on the per-user paths: `~/.local/share/volumen/posts` and `~/.local/share/volumen/users.toml` (honouring `XDG_DATA_HOME`), and it reads `~/.config/volumen/config.toml` when that file exists. No systemd unit is installed in this mode; run the server in the foreground or through a supervisor of your choice. ### Configuration locations | Path | Purpose | |------|---------| | `/etc/volumen/config.toml` | configuration: a copy of the commented `config.toml.example`, edited to fit | | `/var/lib/volumen/posts` | Markdown posts | | `/var/lib/volumen/posts/media` | uploaded images (WebP / AVIF / SVG) | | `/var/lib/volumen/posts/.revisions` | archived post versions and delete tombstones | | `/var/lib/volumen/users.toml` | admin users (created by the first-run wizard or the Settings page) | | `/var/lib/volumen/secret.key` | the session secret the server generated on first start | | `/var/lib/volumen/templates.toml` | post templates (created from the admin Settings page) | | `/var/lib/volumen/tokens.toml` | API access tokens (created from the admin Settings page) | | `/var/lib/volumen/webhooks.toml` | admin-managed webhook endpoints (created from the admin Settings page) | | `/var/lib/volumen/audit.log` | audit trail (only when `audit_log` is configured) | | `/etc/systemd/system/volumen.service` | systemd unit (written by the operator, see the Service unit section) | | `~/.config/volumen/config.toml` | per-user config, read when it exists | | `~/.local/share/volumen/` | per-user data dir (the default when no config file exists) | The users, templates, tokens and secret files carry password hashes, token digests and the signing key, so never make them world-readable: every write the server performs re-applies mode `600`, media and post writes are atomic. Every configuration key is documented in [CONFIGURATION.md](CONFIGURATION.md). ### Scheduled publishing Posts may carry a `publish_at` date in their frontmatter; they stay hidden until that date arrives. Two mechanisms can flip them, and both do the same thing: drop `publish_at` and set `date` when the post had none. **From cron** (or a systemd timer): ```text */5 * * * * /usr/local/bin/volumen publish-due --config /etc/volumen/config.toml ``` Use the absolute path to the binary, since cron's `PATH` is minimal. `--dry-run` lists due posts without touching files; `--json` emits machine-readable output. A real run delivers the `post.published` event to the configured `[[webhooks]]` for every post it publishes and waits for those deliveries to finish, so a front end that rebuilds from a webhook hears about a publish that came from cron. A post whose file could not be saved is reported on stderr and the command exits `1`, so a timer unit or a monitoring check can see the failure rather than assume success. **From the server itself**, via the `[scheduler]` configuration section: ```toml [scheduler] enabled = true interval = 300 # seconds ``` The in-process loop starts with `volumen serve` and needs no external timer. It sweeps once at start-up, so a post whose date passed while the service was down is published as soon as it comes back, and then once every `interval` seconds. An interval below one second never reaches the loop: the configuration validation refuses it at start-up. The loop delivers the same `post.published` webhook as the admin does, and a save failure is logged and skipped, so one bad file cannot stop the sweep or the loop. The systemd timer equivalent for the CLI path: ```ini # /etc/systemd/system/volumen-publish.service [Unit] Description=Publish due volumen posts [Service] Type=oneshot User=volumen ExecStart=/usr/local/bin/volumen publish-due --config /etc/volumen/config.toml ``` ```ini # /etc/systemd/system/volumen-publish.timer [Unit] Description=Publish due volumen posts every five minutes [Timer] OnCalendar=*:0/5 [Install] WantedBy=timers.target ``` ```sh sudo systemctl daemon-reload sudo systemctl enable --now volumen-publish.timer ``` Adjust the `ExecStart` path to match the one in `volumen.service`. ## Service unit `/etc/systemd/system/volumen.service`, written by the operator (paths and binary location fitted to the deployment): ```ini [Unit] Description=Volumen, a lightweight publishing platform for scientists After=network.target [Service] Type=simple User=volumen Group=volumen WorkingDirectory="/etc/volumen" ExecStart="/usr/local/bin/volumen" serve --config "/etc/volumen/config.toml" --content "/var/lib/volumen/posts" Restart=on-failure RestartSec=2 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadWritePaths="/var/lib/volumen" PrivateTmp=true [Install] WantedBy=multi-user.target ``` Quote every path so a layout with spaces in it stays one argument, and double any `%`, because unit files expand `%X` specifiers. - `User` / `Group` name the service account (default `volumen`), and `WorkingDirectory` is the directory holding the config file. - `ExecStart` names the installed binary. - `ReadWritePaths` is the **parent of the data directory**, so a layout with posts at `/srv/volumen/posts` yields `ReadWritePaths=/srv/volumen`: the service also writes the users file, its templates, tokens, webhooks and `secret.key` there. With `ProtectSystem=strict` the rest of the filesystem is read-only to the service. - `NoNewPrivileges`, `ProtectHome` (`/home`, `/root`, `/run/user` inaccessible), `PrivateTmp`, and a two-second restart delay on failure. The server bounds every connection: a 10 second read-header timeout, a 60 second read timeout, a 120 second write timeout, and a 120 second idle timeout, so a client that opens a socket and dribbles a request cannot hold it indefinitely. On `SIGINT` or `SIGTERM` (what `systemctl stop` sends) it stops accepting new connections and drains the requests already in flight, giving them at most 15 seconds. ### FreeBSD (rc.d) The `freebsd/amd64` and `freebsd/arm64` release binaries run natively; nothing is compiled at install time. ```sh # Install the binary from the release, as above, to /usr/local/bin/volumen. pw useradd volumen -d /var/db/volumen -s /usr/sbin/nologin -c "Volumen publishing platform" mkdir -p /usr/local/etc/volumen /var/db/volumen/posts/media cp config.toml.example /usr/local/etc/volumen/config.toml chown -R volumen /var/db/volumen ``` Edit the copied config for a production deployment behind a proxy: set the three paths, `host = "::1"`, `env = "production"`, `trust_proxy = true`, `cookie_secure = true` and `trusted_proxies = ["::1", "127.0.0.1"]`. Leave `[admin].session_key` empty; the server writes `secret.key` into `/var/db/volumen`, which is owned by the `volumen` user. Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in `/var/db/volumen/`. Drop an `rc.d` script in `/usr/local/etc/rc.d/volumen`: ```sh #!/bin/sh # # PROVIDE: volumen # REQUIRE: NETWORKING # KEYWORD: shutdown . /etc/rc.subr name="volumen" rcvar="volumen_enable" load_rc_config $name : ${volumen_enable:="NO"} : ${volumen_user:="volumen"} : ${volumen_config:="/usr/local/etc/volumen/config.toml"} : ${volumen_content:="/var/db/volumen/posts"} pidfile="/var/run/${name}.pid" command="/usr/sbin/daemon" command_args="-f -r -P ${pidfile} -u ${volumen_user} \ /usr/local/bin/volumen serve --config ${volumen_config} --content ${volumen_content}" run_rc_command "$1" ``` Enable and start: ```sh chmod +x /usr/local/etc/rc.d/volumen sysrc volumen_enable=YES service volumen start ``` ## Production configuration Volumen only owns `/api/volumen`, `/admin`, and `/media`. Serve your public site from the same hostname and proxy those prefixes to the backend, so the admin runs same-origin (session cookies and CSRF work without extra configuration). A production deployment binds the service to `::1`, so proxy to `[::1]:9091`: ```nginx server { listen 443 ssl http2; server_name lab.example.com; ssl_certificate /etc/letsencrypt/live/lab.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/lab.example.com/privkey.pem; # Public site: any front end, static files or a built one. root /var/www/site; location / { try_files $uri $uri/ /index.html; } # volumen API, admin and media. location ~ ^/(api/volumen|admin|media)(/|$) { proxy_pass http://[::1]:9091; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } server { listen 80; server_name lab.example.com; return 301 https://$host$request_uri; } ``` Caddy is the recommended front, and the same shape costs a handful of lines: certificates and the HTTP redirect are its own work. ```caddyfile lab.example.com { root * /var/www/site @backend path /api/volumen/* /admin /admin/* /media /media/* handle @backend { reverse_proxy [::1]:9091 } handle { try_files {path} {path}/ /index.html } } ``` Caddy appends the connecting address to `X-Forwarded-For` the way `$proxy_add_x_forwarded_for` does, so the guarantee described below holds with either front. With `[server].trust_proxy = true` (a production deployment turns it on), the client IP used by the API rate limiter and the login limiter is the **last** entry of `X-Forwarded-For`, and only when the connection itself comes from an address listed in `[server].trusted_proxies`. Session cookies then always carry `Secure`. The last entry is the one the peer that wrote it saw as its client, so the directives above are safe as written: `$proxy_add_x_forwarded_for` appends `$remote_addr` after anything the client sent, which means a client cannot displace its own address by sending a header of its own. The guarantee the proxy must provide is the other half: only nginx may reach the backend. Bind it to `::1`, and never expose port 9091, because anything that can open a connection to the backend directly can set `X-Forwarded-For` itself and choose the address it is rate-limited under (see the security notes below). `[server].trusted_proxies` turns that guarantee into a check rather than a promise: with `trusted_proxies = ["::1", "127.0.0.1"]`, the forwarded address is believed only when the connection itself comes from the loopback proxy, and a request that arrives from anywhere else is measured by its real address. A loopback proxy behind `nginx` uses exactly that list. An empty list never reads the header at all: behind a proxy every client is then measured under the proxy's own connection address and shares one rate-limit budget, so list the proxy to measure each client by its forwarded address. ### Hardening the login The engine rate-limits `/admin/login` itself (10 attempts per IP per 60 s; see [the session and rate-limit rules](ARCHITECTURE.md#state-and-lifetime)), but nginx is the durable line of defence. Add brute-force throttling (and, optionally, an IP allowlist) at the proxy. **1. A rate-limit zone** in the `http { }` context (e.g. `/etc/nginx/conf.d/volumen.conf`): ```nginx limit_req_zone $binary_remote_addr zone=volumen_login:10m rate=5r/m; ``` **2. A reusable proxy snippet** at `/etc/nginx/snippets/volumen-proxy.conf`: ```nginx proxy_pass http://[::1]:9091; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; ``` **3. Split the single proxy location** in the `server { }` block into three, namely public API/media, the throttled login, and the rest of the admin: ```nginx # Public API and uploaded media, open to everyone. location ~ ^/(api/volumen|media)(/|$) { include snippets/volumen-proxy.conf; } # Login: throttle brute-force attempts on top of the app-level limit. location = /admin/login { # Optional IP allowlist (uncomment to restrict): # allow 203.0.113.0/24; # deny all; limit_req zone=volumen_login burst=10 nodelay; include snippets/volumen-proxy.conf; } # The rest of the admin. location ^~ /admin { include snippets/volumen-proxy.conf; } ``` nginx matches `= /admin/login` (exact) first, then `^~ /admin` (which stops regex matching), then the `~` regex for API/media. Apply with `sudo nginx -t && sudo systemctl reload nginx`. ### Security notes - Bind to `::1` (or `127.0.0.1`); only nginx is public. TLS is terminated by nginx. The production config sets `host = "::1"`. - Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure` attribute when `[server].cookie_secure = true` or when `trust_proxy = true`. The session secret signs them and survives restarts: by default the server generates it into `secret.key` beside the users file, and `[admin].session_key` (at least 64 bytes, mandatory in production) overrides that file. - `[server].trusted_proxies` names the addresses whose `X-Forwarded-For` is believed. A loopback-nginx deployment lists `["::1", "127.0.0.1"]`, or narrows it to the proxy's address, so a client that reaches the backend directly cannot choose the address it is rate-limited under. An empty list ignores the forwarded header, which behind a proxy folds every client into one rate-limit bucket. - Keep `config.toml`, `users.toml`, `tokens.toml` and `secret.key` readable only by the `volumen` user (mode `600`). Every write the server makes enforces this. - To change a forgotten administrator password, reset it from the admin Settings page as another admin, or rotate the account in `users.toml`. Never put a password on the command line. - The controls themselves, and their limits, are described in [the architecture](ARCHITECTURE.md#state-and-lifetime): how a body is sanitised before it is served, how a password is stored, and what the session cookie does and does not guarantee. ## Upgrade ### From the admin panel The Version panel in Settings shows the running release and, when the release check has found a newer one, an "Update now" action. The update endpoint is admin-only and CSRF-protected. On confirmation the server: 1. queries the latest release from the Gitea releases API (`/api/v1/repos/petrbalvin/volumen/releases/latest`), 2. downloads the matching asset from `https://sourcedock.dev/petrbalvin/volumen/releases/download/v/volumen---`, 3. verifies the SHA-256 digest against the release's `checksums.txt`, 4. stages the new binary in the executable's directory and renames it over the running one, 5. re-executes itself with the original command-line arguments, preserving the process ID so systemd never observes a restart. The response page polls `/healthz` until the server is back (up to three minutes). Nothing is installed without an admin clicking through. The self-update writes the running binary in place, so the service user needs write access to that directory. With the standard layout (`/usr/local/bin/volumen` owned by root, service running as `volumen`) the rename fails; either grant the service user write access to the install directory or use the manual path below. A checksum or download error in the panel means the release asset could not be fetched for the running platform; the state on disk is untouched. ### From the shell ```sh sudo systemctl stop volumen sudo install -m 0755 volumen-1.0.0-linux-amd64 /usr/local/bin/volumen volumen version sudo systemctl start volumen volumen status # confirm: config, posts, users ``` The persistent state (`config.toml`, `users.toml`, posts, revisions) is untouched by either path. ### Monitoring for updates ```sh volumen check-update # human-readable volumen check-update --json # machine-readable ``` Exit codes: `0` when the running version is the latest release, `1` when a newer release exists, and `1` as well when the release API cannot be reached (the cause is on stderr, so a monitoring check distinguishes the two by its output rather than by the code); `2` is a usage error and nothing else. ## Rollback There is no rehearsed rollback procedure for a running installation, and this document does not pretend otherwise. - **A bad upgrade** is undone by putting the previous binary back and restarting: the release assets are versioned, so an older one is still on the releases page, and the data is untouched by an upgrade. A restore from a backup archive is the fallback when data changed as well. - **A bad content change** does not need a rollback: every save archives the previous version under `posts/.revisions//`, and a delete is a move into that archive, so the admin's History view restores either one. - **A bad configuration change** is undone by editing the file and restarting; a configuration that fails validation stops the process before it binds its port, so a broken edit cannot half-start the service. ### Backups All state is files, so any filesystem backup works. Volumen also ships its own archive commands: ```sh volumen export --out /backup/volumen-$(date +%F).tar.gz volumen import /backup/volumen-2026-08-02.tar.gz ``` `export` packs the content directory (posts, media, `.revisions`) under `posts/`, plus `users.toml`, `templates.toml` and `tokens.toml`, into a `tar.gz` written at mode `600`; `--out` is the flag, and the default output path is `volumen-backup.tar.gz` in the working directory. `config.toml` is not in the archive: the import has no use for it and the session key it carries is a credential that should not travel in a file copied around. `import` restores the posts, users, templates and tokens into the locations from the config. A restore is confined by construction: the archive's entries are matched against the fixed `posts/`, `users.toml`, `templates.toml` and `tokens.toml` names, a `posts/` path must be a post, a revision or a media file with an allowed image extension, and every write goes through an `os.Root` opened on the content directory, which refuses an escape through `..` or through a symlink. The decompressed size is bounded at 512 MiB, so a compression bomb cannot exhaust memory. `.toml` files are written with mode `600`, everything else with `644`. The same export and restore is available from the admin Settings page, where both directions require the **admin** role, because the archive carries the users file with its password hashes and the tokens file with its digests. The admin path calls the same two functions as the CLI, so the archive and the containment rules are one implementation, not two. ## Monitoring After the first-run wizard, confirm the installation state: ```sh volumen status # config, content dir, post and user counts volumen status --json # machine-readable; exit code 1 on any issue volumen doctor # config validation, post rendering, weak hashes volumen doctor --json # machine-readable; exit code 1 when a check fails volumen validate # content check: slugs, titles, aliases, rendering sudo systemctl status volumen journalctl -u volumen -f ``` `volumen status` reads the config and reports the post count (with a draft count) and the number of users; with no accounts yet it names the wizard as the next step. `volumen doctor` validates the configuration, renders every post, and warns when a stored password hash uses scrypt parameters below the current policy floor. The exit code follows the levels: a missing or broken config, an unreadable content directory and an unreadable users file fail the command with exit code 1, while an installation still waiting for its first account, posts that fail to render and weak stored hashes are warnings that leave the exit code at 0. `volumen validate` reports duplicate slugs, invalid slugs, missing titles, aliased collisions, and posts that fail to render. Those commands check a deployment. The build of the binary is checked by `just gates`, which runs `build`, `fmt-check`, `vet`, `test` and `race` in one pass; the release pipeline runs the same set without the race detector (see [Pipeline](#pipeline)). Logs go to stderr and therefore to journald under systemd. For structured output, set the log format to JSON: ```toml [server] log_format = "json" ``` Each line is then one JSON object from `log/slog` (time, level, message, and the structured attributes of the event), suitable for `journalctl -o json`, Loki, or similar. The default `"text"` format is human-readable. Every request is logged once when it finishes, with its method, path, status and duration, under a 16-character request id; the same id is answered in the `X-Request-Id` header and attached to every line the handlers write while serving that request, so one id finds a request and everything it did in the journal. The server's own protocol errors are written through the same logger rather than straight to stderr. Set `audit_log` in the configuration to keep a separate, append-only JSON-lines record of administrative actions. Logins and logouts are not recorded; post creation, edits, deletions and bulk actions are, together with media deletions, user and token management, and backup imports. Each line is one JSON object carrying the timestamp `ts` (RFC 3339, UTC), the acting `user` and the `action` as its message, plus the `resource`, the client `ip` and a `detail` object wherever the action knows them. ## Pipeline Releases are built by `.gitea/workflows/release.yml`, which runs when a `v*` tag is pushed. The branch flow is the one in [CONTRIBUTING.md](../CONTRIBUTING.md): work lands on `development`, `development` is merged into `main`, and the tag is cut on `main`. The build happens at the tag, and the toolchain records the tag into the binary's build information, so the version in the binary is right because of where the build ran; nothing is injected, and each job derives the version from the tag itself rather than receiving it from another job. ```mermaid flowchart TD subgraph gates["gates job, 10 minute timeout"] direction TB g1["validate the tag against the semver pattern"] --> g2["go build ./..."] --> g3["gofmt -l . prints nothing"] --> g4["go vet ./..."] --> g5["go fix -diff ./..."] --> g6["go test with a coverage profile"] --> g7["the coverage floor of 80 per cent"] end subgraph builds["build job, 25 minute timeout, six targets in one matrix"] direction TB b1["linux on amd64, arm64, loong64, riscv64"] --> b2["freebsd on amd64, arm64"] b2 --> b3["CGO_ENABLED=0 build with -trimpath and -buildvcs into bin/volumen-VERSION-OS-ARCH"] b3 --> b4["upload the artefact"] --> b5["linux/amd64 smoke test, the binary reports the tag and no +dirty"] end subgraph rel["release job, 15 minute timeout"] direction TB r1["download the artefacts"] --> r2["write checksums.txt over them"] --> r3["take the CHANGELOG section for the tag"] --> r4["create the release over the Gitea API"] --> r5["upload the six binaries and checksums.txt"] end tag["a v* tag is pushed"] --> gates gates --> builds builds --> rel ``` The gates job runs the same set as `just gates` minus the race detector: build, format check, `go vet` together with `go fix -diff`, the suite, and the coverage floor of 80 per cent. Race is deliberately absent: the runner is shared with the forge, and `just gates` races the tree on the machine where the tag is cut. A push or a pull request to `development` runs the same steps without race through `.gitea/workflows/test.yml`, and `.gitea/workflows/race.yml` runs the suite under the race detector when it is dispatched by hand. The release is the only publisher: nothing is deployed out of the repository, and a production installation takes its binary from a release, through the admin self-update (see [Upgrade](#upgrade)) or a manual install followed by a service restart. Each matrix entry builds one static binary named `volumen---` for the version without its leading `v`, with `-trimpath` and `-buildvcs=true`, uploads it, and, on linux/amd64 only, runs it and requires the output of `volumen version` to contain the tag and not `+dirty`. The release job then writes `checksums.txt`, one `sha256sum` line per asset, takes the release notes from the `CHANGELOG.md` section for the tag, creates the release over the Gitea API, and uploads the six binaries with the checksums file. That file is part of the update contract: the admin self-update refuses a download whose SHA-256 does not match it. The release carries no licence files of its own, because the repository holds them: `LICENSE` for Volumen, and [NOTICE.md](../NOTICE.md) for every module the binary is compiled from. Gitea attaches the tag's own source archive to the release beside the binaries, and that archive contains both.