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
Assisted-by: GLM 5.3
749 lines
30 KiB
Markdown
749 lines
30 KiB
Markdown
# 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.
|