# 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`](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. ## 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..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 | 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`): ```ini [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: ```bash 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. ```sh 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: ```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} \ /bin/sh -c 'exec 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 ``` ## 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 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.