Files
petrbalvin 3b8ed31f67
Test / test (push) Successful in 1m7s
Release / release (push) Successful in 1m11s
chore: prepare release v0.4.0
2026-07-26 18:15:29 +02:00

10 KiB

Deployment

Status: production deployment on Linux with systemd + nginx.

volumen runs as a single Python process (Uvicorn via fastapi[standard]) bound to localhost, behind nginx which terminates TLS and reverse-proxies the API and admin. The public website (e.g. a Vue SPA) is served separately and consumes the JSON API.

The CLI binary is installed system-wide via uv tool install (or pipx install); the operational bootstrap — config, data dirs, admin password, optional systemd unit — is performed by the volumen init Python subcommand that replaces the old install.sh bash script.

Requirements

  • Python ≥ 3.14 (the bundled wheel ships its own interpreter when you use uv tool install / pipx install; the system Python is only required if you're building from source).
    • Fedora: sudo dnf install python3.14
    • Debian/Ubuntu: sudo apt install python3.14 python3-venv python3-pip
  • uv (recommended) — install with curl -LsSf https://astral.sh/uv/install.sh | sh. Alternatively, pipx ships in most distro repos.
  • nginx and a TLS certificate (e.g. via certbot) for the public site.
  • A dedicated system user, volumen, created automatically by volumen init when run as root.

Install the CLI

# Pick one:
uv tool install volumen
# or
pipx install volumen

# Both put a self-contained `volumen` binary on PATH. Verify:
volumen version

For platforms where the wheel is not available (or for development), build it from a source checkout:

git clone https://sourcedock.dev/petrbalvin/volumen.git
cd volumen
uv tool install .

Bootstrap the deployment

System install (root, systemd)

sudo volumen init
sudo systemctl status volumen

volumen init walks through:

  1. Whether config_path already exists — if so, leaves it untouched unless --force is passed (and even then it makes a timestamped .bak.<ts>.toml first).
  2. Creates /etc/volumen/, /var/lib/volumen/posts (and a media/ subdirectory), /var/lib/volumen/users.toml.
  3. Creates the system user volumen if missing (useradd --system …).
  4. Prompts for an admin password (getpass), hashes it via scrypt, writes the password_hash into the rendered config.
  5. Generates a fresh 64-byte session key (secrets.token_hex(64)) and writes it into [admin].session_key.
  6. Forces production-style settings: host = "::1", env = "production", trust_proxy = true, cookie_secure = true.
  7. With --systemd (default under root), writes /etc/systemd/system/volumen.service and runs systemctl daemon-reload && systemctl enable --now volumen.

Pass --no-enable to install the unit without starting it; pass --force to back up and overwrite an existing config. The full flag list lives in volumen init --help.

Per-user install (no root)

volumen init --local

Per-user paths: ~/.config/volumen/config.toml, ~/.local/share/volumen/posts, ~/.local/share/volumen/users.toml. No system user is created and no systemd unit is installed. --systemd is rejected in this mode.

Configuration locations

Path Purpose
/etc/volumen/config.toml configuration (rendered by volumen init on first run)
/var/lib/volumen/posts Markdown posts
/var/lib/volumen/posts/media uploaded images
/var/lib/volumen/users.toml admin users (created from Settings)
/etc/systemd/system/volumen.service systemd unit (only when --systemd)
~/.config/volumen/config.toml per-user config (--local)
~/.local/share/volumen/posts per-user data dir (--local)
~/.local/share/volumen/users.toml per-user users file (--local)

The config and users files are owned by volumen:volumen and chmod 600 after a system install — they may carry password hashes, so never make them world-readable.

systemd

/etc/systemd/system/volumen.service (rendered by volumen init --systemd):

[Unit]
Description=volumen blog engine
After=network.target

[Service]
Type=simple
User=volumen
Group=volumen
WorkingDirectory=/etc/volumen
Environment=PYTHONUNBUFFERED=1
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

Hardening notes — NoNewPrivileges, ProtectSystem=strict (the entire filesystem is read-only except for ReadWritePaths), ProtectHome (/home, /root, /run/user are inaccessible), PrivateTmp (private /tmp), and a two-second restart delay on failure.

Inspect health with:

sudo systemctl status volumen
journalctl -u volumen -f
volumen status            # config / data dir / unit / service state
volumen status --json     # machine-readable

FreeBSD (rc.d)

volumen runs on FreeBSD too — it is plain CPython with no compiled extensions.

pkg install python3
# install uv (https://docs.astral.sh/uv/getting-started/installation/)
uv tool install volumen
sudo volumen init --local   # no systemd on FreeBSD

Conventional FreeBSD paths: config in /usr/local/etc/volumen/, data in /var/db/volumen/. Drop a tiny rc.d script in /usr/local/etc/rc.d/volumen that wraps the volumen console script:

#!/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} \
  /bin/sh -c 'exec volumen serve --config ${volumen_config} --content ${volumen_content}'"

run_rc_command "$1"

Enable and start:

chmod +x /usr/local/etc/rc.d/volumen
sysrc volumen_enable=YES
service volumen start

nginx

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

server {
  listen 443 ssl http2;
  server_name blog.example.com;

  ssl_certificate     /etc/letsencrypt/live/blog.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;

  # Public site (e.g. the built Vue SPA).
  root /var/www/blog;
  location / {
    try_files $uri $uri/ /index.html;
  }

  # volumen API, admin and media.
  location ~ ^/(api/volumen|admin|media)(/|$) {
    proxy_pass http://127.0.0.1:9090;
    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 blog.example.com;
  return 301 https://$host$request_uri;
}

Hardening the admin

The admin lives at /admin on your public domain and is protected by login, sessions, CSRF, and roles — but the engine has no rate limit on /admin/login. Add brute-force throttling (and, optionally, an IP allowlist) at nginx.

1. A rate-limit zone in the http { } context (e.g. /etc/nginx/conf.d/volumen.conf):

limit_req_zone $binary_remote_addr zone=volumen_login:10m rate=5r/m;

2. A reusable proxy snippet at /etc/nginx/snippets/volumen-proxy.conf:

proxy_pass http://127.0.0.1:9090;
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 — public API/media, the throttled login, and the rest of the admin:

  # Public API and uploaded media — open to everyone.
  location ~ ^/(api/volumen|media)(/|$) {
    include snippets/volumen-proxy.conf;
  }

  # Login: throttle brute-force attempts. Only this endpoint is rate-limited,
  # so the editor's live preview (/admin/preview) is unaffected.
  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 {
    # Optional IP allowlist (must be repeated — locations do not inherit it):
    # allow 203.0.113.0/24;
    # deny all;
    include snippets/volumen-proxy.conf;
  }

nginx matches = /admin/login (exact) first, then ^~ /admin (which stops regex matching), then the ~ regex for API/media — so each path lands in the right block. Apply with sudo nginx -t && sudo systemctl reload nginx.

Updating

uv tool upgrade volumen
sudo systemctl restart volumen
volumen status   # confirm: config_exists, password_hash_set, session_key_ok, …

The CLI binary is replaced in place by uv tool upgrade; the systemd unit's ExecStart already points at the on-PATH volumen console script, so a restart is all that's needed. The persistent state (config.toml, users.toml, posts) is untouched.

If you used the legacy install.sh workflow (no longer documented, the script was removed), you can migrate forward by deleting /etc/systemd/system/volumen.service

  • /opt/volumen and then running uv tool install volumen && sudo volumen init.

Security notes

  • Bind to ::1 (or 127.0.0.1); only nginx is public. TLS is terminated by nginx.
  • Session cookies are HttpOnly and SameSite=Strict, and gain the Secure attribute automatically on HTTPS requests (detected via X-Forwarded-Proto, which the nginx snippet above sets, and enabled by [server].cookie_secure = true, set automatically by volumen init --systemd) — so the cookie never travels over plain HTTP in production, while local HTTP testing still works. Combined with per-form CSRF tokens this protects the admin. A stable [admin].session_key (also written by volumen init) lets sessions survive restarts.
  • Keep config.toml and users.toml readable only by the volumen user (chmod 600). Both may contain password hashes. The installer enforces this automatically.
  • Use volumen hash-password to rotate; it reads the password without echo. Never pass passwords on the command line.