Files
volumen/docs/deployment.md
T

288 lines
8.4 KiB
Markdown
Raw Normal View History

# 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 |
```bash
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:
```bash
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):
```bash
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:
```bash
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:
```bash
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`:
```ini
[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
```
```bash
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.
```sh
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`:
```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_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:
```sh
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
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.