Initial commit
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
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
This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user