2026-06-21 13:15:06 +02:00
|
|
|
# Deployment
|
|
|
|
|
|
|
|
|
|
> Status: production deployment on Linux with systemd + nginx.
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
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.
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
## Requirements
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
- **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`](https://docs.astral.sh/uv/)** (recommended) — install with
|
|
|
|
|
`curl -LsSf https://astral.sh/uv/install.sh | sh`. Alternatively,
|
|
|
|
|
[`pipx`](https://pipx.pypa.io/) 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.
|
2026-06-21 13:15:06 +02:00
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
## Install the CLI
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
# 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:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
git clone https://sourcedock.dev/petrbalvin/volumen.git
|
|
|
|
|
cd volumen
|
|
|
|
|
uv tool install .
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Bootstrap the deployment
|
|
|
|
|
|
|
|
|
|
### System install (root, systemd)
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
| Path | Purpose |
|
|
|
|
|
|------|---------|
|
2026-07-26 18:15:29 +02:00
|
|
|
| `/etc/volumen/config.toml` | configuration (rendered by `volumen init` on first run) |
|
2026-06-21 13:15:06 +02:00
|
|
|
| `/var/lib/volumen/posts` | Markdown posts |
|
|
|
|
|
| `/var/lib/volumen/posts/media` | uploaded images |
|
2026-07-26 18:15:29 +02:00
|
|
|
| `/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`) |
|
2026-06-21 13:15:06 +02:00
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
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.
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
## systemd
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
`/etc/systemd/system/volumen.service` (rendered by `volumen init --systemd`):
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
```ini
|
|
|
|
|
[Unit]
|
|
|
|
|
Description=volumen blog engine
|
|
|
|
|
After=network.target
|
|
|
|
|
|
|
|
|
|
[Service]
|
2026-07-26 18:15:29 +02:00
|
|
|
Type=simple
|
2026-06-21 13:15:06 +02:00
|
|
|
User=volumen
|
|
|
|
|
Group=volumen
|
2026-07-26 18:15:29 +02:00
|
|
|
WorkingDirectory=/etc/volumen
|
|
|
|
|
Environment=PYTHONUNBUFFERED=1
|
|
|
|
|
ExecStart=/usr/local/bin/volumen serve --config /etc/volumen/config.toml --content /var/lib/volumen/posts
|
2026-06-21 13:15:06 +02:00
|
|
|
Restart=on-failure
|
|
|
|
|
RestartSec=2
|
|
|
|
|
NoNewPrivileges=true
|
|
|
|
|
ProtectSystem=strict
|
|
|
|
|
ProtectHome=true
|
|
|
|
|
ReadWritePaths=/var/lib/volumen
|
|
|
|
|
PrivateTmp=true
|
|
|
|
|
|
|
|
|
|
[Install]
|
|
|
|
|
WantedBy=multi-user.target
|
|
|
|
|
```
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
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:
|
|
|
|
|
|
2026-06-21 13:15:06 +02:00
|
|
|
```bash
|
|
|
|
|
sudo systemctl status volumen
|
|
|
|
|
journalctl -u volumen -f
|
2026-07-26 18:15:29 +02:00
|
|
|
volumen status # config / data dir / unit / service state
|
|
|
|
|
volumen status --json # machine-readable
|
2026-06-21 13:15:06 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## FreeBSD (rc.d)
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
volumen runs on FreeBSD too — it is plain CPython with no compiled extensions.
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
```sh
|
2026-07-26 18:15:29 +02:00
|
|
|
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
|
2026-06-21 13:15:06 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Conventional FreeBSD paths: config in `/usr/local/etc/volumen/`, data in
|
2026-07-26 18:15:29 +02:00
|
|
|
`/var/db/volumen/`. Drop a tiny `rc.d` script in `/usr/local/etc/rc.d/volumen`
|
|
|
|
|
that wraps the `volumen` console script:
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
```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} \
|
2026-07-26 18:15:29 +02:00
|
|
|
/bin/sh -c 'exec volumen serve --config ${volumen_config} --content ${volumen_content}'"
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
run_rc_command "$1"
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Enable and start:
|
|
|
|
|
|
|
|
|
|
```sh
|
2026-07-26 18:15:29 +02:00
|
|
|
chmod +x /usr/local/etc/rc.d/volumen
|
2026-06-21 13:15:06 +02:00
|
|
|
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):
|
|
|
|
|
|
|
|
|
|
```nginx
|
|
|
|
|
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`):
|
|
|
|
|
|
|
|
|
|
```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://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:
|
|
|
|
|
|
|
|
|
|
```nginx
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-07-26 18:15:29 +02:00
|
|
|
uv tool upgrade volumen
|
2026-06-21 13:15:06 +02:00
|
|
|
sudo systemctl restart volumen
|
2026-07-26 18:15:29 +02:00
|
|
|
volumen status # confirm: config_exists, password_hash_set, session_key_ok, …
|
2026-06-21 13:15:06 +02:00
|
|
|
```
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
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`.
|
|
|
|
|
|
2026-06-21 13:15:06 +02:00
|
|
|
## Security notes
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
- Bind to `::1` (or `127.0.0.1`); only nginx is public. TLS is terminated by
|
|
|
|
|
nginx.
|
2026-06-21 13:15:06 +02:00
|
|
|
- Session cookies are `HttpOnly` and `SameSite=Strict`, and gain the `Secure`
|
|
|
|
|
attribute automatically on HTTPS requests (detected via `X-Forwarded-Proto`,
|
2026-07-26 18:15:29 +02:00
|
|
|
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.
|
2026-06-21 13:15:06 +02:00
|
|
|
- Keep `config.toml` and `users.toml` readable only by the `volumen` user
|
2026-07-26 18:15:29 +02:00
|
|
|
(`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.
|