rescriptum
Configuration
Guide
Configuration
Configuration
Environment variables — and, optionally, a file to read them from, in either of two shapes. There is no command line to get wrong, and the variables are the whole configuration: both file formats set exactly the same things under exactly the same rules, so nothing you can write in a file means anything the environment could not.
The variables
| Variable | Default | Meaning |
|---|---|---|
RESCRIPTUM_CONFIG | unset | Read defaults from this TOML file — see below |
RESCRIPTUM_ENV_FILE | unset | Read defaults from this KEY=value file — see below |
RESCRIPTUM_STORE | files | files (a directory) or sqlite (a database) |
RESCRIPTUM_ANSWERS_DIR | /srv/answers | Directory of answer documents |
RESCRIPTUM_DB_PATH | /srv/answers.db | Database path, when RESCRIPTUM_STORE=sqlite |
RESCRIPTUM_LISTEN_ADDR | 0.0.0.0:8000 | Listen address. :0 picks a free port, and the bound one is printed |
RESCRIPTUM_WORKERS | CPU count | Async runtime threads. Not a concurrency limit |
RESCRIPTUM_MAX_CONNECTIONS | 2048 | In-flight connections before shedding with 503 |
RESCRIPTUM_TIMEOUT_SECS | 10 | Header-read timeout and whole-connection deadline |
RESCRIPTUM_ANSWER_TOKEN | unset | Bearer token the answer endpoint requires. Unset means open |
RESCRIPTUM_ADMIN_ADDR | unset | Admin API listener. Unset means the admin API is off |
RESCRIPTUM_ADMIN_TOKEN | unset | Admin bearer token, 16+ characters. Required with RESCRIPTUM_ADMIN_ADDR |
RESCRIPTUM_CAPTURE_DIR | unset | Record request bodies here. Unset means no capture |
RESCRIPTUM_LOG | all | all, problems or off — see below |
RESCRIPTUM_LOG_FILE | unset | A file to append to, or stdout / stderr. Unset means stderr |
RESCRIPTUM_MEDIA_DIR | unset | Installer images. Unset means no media and no media listener |
RESCRIPTUM_MEDIA_ADDR | 0.0.0.0:8001 | The media listener, when there is a media directory |
RESCRIPTUM_MEDIA_TIMEOUT_SECS | 600 | Whole-transfer deadline. Deliberately not the answer listener’s 10 |
RESCRIPTUM_MEDIA_MAX_CONNECTIONS | 16 | Concurrent transfers. Low on purpose: each holds its permit for minutes |
RESCRIPTUM_PUBLIC_HOST | derived | The host generated URLs name. A host, never a URL |
RESCRIPTUM_BOOT_ALLOW | unset | Client CIDRs allowed to fetch boot media. Unset means anyone who can reach the port |
RESCRIPTUM_BOOT_DIR | unset | Loaders and menus, handed out over TFTP. Unset means no TFTP at all |
RESCRIPTUM_TFTP_ADDR | 0.0.0.0:69 | The TFTP listener, or off for none. Port 69 is privileged; see RESCRIPTUM_USER |
RESCRIPTUM_TFTP_PORT_RANGE | unset | The ports transfers answer from, as first-last. A TFTP transfer leaves port 69 immediately — the server replies from a fresh port and the client acknowledges to that — so a firewall allowing only 69 drops the acknowledgement and the machine looks like it lost interest. Pin the range so it can be opened. Unset, the kernel picks |
RESCRIPTUM_TFTP_BLKSIZE | 1468 | The largest TFTP block to agree to. 1468 fills a 1500-byte path exactly — 1468 payload, 4 TFTP, 8 UDP, 20 IP — so a VLAN tag or a tunnel makes the frame too big and a PXE ROM usually just stops. Lower it (1400, or 512) when a boot stalls at the first block |
RESCRIPTUM_BOOT_TIMEOUT_SECS | 15 | Seconds before the menu falls through to local boot |
RESCRIPTUM_BOOT_UNCLAIMED | menu | What a machine no answer claims gets. local hands it back to its firmware instead, which inverts what an answer file means: present is install this one rather than leave this one alone |
RESCRIPTUM_INSTALLED_TOKEN | unset | Proxmox’s [post-installation-webhook] token. Set it and POST /installed exists, dropping a machine’s install claim when it reports success. Unset, there is no endpoint |
RESCRIPTUM_BOOT_LOGO | built-in | A PNG to show behind the menu |
RESCRIPTUM_BOOT_TITLE | built-in | The menu’s title bar |
RESCRIPTUM_USER / _GROUP | unset | Drop to these after binding. The other order fails on deployment |
/srv is where the filesystem hierarchy standard puts data served by the system, which is
what an answers directory is. Both defaults live there so that a bare rescriptum does
something plausible on any Linux host. Nothing creates the directory for you, and the server
says so at startup if it is missing.
Logging
One line per event, on stderr by default. Two knobs, because the two questions are different.
What — RESCRIPTUM_LOG:
| Value | Keeps |
|---|---|
all (default) | every request, plus startup, warnings and errors |
problems | startup, warnings, errors, and only the requests that did not succeed |
off / none | nothing at all |
A successful answer is one line, and at thirteen thousand requests a second that is the
only thing here with any volume. problems is what you want once a rollout is routine and
the disk is not. Everything else is low-volume and diagnostic, so it survives both.
A request that never reached a status at all — a connection that timed out mid-body —
counts as a problem. An unrecognised value falls back to all with a warning: a typo must
not be the reason nobody can see why a rollout failed. The level is named in the startup
line (log=problems), so an empty log explains itself.
Where — RESCRIPTUM_LOG_FILE:
| Value | Goes to |
|---|---|
unset, or stderr | stderr, which is what a supervisor reads |
stdout | stdout |
| any other value | that file, appended to; parent directories are created |
A file that cannot be opened is a startup error, not a fall back to stderr — that would be a silent surprise discovered much later. A write that fails once the server is running is dropped instead: a provisioning server that died because its log disk filled up would fail every install in flight in order to report that it could not report something.
Rotation is yours. Under systemd there is nothing to do, since the log goes to the journal;
with a file, point logrotate at it with copytruncate.
The TOML file
RESCRIPTUM_CONFIG names a file in TOML that sets the same variables in a shape meant to
be read. Reach for it when a person edits the file by hand — on a NAS, in File Station
or over SMB — which is exactly where RESCRIPTUM_ANSWERS_DIR=… on every line reads
poorly and where the word “environment” sends people looking for a shell that is not
there.
# /etc/rescriptum.toml (chmod 600, owned by root)
answers_dir = "/srv/answers"
listen_addr = "0.0.0.0:8000"
log = "problems" # all | problems | off
[store]
kind = "sqlite"
db_path = "/srv/answers.db"
[server]
workers = 2
max_connections = 2048
timeout_secs = 10
[admin]
addr = "127.0.0.1:8001"
token = "…"
[answer]
token = "…"
capture_dir = "/var/lib/rescriptum/captures"
$ RESCRIPTUM_CONFIG=/etc/rescriptum.toml rescriptum
2026-08-29T12:42:02Z - reading configuration defaults from /etc/rescriptum.toml (8 set)
Every rule the env file has, this one has too: never discovered, only named (there is
no ./rescriptum.toml); the real environment wins; and a file that was asked for and
cannot be read is a startup error, never a warning.
Put it outside the answers directory. Every servable .toml at the top of that
directory is an answer document, and this format shares the extension — a configuration
file dropped in there is reported by check as a misplaced answer, and migrate offers
to move it. /etc is the obvious home; on a packaged install the package chooses one.
The names
The prefix goes away and tables do the grouping. Nothing else changes: each line below is
the variable of the same name, and rescriptum config prints both spellings.
| In the file | Variable |
|---|---|
answers_dir | RESCRIPTUM_ANSWERS_DIR |
listen_addr | RESCRIPTUM_LISTEN_ADDR |
log, log_file | RESCRIPTUM_LOG, RESCRIPTUM_LOG_FILE |
public_host | RESCRIPTUM_PUBLIC_HOST |
user, group | RESCRIPTUM_USER, RESCRIPTUM_GROUP |
store.kind, store.db_path | RESCRIPTUM_STORE, RESCRIPTUM_DB_PATH |
server.workers, server.max_connections, server.timeout_secs | RESCRIPTUM_WORKERS, RESCRIPTUM_MAX_CONNECTIONS, RESCRIPTUM_TIMEOUT_SECS |
admin.addr, admin.token | RESCRIPTUM_ADMIN_ADDR, RESCRIPTUM_ADMIN_TOKEN |
answer.token, answer.capture_dir | RESCRIPTUM_ANSWER_TOKEN, RESCRIPTUM_CAPTURE_DIR |
media.dir, media.addr, media.timeout_secs, media.max_connections | the RESCRIPTUM_MEDIA_* four |
boot.dir, boot.allow, boot.unclaimed, boot.timeout_secs, boot.logo, boot.title | the RESCRIPTUM_BOOT_* six |
tftp.addr, tftp.port_range, tftp.blksize | the RESCRIPTUM_TFTP_* three |
installed.token | RESCRIPTUM_INSTALLED_TOKEN |
The format
| Any TOML scalar | a number may be written as a number (workers = 2) or as a string; both reach the server as the same setting |
A # comment | anywhere, including at the end of a line — unlike the env file, which has no escapes and so cannot have inline comments |
A value with a #, a quote or a space | fine, quoted the way TOML quotes things, and read back unchanged |
"" | unset — the same rule as an exported-but-empty variable, which is what lets config unset empty a line instead of deleting the paragraph that documents it |
| The same key twice | refused by TOML itself, so the file does not load |
| A key this program does not read | a warning naming it, so admin.tokenn is caught rather than ignored |
| A list or a table where a value belongs | a startup error: unlike a misspelling it was aimed at a real setting, and serving the default while the file says otherwise would be silent |
| A file others can read | a warning with its mode, because it may hold admin.token |
Warnings name keys and paths, never values.
Both files at once
Naming both is a transition rather than a steady state, so nothing is refused and the
order is stated at startup: the environment beats the TOML file, which beats the env
file. rescriptum config shows which of the three put every value in force, and
config set writes to the TOML file — the one the server reads first, so that a write
cannot be a change that silently does nothing.
The env file
RESCRIPTUM_ENV_FILE names a file of the same variables. It exists for deployments with
nowhere good to put a token — chiefly Synology DSM 7, which has no systemd. Under
systemd, EnvironmentFile= already does this and you do not need it.
# /etc/rescriptum.env (chmod 600, owned by root)
RESCRIPTUM_STORE=sqlite
RESCRIPTUM_DB_PATH=/srv/answers.db
RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001
RESCRIPTUM_ADMIN_TOKEN=…
$ RESCRIPTUM_ENV_FILE=/etc/rescriptum.env rescriptum
2026-08-24T12:42:02Z - reading configuration defaults from /etc/rescriptum.env (4 set)
It is never discovered, only named. There is no ./.env. This binary runs as root: if
it picked a file up from whatever directory it happened to be launched in, anyone who could
write there would own RESCRIPTUM_ADMIN_TOKEN — and with it the root password of every
machine installed afterwards.
The real environment wins. The file supplies defaults, so something exported deliberately at launch is never silently overridden. An exported-but-empty variable counts as unset, so the file still applies.
A file that was asked for and cannot be read is a startup error, not a warning. That is the whole point: the failure it replaces is a server coming up on its defaults — wrong answers directory, no admin token — without a word in the log.
The format
KEY=value, one per line | leading export is accepted, so the same file can also be sourced |
# at the start of a line | a comment |
# anywhere else | part of the value. There are no inline comments: truncating a token at a # it legitimately contains would be silent, and a comment landing in a value is loud |
"quoted" or 'quoted' | the quotes are stripped and inner whitespace is kept; unquoted values are trimmed |
$HOME, ${x} | not expanded. This is not a shell — no substitution, no continuation lines |
| the same key twice | a startup error, rather than a guess about which was meant |
| a key this program does not read | a warning naming the key — so RESCRIPTUM_ADMIN_TOKENN is caught rather than ignored |
| a file others can read | a warning with its mode, because it may hold the admin token |
Warnings name keys and paths, never values.
Reading and editing it
rescriptum config prints every variable, its value, and which of the files and the
environment put it there — the distinction that matters, because the files supply
defaults and the real environment wins. config set edits the file the way you would want
it edited, in either format: comments kept, a commented-out setting uncommented in place
rather than duplicated (in the env file) or the value replaced where it stands (in TOML),
and a change that would leave a server unable to start refused before anything is
written. It is documented in the command line reference, and it is
what the DSM application drives
underneath.
Invalid values
| Case | What happens |
|---|---|
Exported but empty (RESCRIPTUM_LISTEN_ADDR=) | treated as unset — an empty value is a mistake, not an instruction |
| Whitespace-only | same, and values are trimmed |
| A zero or unparseable number | falls back to the default, rather than starting a server that accepts connections and never answers |
RESCRIPTUM_STORE set to anything else | a warning, and files is used |
RESCRIPTUM_ENV_FILE or RESCRIPTUM_CONFIG naming a missing, unreadable or malformed file | a startup error |
| A TOML setting given a list or a table | a startup error, unlike a misspelled key, which warns |
RESCRIPTUM_STORE=sqlite on a binary built without the feature | a startup error |
Startup errors
These stop the server rather than warning, because starting anyway would be worse:
| Condition | Why it is fatal |
|---|---|
RESCRIPTUM_ADMIN_ADDR set with RESCRIPTUM_STORE not sqlite | two ways to change the same configuration, racing |
RESCRIPTUM_ADMIN_ADDR set with no RESCRIPTUM_ADMIN_TOKEN | an open API that rewrites root credentials |
RESCRIPTUM_ADMIN_TOKEN under 16 characters | short enough to guess |
| The listen address cannot be bound | nothing to do |
| The store cannot be opened | nothing to serve |
RESCRIPTUM_MEDIA_ADDR set with no RESCRIPTUM_MEDIA_DIR | a listener with nothing to serve |
RESCRIPTUM_MEDIA_ADDR equal to the answer or admin address | the second bind loses, and which one depends on start order |
RESCRIPTUM_PUBLIC_HOST carrying a scheme, a port or a path | it is written into URLs for two listeners; one port in the value pins every generated script to one of them |
RESCRIPTUM_TFTP_ADDR set with no RESCRIPTUM_BOOT_DIR | a listener with no loaders to hand out |
| The boot directory cannot be resolved | every path check compares against it |
RESCRIPTUM_USER names an account that does not exist | nothing to become |
Startup warnings
These are printed and the server carries on:
| Condition | Line |
|---|---|
| Answers directory missing | warning: … does not exist yet — every request will 404 until it does |
| Answers path exists but is not a directory | warning: … is not a directory — every request will 404 until it is |
| Answers directory present but unreadable | warning: … cannot be read: … — every request will 404 until that is fixed. The likeliest cause is the server running as a user that is not the directory’s owner |
| Admin API not on loopback | warning: the admin API is not bound to loopback — … |
RESCRIPTUM_ANSWER_TOKEN under 16 characters | a warning, not an error — refusing to start would leave a fleet unable to install |
| Any problem in the answer set | one warning: line each, the same set check reports |
RESCRIPTUM_PUBLIC_HOST unset | The routing table’s answer, or the sole interface address when there is no default route. Logged either way, as a warning naming the other addresses when there are any. A NAT host still gets it wrong silently |
| TFTP cannot bind | warning: cannot bind TFTP on … — the one listener whose failed bind is not fatal. Port 69 is the only privileged port in the design, so it is the only bind that can fail for something nobody configured; answers are the product, and dying would fail every install in flight to report that a second port could not be opened. boot check exits non-zero and the message names the ways to have the port |
| Media directory missing or unlistable | one warning: media: … line — a fleet must never be unable to install because one image is odd |
Compile-time options
| Feature | Default | Effect |
|---|---|---|
sqlite | on | The SQLite store and the admin API |
boot | on | The media catalogue, the ISO reader and the media listener |
Measured on ARMv7 (gnueabihf, glibc floor 2.17), all four in one sitting on 2026-08-29. Re-measure rather than quoting these: they moved by about 375 KB when that target changed from musl, and the set they replace here had drifted about 200 KB out of date.
| Build | Bytes |
|---|---|
| both (default) | 2,813,712 |
sqlite only | 2,557,592 |
boot only | 1,649,048 |
| neither | 1,392,544 |
Fixed limits
Not configurable, and deliberately so:
| Limit | Value | Where |
|---|---|---|
| Request body | 1 MB | answer endpoint — an aberrant Content-Length is refused from the header |
| Document size | 256 KB | admin API PUT |
| Captured requests | 1000 captures | counted from the directory at startup, so a restart does not start again |
| Admin failures before a block | 5 within 60 s | block doubles to a maximum of 900 s |
| Addresses tracked by the guard | 4096 | so the guard cannot be turned into a memory leak |
| Listing reload backstop | 1 s | forces a re-read even when the directory mtime looks unchanged |