Files
volumen/docs/DEPLOYMENT.md
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

30 KiB

Deployment

How Volumen runs in production.

Topology

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

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 ships platform binaries and a checksums.txt with their SHA-256 digests. Asset names follow volumen-<version>-<os>-<arch>:

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

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)

# 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)

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.

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):

*/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:

[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:

# /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
# /etc/systemd/system/volumen-publish.timer
[Unit]
Description=Publish due volumen posts every five minutes

[Timer]
OnCalendar=*:0/5

[Install]
WantedBy=timers.target
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):

[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.

# 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:

#!/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:

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:

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.

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), 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):

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:

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:

  # 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: 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<version>/volumen-<version>-<os>-<arch>,
  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

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

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/<slug>/, 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:

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:

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).

Logs go to stderr and therefore to journald under systemd. For structured output, set the log format to JSON:

[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: 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.

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) or a manual install followed by a service restart.

Each matrix entry builds one static binary named volumen-<version>-<os>-<arch> 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 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.