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
- Fedora:
uv(recommended) — install withcurl -LsSf https://astral.sh/uv/install.sh | sh. Alternatively,pipxships in most distro repos.- nginx and a TLS certificate (e.g. via certbot) for the public site.
- A dedicated system user,
volumen, created automatically byvolumen initwhen 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:
- Whether
config_pathalready exists — if so, leaves it untouched unless--forceis passed (and even then it makes a timestamped.bak.<ts>.tomlfirst). - Creates
/etc/volumen/,/var/lib/volumen/posts(and amedia/subdirectory),/var/lib/volumen/users.toml. - Creates the system user
volumenif missing (useradd --system …). - Prompts for an admin password (
getpass), hashes it via scrypt, writes thepassword_hashinto the rendered config. - Generates a fresh 64-byte session key (
secrets.token_hex(64)) and writes it into[admin].session_key. - Forces production-style settings:
host = "::1",env = "production",trust_proxy = true,cookie_secure = true. - With
--systemd(default under root), writes/etc/systemd/system/volumen.serviceand runssystemctl 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/volumenand then runninguv tool install volumen && sudo volumen init.
Security notes
- Bind to
::1(or127.0.0.1); only nginx is public. TLS is terminated by nginx. - Session cookies are
HttpOnlyandSameSite=Strict, and gain theSecureattribute automatically on HTTPS requests (detected viaX-Forwarded-Proto, which the nginx snippet above sets, and enabled by[server].cookie_secure = true, set automatically byvolumen 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 byvolumen init) lets sessions survive restarts. - Keep
config.tomlandusers.tomlreadable only by thevolumenuser (chmod 600). Both may contain password hashes. The installer enforces this automatically. - Use
volumen hash-passwordto rotate; it reads the password without echo. Never pass passwords on the command line.