Troubleshoot self-hosting¶
Problems running your own NetRoll instance. NetRoll validates its configuration at boot and names the offending variable in the error, so start with the logs — that line usually identifies the problem outright.
podman compose logs app | tail -30
The container exits immediately at boot¶
Symptoms: The app container starts and stops. The log ends with a configuration error.
Cause: A required variable is missing, or an optional one is set to an invalid value. Both are deliberate hard failures — a value that looks configured but silently does nothing is worse than a refusal to start.
Fix: Match the error to the variable:
| Error mentions | Fix |
|---|---|
DATABASE_URL |
Set a Postgres connection string. There is no fallback. |
MAIL_FROM |
Set the From address. There is no fallback sender. |
PUBLIC_BASE_URL |
Set it to the public origin your proxy serves, as an https:// URL. There is no fallback when unset. Plain http:// is accepted only for loopback hosts (localhost, 127.0.0.1, [::1]); any other scheme, or an empty host, is refused. |
PORT |
Use a number from 1 to 65535. 0 is refused because it would bind an ephemeral port. |
STATIC_DIR |
Don't set it empty. The path must contain index.html. |
KEK |
Base64-encoded 32 bytes. Unset is fine; malformed is not. |
BOT_MITIGATION_SECRET |
At least 16 bytes. Unset is fine; too short is not. |
MAX_NETS_PER_USER, MAX_OWNERS_PER_NET |
Positive integers. Zero, negative, and non-numeric are refused. |
ADMIN_ACCOUNT_EMAILS |
Remove the blank or malformed entry — often a trailing comma. |
AVATAR_DIR |
Give a writable path. The directory is created and probed at boot. |
CSP_POLICY |
Don't set it empty, keep it to a single line of visible ASCII, and make sure it names at least one recognised CSP directive. Unset is fine — the app derives its own policy. |
CSP_REPORT_ONLY |
Exactly true or false. Unset is fine and means enforce. |
Verify: podman compose logs app shows a clean start, and
curl -s http://localhost:8080/healthz prints {"status":"ok"}.
See the configuration reference.
The app boots but no email is sent¶
Symptoms: The instance runs, sign-in requests report success, no email arrives.
Cause: The app validates MAIL_FROM's presence at boot but can't validate that your relay
accepts it. Sign-in is a magic link, so this makes the instance unusable even though it looks
healthy.
Fix:
- Check the app logs for SMTP errors. Credentials themselves are never logged.
- Confirm
SMTP_HOSTandSMTP_PORTreach your relay from inside the container. - Confirm the relay is authorized to send as
MAIL_FROM, and that the domain has SPF and DKIM for it. - If the relay needs no authentication, leave
SMTP_USERNAMEempty. Setting it makes the app attempt an AUTH exchange the relay may reject.
Verify: Request a magic link and confirm delivery, then follow it to sign in.
A feature returns HTTP 503¶
Symptoms: The app works, but one endpoint consistently returns 503.
Cause: An optional feature whose configuration is absent. This is the designed degradation path, distinct from a boot error.
Fix:
- QRZ credential endpoints: set
KEKto a base64-encoded 32 bytes. Once set, don't rotate it casually — credentials sealed under the old key become permanently undecryptable.
Verify: The endpoint responds normally after a restart.
The admin surface is unreachable¶
Symptoms: No admin endpoints work, and the app booted cleanly.
Cause: ADMIN_ACCOUNT_EMAILS is unset, or an address is listed but no account has
registered under it. Listing an address grants nothing until the account exists.
Fix:
- Add the address to
ADMIN_ACCOUNT_EMAILSand restart. - Sign in at least once with that address to create the account.
Verify: The admin endpoints respond for that account.
Avatars disappeared after an upgrade¶
Symptoms: Uploaded avatars are gone; users show Gravatar defaults.
Cause: No volume mounted at AVATAR_DIR. Avatars are the only user data outside Postgres,
and a deploy replaces the container rather than updating it in place.
Fix:
- Mount a named volume at the avatar directory,
/app/data/avatarsin the published image. - Bring the stack up again.
Avatars uploaded before the volume existed are not recoverable. Users can re-upload.
Verify: Upload an avatar, recreate the container, and confirm it survives.
The app renders but the console shows CSP violations¶
Symptoms: The page loads, but the browser console reports Content-Security-Policy
violations — blocked scripts or styles, or analytics events that never send.
Cause: Your reverse proxy is also setting a Content-Security-Policy. The app sends its own,
and a browser given two enforces both: the effective policy is their intersection, which nobody
wrote.
Fix:
- Remove the CSP from your proxy configuration.
curl -sI https://your-host/should show exactly onecontent-security-policyheader — the app's. - To diagnose without blocking anything, set
CSP_REPORT_ONLY=trueand restart: the same policy is sent as report-only, so the console shows what would be blocked while the page keeps working. Report-only enforces nothing,frame-ancestors 'none'included. That directive is the app's only anti-framing rule — it sets noX-Frame-Options— so while the flag is on, the page is frameable unless your proxy setsX-Frame-Options: DENY(the example Caddyfile does). Keep the window short, and remove the flag once the proxy is clean.
Verify: The console is quiet and, with analytics enabled, events reach your Plausible origin.
The host port serves nothing¶
Symptoms: The container is running and healthy; connecting to the host port gives nothing.
Cause: The container side of the port mapping doesn't equal PORT.
Fix: Make them match. With PORT=3000, the mapping is '8080:3000'. If you change PORT,
change the mapping too.
Verify: curl -sI http://localhost:8080/ returns 200.
A webhook URL is rejected¶
Symptoms: Saving a delivery webhook fails with a specific message, or delivery never fires.
Cause: The egress client refuses URLs it isn't allowed to call. All outbound HTTP shares one SSRF-guarded client, which is what makes it safe to let net owners type in a URL.
Fix:
- Use HTTPS. Plain HTTP is refused.
- Use a publicly-routable host. Loopback, link-local, private (RFC 1918 and ULA), and cloud-metadata addresses are all refused.
- Remember the check runs again at delivery time against the pinned resolved IP, so a hostname that later resolves into a denied range is still blocked.
Verify: The URL saves, and closing a session delivers a signed POST to it.
Rate limits behave oddly across replicas¶
Symptoms: Limits appear looser than configured.
Cause: The rate limiter is in-app and per-process. Running multiple app replicas multiplies the effective limits, because each process counts independently.
Fix: Run a single app replica. NetRoll is designed for single-node operation at its expected load. If you need multiple replicas, expect the limits to scale with them.
Verify: Limits behave as configured with one replica.
Get help¶
File an issue at github.com/nkbooth/netroll/issues.
Include the image tag you're running, the exact boot error line if there is one, and your compose file with secrets redacted.