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

18 KiB

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.

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.

# 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). 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 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 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.

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
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 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 section describes. The message names the key and the type it found:

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:

config           ok  /etc/volumen/config.toml
config-validate  ok
posts-readable   ok
posts-render     ok
users-file       ok
password-hashes  ok