Files
volumen/docs/deployment.md
T

8.4 KiB

Deployment

Status: production deployment on Linux with systemd + nginx.

volumen runs as a single Ruby process (Puma) 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.

Requirements

  • Ruby ≥ 3.3 with a C toolchain (Puma compiles a native extension).
    • Fedora: sudo dnf install ruby ruby-devel @development-tools openssl-devel
    • Debian/Ubuntu: sudo apt install ruby-full build-essential libssl-dev
  • nginx and a TLS certificate (e.g. via certbot).
  • A dedicated system user, e.g. volumen.

Layout

Path Purpose
/opt/volumen source checkout (or installed gem)
/etc/volumen/config.toml configuration (auto-generated on first run)
/var/lib/volumen/posts Markdown posts
/var/lib/volumen/users.toml admin users (created from Settings)
/var/lib/volumen/posts/media uploaded images
sudo useradd --system --home /var/lib/volumen --shell /usr/sbin/nologin volumen
sudo mkdir -p /etc/volumen /var/lib/volumen/posts
sudo chown -R volumen:volumen /var/lib/volumen

Install

Quick install (Linux + systemd)

From a cloned repository, run the installer as root. It installs the Ruby toolchain from the distro repositories on Fedora and openEuler (prints guidance on other distros), creates the volumen user and directories, runs bundle install, generates config.toml, and installs and enables the systemd unit:

sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
cd /opt/volumen
sudo ./install.sh

The service is enabled but not started — finish by setting the admin password (printed steps), then sudo systemctl start volumen. The manual steps below describe what the script does.

Manual install

From a source checkout (reproducible, no publishing needed):

sudo git clone https://codeberg.org/petrbalvin/volumen.git /opt/volumen
cd /opt/volumen
sudo bundle install --deployment

Alternatively build and install the gem (just build produces pkg/volumen.gem, then gem install pkg/volumen.gem puts volumen on the PATH).

First run and the admin password

The first serve writes a commented config.toml if it is missing:

sudo -u volumen bundle exec exe/volumen serve \
  --config /etc/volumen/config.toml \
  --content /var/lib/volumen/posts
# → "volumen: wrote a default config to /etc/volumen/config.toml; review it and restart."

Generate a password hash and a stable session secret, then edit the config:

bundle exec exe/volumen hash-password    # → scrypt$...
openssl rand -hex 64                      # → session_key
sudo -u volumen $EDITOR /etc/volumen/config.toml

Set host = "127.0.0.1", your site.base_url, the admin.password_hash, and a admin.session_key (so sessions survive restarts). You can now sign in as user admin; from the Settings page you can add more users (roles admin / author) and change passwords. From then on users.toml is the source of truth.

systemd

/etc/systemd/system/volumen.service:

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

[Service]
User=volumen
Group=volumen
WorkingDirectory=/opt/volumen
ExecStart=/usr/bin/bundle exec exe/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
sudo systemctl daemon-reload
sudo systemctl enable --now volumen
sudo systemctl status volumen
journalctl -u volumen -f

FreeBSD (rc.d)

volumen runs on FreeBSD too — it is plain CRuby plus gems; only Puma (and its nio4r dependency) build a native extension, which compiles with the base clang toolchain.

pkg install ruby
# then install volumen as above (git clone + bundle install)

Conventional FreeBSD paths: config in /usr/local/etc/volumen/, data in /var/db/volumen/. Service script /usr/local/etc/rc.d/volumen:

#!/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_dir:="/usr/local/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 'cd ${volumen_dir} && exec bundle exec exe/volumen serve --config ${volumen_config} --content ${volumen_content}'"

run_rc_command "$1"

Enable and start:

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

cd /opt/volumen
sudo git pull
sudo bundle install --deployment
sudo systemctl restart volumen

Security notes

  • Bind to 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) — 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. Set a stable admin.session_key so sessions survive restarts.
  • Keep config.toml and users.toml readable only by the volumen user (chmod 600). Both may contain password hashes.
  • volumen hash-password reads the password without echo; never pass passwords on the command line.