Environment Variables
Skol Arena has no central env-validation file — every variable below is read directly where it’s needed. This page is the canonical reference.
The values in effect on a running instance can be checked from Admin → Technical maintenance — secrets only report whether they are set. See Technical maintenance.
Database & migrations
| Variable | Purpose | Required | Default |
|---|---|---|---|
DATABASE_URL |
PostgreSQL connection string. Used by the app’s connection pool, Better Auth, the Drizzle migrator, and the background job queue. | Yes | — |
DATABASE_POOL_MAX |
Maximum connections in the pool. | No | 10 |
MIGRATIONS_FOLDER |
Path to the Drizzle migrations directory. | No | ./drizzle (preset in the Docker image) |
Migrations run automatically, synchronously, at server startup — before any traffic is accepted. There is no manual migrate command. If a migration fails, the process exits immediately (useful for container orchestrators to detect a bad deploy).
Server & runtime
| Variable | Purpose | Required | Default |
|---|---|---|---|
NODE_ENV |
Standard Node environment flag; affects CORS origin behavior. | No | dev behavior if unset |
PORT |
Port the server listens on. | No | 3000 |
FRONTEND_URL |
Allowed CORS origin and Better Auth trusted origin for the frontend. | No | http://localhost:5173 |
FRONTEND_BUILD_PATH |
Single-container mode: serves the built frontend (static assets + SPA fallback) from this path. Set to an empty value to serve the API only. | No | unset outside Docker (preset in the image) |
LOG_LEVEL |
Pino log level. | No | info |
LOG_FORMAT |
Log output format: pretty (human-readable), json or logfmt. Pick json or logfmt if a log collector parses your logs. |
No | pretty |
APP_TIMEZONE |
Timezone used for time-of-day/day-of-week logic in the contextual rules engine. | No | Europe/Paris |
INITIAL_ADMIN_EMAIL |
Email address for the auto-created super-admin account. Its password is regenerated and logged on every startup until that account logs in once. | No | admin@skol-arena.local |
API_DOCS_ENABLED |
Serves the OpenAPI documents and the Scalar reference at /api/docs. Set to "false" to switch them off. See API versioning. |
No | on in development, off when NODE_ENV=production |
RANKED_MATCH_MAX_AGE_HOURS |
How long after being played a ranked match can still be declared, in hours. 0 turns the check off — a test/backfill setting, not something to run in production. Invalid values fall back to the default. |
No | 48 |
RATE_LIMIT_ENABLED |
Arms request rate limiting — the sign-in/sign-up/password-reset limits and the public business endpoints. Set to "true" to exercise it outside production, "false" if you rate limit at your reverse proxy instead. |
No | on when NODE_ENV=production, off otherwise |
TRUSTED_PROXY_HOPS |
How many reverse proxies sit between your users and the app, which is what lets rate limiting tell callers apart. 0 means the app is reached directly and x-forwarded-for is ignored, since any caller can send one; 1 for a single reverse proxy, 2 for a CDN in front of it. Too high is safe — callers share a window; too low lets the limits be evaded. |
No | 0 |
Authentication (Better Auth)
| Variable | Purpose | Required | Default |
|---|---|---|---|
BETTER_AUTH_SECRET |
Signing secret for sessions and cookies. | Yes | — |
BETTER_AUTH_URL |
Better Auth’s own base URL; also added as a trusted origin. | No | falls back to BASE_URL, then http://localhost:3000 |
BASE_URL |
Secondary fallback for the auth base URL. | No | http://localhost:3000 |
SHOW_EMAIL_PASSWORD_FORM |
Shows/hides the email+password login form on the frontend (set to the literal string "false" to hide it). Frontend only — see the note below. |
No | shown |
ENABLE_EMAIL_PASSWORD |
Deprecated name for SHOW_EMAIL_PASSWORD_FORM. Still read when the new name is unset, and warns at startup. |
No | — |
KEYCLOAK_CLIENT_ID |
Keycloak OAuth client ID. | No — but all three Keycloak variables are needed together to enable SSO | — |
KEYCLOAK_CLIENT_SECRET |
Keycloak OAuth client secret. | No (see above) | — |
KEYCLOAK_ISSUER |
Keycloak issuer URL (also used to derive the realm name shown on the login button). | No (see above) | — |
KEYCLOAK_PKCE |
Enables PKCE for the Keycloak OAuth flow (literal "true" to enable). |
No | disabled |
KEYCLOAK_LOGIN_LABEL |
Custom label for the Keycloak login button. | No | — |
At least one authentication method must be active: the app refuses to start if
SHOW_EMAIL_PASSWORD_FORM is disabled and Keycloak isn’t fully configured.
SHOW_EMAIL_PASSWORD_FORM=falsehides the form, it does not close the endpoint.POST /api/auth/sign-in/emailkeeps accepting valid credentials. This is deliberate: it leaves administrators a way in when Keycloak is unreachable, which is exactly when you need one. If your threat model requires password sign-in to be genuinely unavailable, block the/api/auth/sign-in/emailpath at your reverse proxy — the variable will not do it for you. The old name,ENABLE_EMAIL_PASSWORD, claimed otherwise, which is why it was renamed.
Email (SMTP)
Used for transactional email such as password resets.
| Variable | Purpose | Required | Default |
|---|---|---|---|
SMTP_HOST |
SMTP server host. | Yes, for email to work | — |
SMTP_PORT |
SMTP server port. | No | 587 |
SMTP_SECURE |
Use TLS (typically "true" for port 465). |
No | false |
SMTP_USER |
SMTP auth username. Omit for a server without AUTH. | No | — |
SMTP_PASSWORD |
SMTP auth password. | No | — |
SMTP_FROM |
“From” email address. | Yes, for email to work | — |
SMTP_FROM_NAME |
Display name in the “From” header. | No | — |
If these are left unset, the app still starts fine — email sending will simply fail when triggered (e.g. a password-reset request). The server checks the SMTP connection at startup and logs a warning when it is unreachable.
With a local mail catcher (maildev, mailpit…), set SMTP_HOST=127.0.0.1 rather
than localhost: the latter resolves to ::1 first while those tools bind IPv4
only, which yields connect ECONNREFUSED ::1:<port>.
Web push notifications (VAPID)
| Variable | Purpose | Required | Default |
|---|---|---|---|
VAPID_PUBLIC_KEY |
VAPID public key for browser push notifications. | No — push is silently disabled unless both keys are set | — |
VAPID_PRIVATE_KEY |
VAPID private key. | No (see above) | — |
Avatars
| Variable | Purpose | Required | Default |
|---|---|---|---|
AVATAR_STORAGE |
Where player avatars are kept: postgres or filesystem. An unknown value logs a warning and falls back to the default. |
No | postgres |
AVATAR_STORAGE_DIR |
Directory used by the filesystem storage. Must be writable by the app; the server refuses to start otherwise. |
No | ./data/avatars (/data/avatars in the image) |
AVATAR_MAX_UPLOAD_BYTES |
Largest upload accepted, in bytes. Larger requests are cut off before being read. | No | 5242880 (5 MB) |
Uploaded pictures are never stored as sent: the server decodes them, checks they really are JPEG, PNG or WebP images, and keeps only three re-encoded WebP variants (64, 128 and 256 px), a few kilobytes each. Every new avatar gets a new random URL, so browsers cache each image for 30 days; past that they revalidate it, and an avatar that has since been replaced or removed drops out of their cache.
postgres needs nothing extra and is covered by your pg_dump backups, but
every avatar lives in the database: with many players it grows the database and its
backups. filesystem keeps the database lean, at the cost of a volume to mount
and to back up separately — see Deployment.
Switching from one to the other does not move existing avatars by itself: until they are migrated, players who had one fall back to their initials. The migration is one click in Admin → Technical maintenance — see Deployment.
Real-time (WebSocket)
No dedicated variables. WebSocket connections are mounted on the same server and port as the HTTP API, and authenticated using the existing Better Auth session — nothing extra to configure.