Environment variables reference
This page lists every environment variable read by the Polaris Express
stack. Values come from web/.env.example; if you see a drift between
this page and that file, the file wins — open an issue and we’ll
re-sync.
Variables are grouped by subsystem. Within each group, “Required” means the service will refuse to boot or will misbehave without it. “Optional” means the default is sane and you can ignore the variable until you need to tune it.
How env vars are loaded
Section titled “How env vars are loaded”The web service (web/) reads .env at boot via Deno’s standard env
loader. Docker Compose injects the same file into the steve and
postgres containers where needed. The Cloudflare Email Worker and
APNs paths have their own secrets stored separately (see the relevant
phases).
Database
Section titled “Database”| Name | Default | Required | Notes |
|---|---|---|---|
DATABASE_URL | postgresql://user:password@localhost:5432/ocpp_billing | Yes | Full connection string used by the web service. |
POSTGRES_DB | ocpp_billing | Yes | Database name created by the Postgres container. |
POSTGRES_USER | ocpp_user | Yes | Superuser for the database. |
POSTGRES_PASSWORD | ocpp_password | Yes | Change before any non-local deploy. |
SteVe (OCPP)
Section titled “SteVe (OCPP)”| Name | Default | Required | Notes |
|---|---|---|---|
STEVE_BASE_URL | http://localhost:8080/steve | Yes | Base URL for dashboard links. API calls append /api. |
STEVE_API_USERNAME | admin | Yes | Must match SteVe’s auth.user. |
STEVE_API_KEY | (empty) | Yes | Must match SteVe’s webapi.value. HTTP Basic auth since SteVe 3.8. |
DOCKER_SOCKET_PATH | /var/run/docker.sock | Yes | Used to stream SteVe container logs for tag detection. |
STEVE_CONTAINER_NAME | steve | Yes | Container name to stream from. |
See the SteVe phase for how these values are generated on the SteVe side.
SteVe → ExpresSync webhooks
Section titled “SteVe → ExpresSync webhooks”| Name | Default | Required | Notes |
|---|---|---|---|
STEVE_PREAUTH_HMAC_KEY | (empty) | Yes (Wave 1+) | HMAC shared secret for pre-authorize webhook. Generate with openssl rand -hex 32. |
STEVE_METERVALUE_HMAC_KEY | (empty) | Yes (Wave 1+) | HMAC shared secret for MeterValues webhook. |
Lago (billing)
Section titled “Lago (billing)”| Name | Default | Required | Notes |
|---|---|---|---|
LAGO_API_URL | https://api.getlago.com | Yes | API base. The client appends /api/v1. |
LAGO_API_KEY | (empty) | Yes | Lago API key with org scope. |
LAGO_DASHBOARD_URL | https://app.getlago.com | Yes | Used for “Open in Lago” links from the admin console. |
LAGO_METRIC_CODE | ev_charging_kwh | Yes | Code of the Lago billable metric for kWh consumption. Must exist in Lago before first sync. |
Application
Section titled “Application”| Name | Default | Required | Notes |
|---|---|---|---|
PORT | 8000 | No | Port the web service binds. Change in tandem with your reverse proxy. |
DENO_ENV | development | No | Set to production for production deploys. Enables stricter error handling and disables verbose tracebacks. |
DEBUG_LEVEL | ERROR | No | One of ERROR, WARN, INFO, DEBUG. Use INFO when debugging a live incident. |
Sync worker (Adaptive Cadence)
Section titled “Sync worker (Adaptive Cadence)”The sync worker runs on adaptive cadence by default: Active charge boxes poll every 15 minutes, Idle every hour, Dormant once a week.
| Name | Default | Required | Notes |
|---|---|---|---|
SYNC_CRON_SCHEDULE | (unset) | No | Escape hatch. If set to a cron pattern, all tiers use that pattern and adaptive behavior is disabled. Leave unset in normal operation. |
SYNC_ON_STARTUP | false | No | If true, runs a sync tick immediately on worker boot. Useful for testing; leave false in production to avoid thundering-herd on restart. |
SYNC_LOOKBACK_MINUTES | 10080 | No | How far back to scan SteVe transactions each tick. Default is 7 days. Reduce to 1440 (24h) only if you’re certain the worker cannot lag. |
SYNC_DORMANT_THRESHOLD_DAYS | 30 | No | Days of no activity before a charge box is demoted from Idle to Dormant. |
SYNC_IDLE_HYSTERESIS_TICKS | 2 | No | Consecutive idle evaluations required to demote a tier. Higher = more conservative. |
BetterAuth (sessions)
Section titled “BetterAuth (sessions)”| Name | Default | Required | Notes |
|---|---|---|---|
AUTH_SECRET | (empty) | Yes | Minimum 32 characters. Generate with openssl rand -hex 32. Rotating this invalidates every active session. |
AUTH_URL | http://localhost:8000 | Yes | Canonical customer-portal URL. Used as session issuer and for magic-link composition. |
Customer portal & email
Section titled “Customer portal & email”| Name | Default | Required | Notes |
|---|---|---|---|
CF_EMAIL_WORKER_URL | (empty) | No | URL of the Cloudflare Email Worker. Leave empty in dev to log emails to console. |
CF_EMAIL_WORKER_SECRET | (empty) | Yes (with Worker) | 32-byte shared secret. Required only when CF_EMAIL_WORKER_URL is set. |
EMAIL_FROM | (empty) | Yes | From: header for customer-facing email, e.g. Polaris Express <[email protected]>. |
EMAIL_FROM_ADMIN | (empty) | Yes | From: header for admin-facing email. Use a distinct mailbox so admins can filter. |
OPERATOR_CONTACT_EMAIL | (empty) | Yes | mailto: target shown to inactive customers and on hard-error pages. |
Session and link TTLs
Section titled “Session and link TTLs”All values in seconds.
| Name | Default | Required | Notes |
|---|---|---|---|
MAGIC_LINK_TTL_SECONDS | 900 | No | Standard 15-minute customer magic link. |
MAGIC_LINK_INVITE_TTL_SECONDS | 86400 | No | 24-hour first-time invite link. |
ADMIN_PASSWORD_RESET_TTL_SECONDS | 86400 | No | 24-hour admin password-reset link. |
CUSTOMER_SESSION_TTL_SECONDS | 28800 | No | 8-hour ceiling for customer sessions. |
Hostnames & cookie scope
Section titled “Hostnames & cookie scope”| Name | Default | Required | Notes |
|---|---|---|---|
ADMIN_BASE_URL | (empty) | Yes | Canonical admin host, e.g. https://manage.example.com. Used for admin redirects and password-reset emails. |
CUSTOMER_BASE_URL | (empty) | Yes | Canonical customer host, e.g. https://example.com. |
COOKIE_DOMAIN | (empty) | Yes | Cookie scope, e.g. .example.com (note leading dot). Must cover both admin and customer subdomains so sessions work on either surface. |
Pocket ID OIDC (admin auth)
Section titled “Pocket ID OIDC (admin auth)”When set, the admin login presents an OIDC button. When unset, login falls back to email/password. See the admin OIDC phase.
| Name | Default | Required | Notes |
|---|---|---|---|
ADMIN_OIDC_ISSUER | (unset) | No | OIDC issuer URL, e.g. https://pocket-id.example.com. Setting this enables the OIDC plugin. |
ADMIN_OIDC_CLIENT_ID | (unset) | Yes (with OIDC) | Client ID registered at the IdP. |
ADMIN_OIDC_CLIENT_SECRET | (unset) | Yes (with OIDC) | Client secret from the IdP. |
ADMIN_AUTH_SHOW_FALLBACK | false | No | If true, shows the email/password form alongside the OIDC button as a break-glass. |
APNs (push notifications, Wave 2)
Section titled “APNs (push notifications, Wave 2)”Token-based JWT (ES256) auth. Missing values do not block boot —
push sends return {ok:false,reason:"JwtSignFailed"} and the app
falls back to SSE.
| Name | Default | Required | Notes |
|---|---|---|---|
APNS_KEY_ID | (unset) | No | Key ID from App Store Connect → Users & Access → Keys. |
APNS_TEAM_ID | (unset) | No | 10-character Apple Team ID. |
APNS_KEY_BASE64 | (unset) | No | Base64-encoded .p8 key contents (including BEGIN/END lines). See below. |
APNS_TOPIC | (unset) | No | iOS bundle identifier, e.g. com.example.expresscharge.ios. |
Encode the key file as:
APNS_KEY_BASE64=$(base64 -i AuthKey_ABC123XYZ9.p8 | tr -d '\n')Verify your env file
Section titled “Verify your env file”After editing .env, dry-run the loader:
docker compose config | grep -E '^(\s+)(AUTH_SECRET|LAGO_API_KEY|STEVE_API_KEY)'You should see your secrets resolved (do not paste this output anywhere). If any value is empty, the corresponding service will fail its boot health check.
For a runtime check, hit the admin health endpoint after starting the stack:
curl -s http://localhost:8000/api/admin/health | jqThe response enumerates which subsystems found their config and which fell back to defaults.
If something goes wrong
Section titled “If something goes wrong”- Service crashes on boot with “missing AUTH_SECRET”: the value
must be at least 32 characters. Regenerate with
openssl rand -hex 32. - Customer can’t log in across subdomains: check
COOKIE_DOMAINhas a leading dot and matches bothADMIN_BASE_URLandCUSTOMER_BASE_URL. - Lago sync silently does nothing: confirm
LAGO_METRIC_CODEmatches a billable metric that already exists in Lago. The sync worker logs atINFOlevel when the metric is missing. - SteVe API calls return 401:
STEVE_API_USERNAMEandSTEVE_API_KEYmust exactly match SteVe’sauth.userandwebapi.value. Restart the SteVe container after changing them.