Files
volumen/docs/DEPLOYMENT.md
T

749 lines
30 KiB
Markdown
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
# 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-<version>-<os>-<arch>`:
```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<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
```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/<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:
```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-<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](../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.