Environment variables
Configuration lives in .env on a self-hosted install. Set it to mode 600 and
never commit it.
Generate every secret fresh, per deployment. Never copy one from another install, from this page, or from a demo.
openssl rand -base64 32| Variable | Default | Notes |
|---|---|---|
NODE_ENV |
development |
Set to production for anything real |
APP_VERSION |
Stamped in at build time. Shown under Admin, Licence | |
RELEASE_CHANNEL |
stable |
stable or beta. Determines which release is recommended, never causes an update |
Database
Section titled “Database”| Variable | Notes |
|---|---|
DATABASE_URL |
PostgreSQL connection string. Required |
DATABASE_URL="postgresql://user:pass@localhost:5432/plugboard?schema=public"| Variable | Default | Notes |
|---|---|---|
REDIS_URL |
redis://localhost:6379 |
Optional. Only the legacy people-sync and digest worker uses it |
Object storage
Section titled “Object storage”Optional. Only needed for logos, photos and exports.
| Variable | Notes |
|---|---|
S3_ENDPOINT |
Any S3-compatible endpoint |
S3_REGION |
|
S3_ACCESS_KEY |
|
S3_SECRET_KEY |
|
S3_BUCKET |
Addresses
Section titled “Addresses”The most important block, and the one most often wrong.
| Variable | Notes |
|---|---|
PUBLIC_URL |
The origin your users type, for example https://helpdesk.yourschool.org |
PUBLIC_HOST |
The same hostname without the scheme |
API_URL |
The API origin |
WEB_URL |
The web origin |
API_PORT |
Default 4000 |
WEB_PORT |
Default 3000 |
PUBLIC_URL (and WEB_URL) is the CORS allow-list, the base for every emailed
link, the SSO redirect target and the portal address. Set it to an internal name
and you generate links your users cannot open.
https://x, https://x/ and http://x are three different values as far as
CORS is concerned.
Authentication
Section titled “Authentication”| Variable | Default | Notes |
|---|---|---|
JWT_SECRET |
Signs session and refresh tokens. Rotating it logs everybody out | |
JWT_ACCESS_TTL |
15m |
Access token lifetime |
JWT_REFRESH_TTL |
30d |
Refresh token lifetime |
Secrets
Section titled “Secrets”| Variable | Notes |
|---|---|
SECRETS_MASTER_KEY |
32-byte base64. Encrypts connector credentials and backups |
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"Two options. Use a reverse proxy, or point the application at a certificate.
| Variable | Notes |
|---|---|
TLS_CERT_FILE |
PEM certificate, including intermediates |
TLS_KEY_FILE |
PEM private key |
TLS_CA_FILE |
Optional intermediate chain, if your issuer ships one separately |
TLS_PFX_FILE |
A PKCS#12 file, which is what most Windows tooling produces |
TLS_PFX_PASSWORD |
Its password |
Leave all unset to serve plain HTTP and terminate TLS at a proxy. See HTTPS and certificates.
Multi-tenancy
Section titled “Multi-tenancy”| Variable | Notes |
|---|---|
DEFAULT_TENANT_SLUG |
Resolves a single default tenant when no subdomain matches. For single-tenant self-hosted installs |
Internal
Section titled “Internal”| Variable | Notes |
|---|---|
INTERNAL_API_TOKEN |
Shared secret the worker uses to call internal endpoints |
Self-service portals
Section titled “Self-service portals”| Variable | Default | Notes |
|---|---|---|
PORTAL_USERNAME_ENTRY |
0 |
1 allows username-only entry at the kiosk and parent portal. Refused by default because typing a username proves nothing |
PORTAL_SESSION_TTL_MS |
900000 |
Portal session lifetime, 15 minutes |
Outbound fetch guard
Section titled “Outbound fetch guard”| Variable | Default | Notes |
|---|---|---|
ALLOW_PRIVATE_EGRESS |
0 |
1 allows outbound requests to private, loopback and link-local addresses |
Blocks a tenant-supplied URL (a monitor target, a webhook, a connector base URL) from being used to reach internal hosts or a cloud metadata service.
Set it to 1 on an on-premises install that legitimately monitors LAN addresses.
Leave it off for anything multi-tenant.
Live updates
Section titled “Live updates”| Variable | Default | Notes |
|---|---|---|
SSE_MAX_STREAMS_PER_USER |
4 |
Live-update streams one signed-in person may hold open at once |
SSE_MAX_STREAMS_PER_TENANT |
200 |
Live-update streams one school may hold open at once |
SSE_MAX_STREAMS |
2000 |
Live-update streams the whole deployment may hold open at once |
The console holds one live-update stream open per signed-in tab, and every open stream asks the database every 30 seconds whether the person behind it is still signed in. The number of them is therefore a load on the same connection pool every other request is queueing for.
A stream past a ceiling is refused; the console backs off and tries again, so the tab recovers on its own once one closes. Four per person covers a technician with several tabs open and one the browser has not reaped yet.
Raise these on a large desk, and raise the database connection pool with them.
Setting any of them to 0 or to something unparseable means “not configured”
and leaves the default in place, so live updates cannot be switched off by
mistake.
Licensing
Section titled “Licensing”| Variable | Notes |
|---|---|
LICENSE_KEY |
Your issued licence key |
LICENSE_PUBLIC_KEY |
The vendor’s Ed25519 public key, base64 SPKI PEM. Same for every customer |
LICENSE_PUBLIC_KEYS |
A {kid: key} map, for key rotation |
LICENSE_REVOKED_JTIS |
Comma-separated licence ids to refuse locally |
LICENSE_SIGNING_SECRET |
Legacy HS256 shared secret. Needs LICENSE_ALLOW_LEGACY_HS256 as well |
LICENSE_ALLOW_LEGACY_HS256 |
Set to 1 to allow legacy HS256 keys at all. Off by default |
Instances verify with the public key. The private half lives only on the vendor, so entitlement is checked offline and a network outage cannot disable a service desk.
Your deployment holds no licence signing key, and should not be given one. The older HS256 format is symmetric — the same secret that checks a licence can issue one — so both variables above must be set, by hand, before a legacy key will verify, and neither is set for you. They exist only to keep a school running mid-migration. If you are still on a v1 key, ask us for a reissued Ed25519 one rather than setting these.
Telemetry
Section titled “Telemetry”| Variable | Default | Notes |
|---|---|---|
TELEMETRY_ENDPOINT |
Normally comes from the licence. This is an override | |
DEPLOYMENT_EDITION |
onprem |
onprem or saas |
USAGE_REPORTER_DISABLED |
0 |
1 turns off reporting. Snapshots are still kept locally |
USAGE_REPORTER_TICK_MS |
3600000 |
Reporter loop interval |
Only counts are reported: technician accounts, managed devices, enabled modules, version. See licence and plan.
Modules
Section titled “Modules”| Variable | Notes |
|---|---|
DEMO_DISABLED_MODULES |
Comma-separated module keys to hide on this deployment |
DEMO_DISABLED_MODULES=module.cardChecker,module.inductionsA blunt instrument: a module hidden this way cannot be re-enabled from the interface.
Seeds the SMTP connector on first start, so a fresh install can send email before anybody opens the connectors screen.
| Variable | Notes |
|---|---|
SMTP_HOST |
|
SMTP_PORT |
587 for STARTTLS, 465 for implicit TLS |
SMTP_USER |
|
SMTP_PASSWORD |
|
SMTP_FROM |
For example ICT Service Desk <[email protected]> |
Beyond the initial setup, email is configured as a connector.
Local AI
Section titled “Local AI”| Variable | Default | Notes |
|---|---|---|
LOCAL_AI_URL |
The whole of “turn it on”. Set it and first run creates an enabled AI connector pointing at it, for every institution that has none | |
LOCAL_AI_MODEL |
qwen2.5:3b |
Any Ollama model on that host |
OLLAMA_URL |
Older name, still read | |
OLLAMA_MODEL |
Older name, still read |
The installer sets these for you. They are here for pointing the deployment at a model you host somewhere else.
Leave LOCAL_AI_URL blank and the desk runs without a model: the assistant
falls back to its command engine, triage runs on rules, and the summary and
draft features say there is no model connected.
Do not go below 3B. The 1B-class models fail the closed-list checks that triage answers are held to, often enough to be worse than no model at all.
See the bundled model, and three ways to get a model for the alternatives.
Embedding
Section titled “Embedding”| Variable | Default | Notes |
|---|---|---|
EMBED_ORIGINS |
empty | Parent origins allowed to frame the app, comma-separated. Empty refuses all framing |
Read per request, so one image serves every deployment. Leave it unset unless you are embedding the product in another page on purpose.
| Variable | Default | Notes |
|---|---|---|
HSTS_MAX_AGE |
31536000 |
Seconds a browser should refuse plain HTTP for this hostname. 0 sends no Strict-Transport-Security header at all |
Both the API and the web app read this and send the header themselves, so it applies whatever sits in front of them — a reverse proxy, a tunnel, or nothing. Browsers only honour it over HTTPS, so the default costs a plain-HTTP install nothing and there is no need to change it there.
Set it to 0 if this hostname must stay reachable over plain HTTP by design, or
if your own proxy already sends the header and you want a single authority for
the value.
Note that shortening or removing it is not retroactive. A browser that has
already seen a year holds it for a year from the last time it saw it; only an
HTTPS response carrying a smaller max-age updates that, and only when that
browser next visits.
Connector agents
Section titled “Connector agents”| Variable | Default | Notes |
|---|---|---|
AGENT_JOB_TTL_MS |
120000 |
How long a queued job waits for an agent |
AGENT_LONG_POLL_MS |
25000 |
How long an agent holds the connection with nothing to do |
AGENT_TOKEN_TTL_DAYS |
365 |
How long a newly issued or rotated agent token is accepted for |
Defaults are sensible. See connector agents.
Two more are read by the agent itself, on the machine it runs on, rather than by
the platform. AGENT_CONFIG and AGENT_SECRETS say where its agent.env and
its secrets file are. AGENT_ALLOW_INSECURE_URL=1 lets it report to a plain
http:// address, which it otherwise refuses — only set it where TLS is
terminated somewhere you trust on the same host, because the agent’s token is
sent on every heartbeat.
Automatic updates
Section titled “Automatic updates”On-premises bundle only.
| Variable | Default | Notes |
|---|---|---|
AUTO_UPDATE |
on | off to never update on its own |
AUTO_UPDATE_HOUR |
1 |
Local hour, 0 to 23 |
AUTO_UPDATE_RESTART |
on | off to stage the update but apply at your next restart |
Uses the machine’s own local time. A server set to UTC in a school that is not will update in the middle of a school day.
Status panel
Section titled “Status panel”The local panel described in the status panel. On-premises bundle only.
| Variable | Default | Notes |
|---|---|---|
PANEL_HOST |
127.0.0.1 |
The interface the panel binds to. Loopback by default |
PANEL_PORT |
9090 |
Change this if something else on the machine already has 9090 |
PANEL_TOKEN |
generated | Protected installation-owner key, required on every interface including loopback. Used by the launcher to authenticate the panel; never put it in a browser URL |
PANEL_ALLOWED_HOSTS |
(none) | Extra hostnames the panel will answer to, comma separated. Needed only if you reach a non-loopback panel by name rather than by address |
Open the panel with plugboard panel under the installation owner. The CLI
verifies the listener and creates a one-use opening link valid for 30 seconds.
The browser then needs both its session cookie and an exact-origin storage proof;
the owner key is never sent to the browser. Sessions expire after eight hours
or a panel restart. An empty owner key refuses authentication on loopback too.
See status panel access for Linux service-owner
and SSH-forwarding instructions.
Either way, the panel answers only to its own name. A request whose Host is
not loopback, your PANEL_HOST, or a name you listed in PANEL_ALLOWED_HOSTS
gets a 403 and nothing else — not the page, not the state, not a cookie. That is
what closes DNS rebinding, where a hostile page points its own hostname at
127.0.0.1 so the browser will treat the panel as same-origin with it and send
that custom header for free. Set PANEL_ALLOWED_HOSTS only for the names you
actually use; it is read at start, so a change needs a restart.
It is still the wrong thing to expose. It configures the deployment and can restart it.
Backups
Section titled “Backups”| Variable | Default | Notes |
|---|---|---|
BACKUP_BASE_DIR |
the working directory | The root every backup path is contained under. The target directory set in Admin, Backups is resolved against this, and a path that lands outside it - absolute, or climbing out with .. - is refused when it is saved and again when a run starts |
See backups and restore.
Diagnostics
Section titled “Diagnostics”| Variable | Notes |
|---|---|
SENTRY_DSN |
Error reporting to your own self-hosted Sentry or GlitchTip. No default destination |
METRICS_TOKEN |
Unset, /api/metrics is open. Set it, and a scrape must send Authorization: Bearer <token> |
BUILD_INFO_TOKEN |
Lets a holder read the git commit and the build timestamp from /api/health/version, which otherwise answers with the version and channel only. Unset - the default - nobody gets them. A missing or wrong token is answered with the short form rather than an error, so this can never stop a health check working |
See keeping an eye on it.
Test and development
Section titled “Test and development”| Variable | Notes |
|---|---|
PLUGBOARD_DEMO_DATA |
1 seeds fictional sample data on a brand new database only |
WEBHOOKS_DISABLED |
1 disables outbound webhook delivery |
REPORTS_DISABLED |
1 disables the scheduled report loop |
BACKUPS_DISABLED |
1 stops the scheduled backup loop from starting. Manual backups still work. Not for production |