Configuration¶
NetRoll is configured entirely through environment variables. This page lists every variable the app reads.
Two behaviours recur throughout and are worth knowing before you read the tables:
- Unset optional variables degrade cleanly. No encryption key means QRZ endpoints return HTTP 503; no admin emails means the admin surface is unreachable; no analytics domain means no tracker is ever downloaded.
- Set-but-invalid values are hard boot errors. A value that looks configured but silently does nothing is treated as worse than a refusal to start.
Core¶
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
PORT |
integer | No | 3000 |
TCP listen port. Non-numeric, out of range, or 0 is a boot error. |
STATIC_DIR |
path | No | frontend/dist |
Directory holding the built SPA. Baked into the container image. Set-but-empty is a boot error, as is a path with no index.html. |
DATABASE_URL |
string | Yes | — | Postgres connection string. Unset or empty is a boot error — there is no fallback. |
PUBLIC_BASE_URL |
URL | Yes | — | Base URL emailed magic links point at; set it to the public origin your reverse proxy serves. Unset or empty is a boot error — there is no dev-server fallback. Must be an https:// origin: any other scheme, an empty host, or plain http:// on anything but a loopback host (localhost, 127.0.0.1, [::1]) is a boot error. A trailing slash is stripped. |
Email¶
Sign-in is a magic link, so an instance without working SMTP is one nobody can log in to, even though the app boots.
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
SMTP_HOST |
hostname | Yes, in practice | — | SMTP relay host. |
SMTP_PORT |
integer | Yes, in practice | — | SMTP relay port. Non-numeric or out of range is a boot error. |
SMTP_USERNAME |
string | No | empty | Empty selects the unauthenticated transport. Set it only when the relay requires AUTH. |
SMTP_PASSWORD |
string | No | empty | Paired with SMTP_USERNAME. |
MAIL_FROM |
string | Yes | — | The From address on every outgoing email, bare or as Display Name <user@domain>. Unset is a boot error — there is no fallback sender. Must be an address the relay is authorized to send as. |
Envelope encryption¶
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
KEK |
base64 | No | none | Instance key-encryption key for stored QRZ credentials. Base64-encoded 32 bytes for AES-256. Unset means the app boots normally and QRZ credential endpoints return HTTP 503. Present but malformed — bad base64 or wrong length — is a boot error. |
Mint one with:
openssl rand -base64 32
The key is injected at runtime and never written to Postgres or backups. Do not rotate it casually: every credential sealed under the old key becomes permanently undecryptable.
Resource caps¶
Plain integers, not secrets. Unset uses the documented default; a set-but-invalid value — zero, negative, or non-numeric — is a boot error rather than a silent fallback.
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
MAX_NETS_PER_USER |
integer | No | 7 |
Maximum active, non-archived nets one account may own. |
MAX_OWNERS_PER_NET |
integer | No | 5 |
Maximum owners on one net. |
Caution on raising MAX_NETS_PER_USER. The net-creation rate limiter has a fixed burst of
12, which is not configurable by environment. Set the cap to 12 or higher and a user filling
their whole quota in one sitting may be throttled before reaching it. The app logs a boot-time
warning when the two are in that relationship; raising the burst itself requires a source change.
Aggregate magic-link cap¶
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
MAGIC_LINK_AGGREGATE_SENDS_PER_HOUR |
integer | No | 60 |
Instance-wide ceiling on outbound magic-link email, in sustained sends per hour across all addresses combined. The first hour after a restart allows up to twice this — see "What these numbers actually cap". Zero, negative, or non-numeric is a boot error. |
MAGIC_LINK_RESERVE_SENDS_PER_HOUR |
integer | No | 120 |
Second-tier budget, in sustained sends per hour, drawn on only once the ceiling above is spent, and only for addresses that signed in on this instance recently (or, on the authenticated email-change path, for any signed-in caller). Same first-hour doubling. Zero, negative, or non-numeric is a boot error. |
This is independent of, and not a replacement for, the per-address quota of 3 magic-link
requests per address per 15 minutes. That quota is fixed in source and has its own budget for
every address, so cycling a harvested address list would otherwise drive unbounded mail through
your SMTP relay. Both limits are in force, and the per-address one is evaluated first — a
single-address abuser still gets an honest 429.
The two budgets work as one two-tier cap. Every magic-link request draws on
MAGIC_LINK_AGGREGATE_SENDS_PER_HOUR first, whoever it is for. Only when that budget is spent
may an address that completed a sign-in on this instance within the last 30 days fall back to
MAGIC_LINK_RESERVE_SENDS_PER_HOUR. Everything else is dropped.
That reserve is what keeps an abuse campaign against unknown addresses from locking your own users out of the only sign-in path the app has — but it is a budget, not a guarantee, and it can itself be drained. See "What the reserve does and does not promise" below. The set of recently-signed-in addresses is held in memory and is emptied by a restart.
Requests over both budgets get the same 202 Accepted, with no mail sent and no token issued,
that a successful request gets. This is deliberate: a distinguishable response would let a caller
probe which addresses have accounts.
The authenticated email-change path spends these same two budgets. POST
/api/accounts/me/email-change mails a confirmation link to the new address, and that send draws
on MAGIC_LINK_AGGREGATE_SENDS_PER_HOUR first and MAGIC_LINK_RESERVE_SENDS_PER_HOUR second, the
same order a magic-link request uses — so confirmation-link sends across both endpoints stay
bounded by general + reserve, whichever endpoint drove them. (The one courtesy notice mailed to
the old address when a change is confirmed is not drawn from either budget; it is bounded by the
confirmations, at most one per admitted request.) Who may fall back to the reserve differs: any
signed-in, consented caller qualifies, not the recently-signed-in-address set above. The caller's
live session is the proof of control; the address being mailed is not one the caller has proven
control of, and gating on whether it has signed in here would let the response reveal that.
A capped email-change request is the same silent 202 — no mail, no token, and the
"is that address already taken" check is skipped too, so the cap cannot be used to probe accounts.
So that one account cannot drain the shared budget by itself, the email-change path also carries
a per-account quota, fixed in source: a burst of 5 requests per account, then one every 10
minutes. It is checked before the shared budgets, so a throttled account spends none of them, and
it answers an honest 429 with Retry-After — the key is the caller's own account, so the answer
says nothing about the target address.
What these numbers actually cap¶
Each budget is a token bucket that starts full and then refills at its configured rate. A pool of N therefore admits N sends immediately, plus up to another N replenished over the following hour — up to 2N in the first hour after the process starts, settling to N per hour sustained after that.
| Budget | Sustained | First hour after boot |
|---|---|---|
MAGIC_LINK_AGGREGATE_SENDS_PER_HOUR (default 60) |
60/hour | up to 120 |
MAGIC_LINK_RESERVE_SENDS_PER_HOUR (default 120) |
120/hour | up to 240 |
| Total | 180/hour | up to 360 |
A crash-looping process resets both buckets on every boot, so the burst figure is not a one-time startup allowance — it is repeatable. If you are sizing what your SMTP relay's sending reputation has to survive, use the burst number (360 on the defaults), not the sustained one. Both figures scale linearly with the two variables.
The default of 60 per hour sits far above a small instance's real signup and sign-in volume while bounding an email-bombing campaign to a volume a relay's sending reputation survives. Raise both if you run a large instance.
What the reserve does and does not promise¶
The reserve makes a returning user's sign-in survive a campaign against unknown addresses, which is what it exists for. It does not make sign-in unconditionally available, because the reserve is a finite budget and a determined attacker can drain it too.
The concrete shape: an attacker who controls a catch-all domain signs in once from each of about
ten addresses (a one-time cost of ten cells from the general pool), which puts all ten into the
recently-signed-in set. Each of those addresses may then request a link 3 times per 15 minutes
without tripping the per-address quota — 120 requests per hour across the ten, exactly the
reserve's default replenish rate. The reserve then sits at zero, and a genuine returning user
gets the silent 202: the outcome the reserve exists to prevent.
Raising MAGIC_LINK_RESERVE_SENDS_PER_HOUR raises how much draining it takes, and it raises the
outbound ceiling by the same amount. The two move together; there is no setting that buys one
without the other. This is a deliberate trade against the alternative — an unlimited exemption
for recently-signed-in addresses, which could not be drained but left total outbound mail
unbounded once an attacker had established exemptions.
Retention¶
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
TOKEN_RETENTION_DAYS |
integer | No | 30 |
How long dead magic-link tokens, sessions, and email-change tokens are kept before a background sweep deletes them. Zero, negative, or non-numeric is a boot error. |
One window covers all three tables. A sweep runs at startup and then hourly, deleting:
- magic-link tokens and email-change tokens whose expiry is older than the window,
- sessions whose absolute 30-day cap, or whose revocation, is older than the window.
Sessions are not pruned on idle time. A session you have not used in months but whose absolute cap has not been reached is left alone — idle expiry is decided per request, and deleting the row would tie its lifetime to a policy the sweep does not own. Nothing this sweep deletes can sign a signed-in user out.
The sweep is best-effort. It does not batch its deletes, it is not waited for at shutdown, and a tick that fails is not retried until the next hour. None of the three columns it filters on is indexed, deliberately — an index would add write amplification to the sign-in path — so every tick is three sequential scans, hourly, for as long as the process runs. At any realistic instance size that cost is negligible, and it is what keeps the tables bounded in the first place.
In steady state, losing a sweep costs nothing but a delay: nothing observes a row between the
moment it dies and the moment it is removed. The first sweep is the exception, because those
limits compose. On tables that have never been pruned it is the largest single DELETE the
system will ever issue; it starts at boot rather than an hour in; and because it is one
unbatched statement that nothing waits for, a restart in the middle of it rolls back the whole
thing rather than part of it. An interrupted first sweep therefore costs a full rollback plus
the dead tuples it churned, re-incurred on every boot until one sweep runs to completion. It
will complete — no statement timeout is configured — so on a large backlog, let the first boot
after upgrading finish rather than restarting into it.
What the sweep cannot do is get in a signed-in user's way. Nothing foreign-keys to these three tables, and it only ever selects rows already past the retention window — rows no live request can be holding — so its row locks cannot collide with session refresh on an authenticated request or with consuming a magic link. The worst case is one pooled connection held for the duration and a burst of write-ahead log.
Lower the window if you want tighter data minimisation; raise it if you want more history to correlate a sign-in against the audit log. Existing rows already past a newly-lowered window go on the next sweep.
Bot mitigation¶
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
BOT_MITIGATION_SECRET |
string | No | none | HMAC signing secret for the honeypot and timing form tokens on signup and net creation. Its presence is the on/off switch: unset or empty disables mitigation. Must be at least 16 bytes — present but shorter is a boot error. |
The minimum length is enforced because GET /api/form-tokens hands out token and signature
pairs publicly. A short key is brute-forceable from those pairs, which would defeat mitigation
while appearing configured.
Mint one with openssl rand -base64 32. Mitigation is self-hosted — there is no external CAPTCHA
vendor.
Uploaded avatars¶
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
AVATAR_DIR |
path | No | data/avatars |
Directory uploaded avatars are written to and served from, as /avatars/<account-id>.<ext>. Set-but-empty is a boot error, as is an unwritable path. |
The directory is created at boot and probed for writability, so a misconfigured volume fails at startup rather than on a user's first upload.
Mount a volume here in production. Avatars are the only user data outside Postgres, and without a volume they're lost whenever the container is replaced.
Uploads are capped at 1 MB and accepted only when the file's own magic bytes say PNG, JPEG,
WebP, or GIF. The filename and Content-Type are never trusted.
Platform admins¶
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
ADMIN_ACCOUNT_EMAILS |
comma-separated emails | No | none | Accounts granted the bounded admin surface. Matched case-insensitively against the account's normalized stored email. |
Unset or empty means no admins are configured — the app boots and the admin surface is unreachable. A list containing a blank or non-address entry, such as one left by a trailing comma, is a boot error rather than a silent partial parse.
The listed address grants nothing until an account registers under it. Platform admin is a separate axis from per-net roles.
ADMIN_ACCOUNT_EMAILS=admin@example.com,ops@example.com
Analytics¶
Off by default. See enable analytics.
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
PLAUSIBLE_DOMAIN |
domain | No | none | The site domain this instance reports as. Must match a site registered in the Plausible instance, or events are rejected. Unset or empty means no analytics: the tracker chunk is never downloaded and nothing is requested from any external host. |
PLAUSIBLE_SCRIPT_HOST |
origin | No | Plausible Cloud | Origin of a self-hosted Plausible. Events are posted to <origin>/api/event. Ignored entirely when PLAUSIBLE_DOMAIN is unset. |
The tracker ships in NetRoll's own bundle, so no script is fetched from this host.
Content Security Policy¶
The app sends its own Content-Security-Policy on every response whose Content-Type is
text/html — the app shell, every client-side route, and the documentation under /docs.
Responses of any other type — API JSON, the /healthz probe, JavaScript, CSS, images, avatars —
carry none. The header follows the content type, not the path: a request for a path no API route
matches, such as GET /api/typo, falls through to the SPA shell, which is HTML, and so returns
200 with the header. Your reverse proxy must not set a CSP: two CSP headers on one response are both
enforced, and the intersection is a policy nobody tested.
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
CSP_POLICY |
string | No | derived | Replaces the whole policy verbatim, trimmed — no merging with the default. Set-but-empty is a boot error, as is a value containing a carriage return, a line feed, or any byte outside visible ASCII. |
CSP_REPORT_ONLY |
true / false |
No | false |
true sends the same policy as Content-Security-Policy-Report-Only: the browser logs violations to its console and enforces nothing. Case-insensitive, trimmed. Anything other than true or false is a boot error. |
The default policy, for PUBLIC_BASE_URL=https://netroll.example with analytics off, is:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' wss://netroll.example; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'
Directive by directive:
- Scripts come from the app's own origin only. The built shell has no inline script, so there
is no hash, no nonce, and no
'unsafe-inline'. - Styles are not origin-only.
<style>elements and stylesheets come from the app's origin only (style-src-elem 'self'), but inlinestyle=""attributes are allowed (style-src-attr 'unsafe-inline') because React sets them throughout the UI. The split is deliberate: an attribute cannot carry a selector, so it cannot do the attribute-selector exfiltration an injected<style>element can. The plainstyle-src 'self' 'unsafe-inline'is the fallback for browsers that predate the CSP level 3 elem/attr split; on those, injected<style>elements are not refused. - Images come from the app's origin,
data:URIs (Material for MkDocs embeds its icons in CSS under/docs), and anyhttps:host, because an avatar may point at any HTTPS URL and Gravatar is one. - Fonts come from the app's origin only.
connect-srcnames the app's origin, the WebSocket origin derived fromPUBLIC_BASE_URL(wss://forhttps://,ws://for a loopbackhttp://; host and port kept, path dropped), and — only whenPLAUSIBLE_DOMAINis set — the analytics origin (PLAUSIBLE_SCRIPT_HOST, or Plausible's cloud host when that is unset). Change those two variables andconnect-srcfollows; nothing else needs editing.- Plugins, framing and
<base>are refused outright (object-src 'none',frame-ancestors 'none',base-uri 'none'), and forms may submit to the app's origin only.
Use CSP_REPORT_ONLY=true to watch a browser console for violations during a rollout, then
remove it. There is no reporting endpoint. Report-only enforces nothing, frame-ancestors 'none'
included — and the app sets no X-Frame-Options of its own — so while it is on, the page is
frameable unless your proxy sets X-Frame-Options: DENY (the example Caddyfile does). Keep the
window short.
Donations¶
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
KOFI_USERNAME |
string | No | none | Ko-fi username behind the footer's support link. The app builds https://ko-fi.com/<username>. Unset or empty renders no link. |
A plain link only — no third-party widget, no overlay script, nothing that can track a visitor.
Constraints¶
- Every variable is read at boot. Changing one requires restarting the app.
- The container side of your port mapping must equal
PORT, or the host port forwards to nothing. - Secrets are supplied as literal values by self-hosters. The
op://references in.env.templateanddeploy/.env.production.tplare 1Password bindings for the hero instance and are not a required mechanism.