Files
volumen/docs/CONFIGURATION.md
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

273 lines
18 KiB
Markdown

# Configuration
Volumen reads its configuration from one TOML file:
`/etc/volumen/config.toml` for a system install,
`~/.config/volumen/config.toml` for a per-user install, with the file that
exists tried first when no `--config` is given. With no file anywhere the
server runs on the built-in defaults, which put the state under
`~/.local/share/volumen`. No environment variable is read (the XDG ones only
locate those paths). Flags passed to `volumen serve` override the file, as
described under [Precedence](#precedence).
## File
The file is copied from the commented template (`internal/config/template.go`),
which is committed as `config.toml.example` at the repository root. Every key
the loader accepts is listed there with its
meaning, so an operator sees the whole surface and edits what this deployment
needs rather than discovering keys from a bare table dump. The copy lives at
the config path with mode `600`, because it is deployment state.
```toml
# volumen configuration.
#
# Every key this file accepts is listed here. Copy it to
# /etc/volumen/config.toml (or ~/.config/volumen/config.toml for a
# per-user installation), then edit.
#
# Keys that belong to no table come first, because a key written below a
# [table] header belongs to that table.
# Directory of the Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File holding the admin accounts (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
# How many previous versions of each post to keep in .revisions/
# (0 keeps none, which also makes deleting a post permanent).
revision_limit = 10
# Where the audit log is appended, or "" to disable auditing. Records
# who changed what, and when, in JSON lines.
audit_log = ""
[server]
# Address to bind, as an IP address: "::" is every interface, "::1" is
# loopback only, which is what a reverse proxy needs.
host = "::"
port = 9091
# Environment label: "development" or "production". It decides the
# startup safety checks (session key length, cookie flags, password
# policy).
env = "development"
# Set true ONLY when a trusted reverse proxy terminates TLS in front of
# volumen. Client addresses are then taken from X-Forwarded-For and
# cookies are marked Secure.
trust_proxy = false
# Addresses whose X-Forwarded-For may be believed, as addresses or CIDR
# prefixes. An empty list never reads the header and always uses the
# connection address; list the proxy so its clients each rate-limit
# under their own address.
trusted_proxies = []
# Set true in production to force the Secure flag on session cookies.
cookie_secure = false
# Log output format: "text" (human readable) or "json" (structured).
log_format = "text"
[site]
title = "Volumen"
description = "Powered by Volumen."
# Absolute URL of the public site, without a trailing slash.
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Fediverse handle surfaced as the author in feeds and meta tags.
# Leave empty to disable.
fediverse_creator = ""
[admin]
# Secret that signs session cookies (at least 64 bytes in production).
# Leave empty: the server generates one and keeps it in secret.key next
# to users.toml. A value here overrides that file.
session_key = ""
# Session lifetime in seconds (24 hours by default).
session_ttl = 86400
# Minimum password length enforced when a password is set in the admin UI.
min_password_length = 10
# Maximum password length, to bound the scrypt work.
max_password_length = 1024
# Maximum upload size in bytes (10 MB by default).
max_upload_bytes = 10485760
[api]
# Public API rate limit: requests allowed per window per client address.
# 0 disables rate limiting.
rate_limit = 60
# Rate-limit window length in seconds.
rate_limit_window = 60
# Scheduled publishing, for a post whose frontmatter carries publish_at.
# [scheduler]
# enabled = false
# interval = 300
# Outgoing webhooks: POST a signed JSON payload on post changes so a
# front-end can rebuild its cache or static pages. Repeat the block for
# more endpoints; events may be omitted to receive every event.
# [[webhooks]]
# url = "https://example.com/hooks/rebuild"
# secret = "a-long-random-string" # HMAC-SHA256 signing key
# events = ["post.created", "post.updated", "post.deleted", "post.published"]
# enabled = true
```
The template lists the four keys that belong to no table first, before any
header, because TOML puts a key written below a `[table]` header inside that
table. The loader refuses such a file rather than falling back to the default:
a `content_dir` written under `[server]` stops the start with a message naming
the fix, so a misplaced key is never silently ignored.
Five state files live next to `users_file`, in the same directory, and are not
read from the configuration file itself: `users.toml` holds the admin
accounts, started by the first-run wizard; `secret.key` holds the session
secret the server generated on first start (unless `[admin].session_key`
overrides it); `templates.toml` the `[[templates]]` array of post templates offered
in the admin "New post" form, each with `name`, `title`, `slug`, `tags`,
`body`, and an optional `[templates.fields]` table of editor inputs to
pre-fill (`author`, `lang`, `doi`, `orcid`, `series`, `series_order`,
`excerpt`, `cover`, …); `tokens.toml` the API token records, each with `name`, `token_hash`
(the SHA-256 digest, never the raw token), `created`, `last_used` and `scopes`;
and `webhooks.toml` the admin-managed webhook endpoints, of the same shape as
`[[webhooks]]` below. The admin rewrites each atomically at mode `600`, so
editing one by hand while the server runs is not advised. A token record
without a `scopes` list is unrestricted; a token created with a scope list
that names no recognised scope is refused rather than turned into an
unrestricted one; the scopes are `write` and `delete`
([`internal/tokens/tokens.go`](../internal/tokens/tokens.go)). A `webhooks.toml`
that cannot be parsed is logged and ignored, and the config-declared hooks
keep working.
## Keys
| Key | Type | Default | Effect |
|---|---|---|---|
| `content_dir` | string | `"/var/lib/volumen/posts"` | Directory of `.md` files with `+++` TOML frontmatter. Archived revisions live under `<content_dir>/.revisions/`, uploaded media under `<content_dir>/media/`. The parent directory must be writable, checked at startup with a write probe |
| `users_file` | string | `"/var/lib/volumen/users.toml"` | File holding the admin accounts. Its parent directory must be writable. `templates.toml`, `tokens.toml` and `webhooks.toml` are created next to it |
| `revision_limit` | integer | `10` | Archived versions kept per post under `<content_dir>/.revisions/`. `0` disables archiving: a save then keeps no previous version and a delete is permanent |
| `audit_log` | string | `""` | Path of the JSON-lines audit log, or empty to disable auditing. Each line is one JSON object carrying `ts` (RFC 3339, UTC), `action` and `user`, and, where the action knows them, `resource`, `detail` and `ip`. The file is created at mode `600`; a write failure is logged and never fatal |
| `server.host` | string | `"::"` | Bind address, written as an IP address rather than a name: the address is parsed, not resolved, so `"localhost"` is refused. `"::"` listens on every interface with IPv4 dual-stack; `"::1"` or `"127.0.0.1"` binds loopback only, for a reverse proxy in front |
| `server.port` | integer | `9091` | TCP port to bind. `1` to `65535` |
| `server.env` | string | `"development"` | `"development"` or `"production"`. The label decides one thing beyond its own validation: in production the session-key rules are fatal, as the `admin.session_key` row and [Validation](#validation) describe |
| `server.trust_proxy` | boolean | `false` | Take the client address from `X-Forwarded-For` instead of the connection, and mark session cookies `Secure`. The header is read only when the peer address is inside `server.trusted_proxies`, and the address taken is its last entry, the one the proxy appends when it forwards a request; the entries to its left are client-supplied and can be forged to rotate the rate-limit key, so the proxy must append the connecting address rather than pass the header through. Set it only behind a trusted reverse proxy that terminates TLS, and outside production the start logs a warning saying so |
| `server.trusted_proxies` | array of strings | `[]` | Addresses or CIDR prefixes whose `X-Forwarded-For` may be believed. An empty list never reads the header and always uses the connection address, so every client behind the proxy shares one rate-limit budget; a loopback deployment behind nginx lists `["::1", "127.0.0.1"]`. Each entry must parse as an address or a prefix. The address taken is always the header's last entry, the one the trusted proxy appended; with two chained proxies in front of the listener that entry is the front proxy's address, so all of its clients then share one rate-limit bucket, and the deployment must either let only the immediate proxy append the header or size the limit for the aggregate |
| `server.cookie_secure` | boolean | `false` | Mark session cookies `Secure` so a browser sends them over HTTPS only. Cookies also carry the flag when `server.trust_proxy` is set, and either setting makes responses carry `Strict-Transport-Security` |
| `server.log_format` | string | `"text"` | `"text"` for human-readable lines or `"json"` for one structured object per line through `log/slog`. Applied once at startup by `volumen serve` |
| `site.title` | string | `"Volumen"` | Site title, served by `/api/volumen/site` and used in the feeds |
| `site.description` | string | `"Powered by Volumen."` | Site description, used in the RSS channel, the Atom subtitle, the JSON Feed and `/api/volumen/site` |
| `site.base_url` | string | `"https://example.com"` | Canonical site URL, without a trailing slash. Must be an absolute URL. Builds every absolute link: post permalinks in the feeds, `feed_url`, the sitemap, preview links and the `Sitemap:` line in `robots.txt` |
| `site.language` | string | `"en"` | Default language, inherited by a post whose frontmatter and directory carry none. Emitted as `<language>` in RSS and `language` in the JSON Feed. Seeds the admin interface language for the login screen until an account picks its own |
| `site.author` | string | `"Anonymous"` | Site author, served by `/api/volumen/site`. It is not substituted into a post's own `author` field |
| `site.fediverse_creator` | string | `""` | Optional site-wide handle in `@user@host` form, validated against that pattern when set. Served by `/api/volumen/site`, used as the author of a JSON Feed item whose post has none, and offered as the default in the admin post form. A per-post value takes precedence |
| `admin.session_key` | string | `""` | Secret that signs session cookies and preview links. Leave it empty: the server generates a 64-character hex secret on first start and keeps it in `secret.key` beside the users file, so sessions survive restarts without the operator doing anything. A value here overrides that file and must be at least 64 bytes in production; a shorter configured value is refused at startup there, and tolerated in development, where an empty or short key means an ephemeral secret |
| `admin.session_ttl` | integer | `86400` | Session lifetime in seconds, used as the cookie `max-age` and enforced when the cookie is loaded. `1` to `31536000` (24 hours by default, one year at most) |
| `admin.min_password_length` | integer | `10` | Shortest password the admin accepts when an account is created or a password is changed. At least `1` and at most `admin.max_password_length` |
| `admin.max_password_length` | integer | `1024` | Longest password the admin accepts. Bounds the scrypt work, because unbounded input would be a denial-of-service vector. `1` to `1024`; a larger value is refused at startup rather than turning into a failed password change later |
| `admin.max_upload_bytes` | integer | `10485760` | Largest accepted upload in bytes: a media upload (`POST /admin/uploads`, refused with `413` and the code `too_large`), a post import and a profile photo, which report the size in the form instead. `1` to `1073741824` (10 MiB by default, 1 GiB at most) |
| `api.rate_limit` | integer | `60` | Requests allowed per window per client address across `/api/volumen/*`, counted in a sliding window. `0` disables rate limiting: the middleware is not installed and no `X-RateLimit-*` header is sent. Zero or greater. See the [API documentation](API.md#rate-limiting) for the headers and the `429` body |
| `api.rate_limit_window` | integer | `60` | Window length in seconds for the limit above. `1` to `86400` while rate limiting is enabled; a value outside that range is refused at startup |
| `scheduler.enabled` | boolean | `false` | Start the in-process publish loop, which is the alternative to running `volumen publish-due` from cron: with the scheduler enabled, `volumen serve` publishes due posts itself, once at startup and then on the interval. Read once, at startup |
| `scheduler.interval` | integer | `300` | Seconds between runs. At least `1` when the scheduler is enabled |
| `[[webhooks]].url` | string | unset | Endpoint URL, required, absolute `http` or `https`; an entry without it stops the start |
| `[[webhooks]].secret` | string | `""` | HMAC-SHA256 signing key. When set, each request carries `X-Volumen-Signature: sha256=<hex digest of the raw body>` |
| `[[webhooks]].events` | array of strings | `[]` | Events to receive: `post.created`, `post.updated`, `post.deleted`, `post.published`. Empty or absent receives every event |
| `[[webhooks]].enabled` | boolean | `true` | `false` keeps the entry but skips delivery |
Both scheduled-publishing mechanisms do the same work: a post whose
`publish_at` date is today or earlier loses that key and gains a `date` when
it carried none, and each published post fires the `post.published` webhook.
See [DEPLOYMENT.md](DEPLOYMENT.md#scheduled-publishing).
The optional `[[webhooks]]` array registers one outgoing webhook per entry.
When a post is created, updated, deleted or published, Volumen POSTs a signed
JSON body to every matching endpoint. Every request also carries
`X-Volumen-Event` (the event name), `X-Volumen-Delivery` (a unique delivery
id) and `User-Agent: volumen/<version>`. The body carries `event`,
`timestamp`, `version` and, for a post event, a `post` object holding the
post summary. Delivery runs in the background with up to three attempts. The
most recent deliveries are listed in the admin at **Settings, Webhooks**,
where a **Send test** button fires a `ping` event. Endpoints added in the
admin are not written into this file: they live in `webhooks.toml` beside the
users file, and a change applies there without a restart; the config-declared
entries are read-only in the admin and both sets deliver.
## Precedence
Sources, strongest first: the flags of `volumen serve`, then the configuration
file, then the built-in defaults. There is no environment variable and no
second file.
| Flag | Effect |
|---|---|
| `--config PATH` | Selects the file to read. Without it the server tries `/etc/volumen/config.toml`, then `~/.config/volumen/config.toml`, and uses the built-in defaults when neither exists |
| `--content DIR` | Overrides `content_dir` |
| `--host ADDR` | Overrides `server.host` |
| `--port N` | Overrides `server.port`, and must be `1` to `65535` |
```sh
volumen serve --config /etc/volumen/config.toml \
--content /var/lib/volumen/posts \
--host 127.0.0.1 \
--port 9000
```
A key absent from the file takes its default, and a key the loader does not
know is ignored, so a file left over from an older release still starts. A
missing file is not an error either: the defaults are used and one line is
logged saying so.
## Validation
`Config.Validate()` runs at startup, before the server binds.
The file is decoded into typed values, and a key whose TOML type does not match
its field is an error rather than a silent fallback to the default, because a
typo that quietly disables a setting is worse than a refusal to start. The
decoder lives in [`internal/config/config.go`](../internal/config/config.go)
and covers every key in the table above. A key the decoder does not know is
ignored, so a file written for another release still loads, and a root key
written below a table header is refused with the fix in the message, as the
[File](#file) section describes. The message names the key and the type it
found:
```text
volumen serve: parse config /etc/volumen/config.toml: interpres: server.port: cannot assign string to int
```
The remaining rules are value rules:
| Key | Rule |
|---|---|
| `server.host` | Non-empty, and an IP address |
| `server.port` | `1` to `65535` |
| `server.env` | `development` or `production` |
| `server.log_format` | `text` or `json` |
| `server.trusted_proxies` | Each entry an address or a CIDR prefix |
| `site.base_url` | Non-empty, and an absolute URL with a scheme and a host |
| `site.fediverse_creator` | `@user@host` when set |
| `admin.session_ttl` | `1` to `31536000` |
| `admin.min_password_length` | At least `1`, and at most `admin.max_password_length` |
| `admin.max_password_length` | At most `1024` |
| `admin.max_upload_bytes` | `1` to `1073741824` |
| `admin.session_key` | At least 64 bytes when `env = "production"` |
| `revision_limit` | Zero or greater |
| `api.rate_limit` | Zero or greater |
| `api.rate_limit_window` | `1` to `86400` while rate limiting is enabled |
| `scheduler.interval` | At least `1` while the scheduler is enabled |
| `[[webhooks]].url` | Absolute `http` or `https` URL |
| `content_dir` | The parent directory must be creatable and writable |
| `users_file` | The parent directory must be creatable and writable |
A failure stops the process with the message on standard error and exit code
`1`, as the example above shows. `volumen doctor --config PATH` reports the
same check without starting the server:
```text
config ok /etc/volumen/config.toml
config-validate ok
posts-readable ok
posts-render ok
users-file ok
password-hashes ok
```