Every variable, its default, and who consumes it. The canonical template is
.env.example (dev stack only); the production shared
SaaS stack uses deploy/.env.prod.example
(see deployment/vps-guide.md); legacy
per-client stacks get a generated .env.<slug> from
scripts/onboard-client.sh. Values flow:
.env → compose file service environment: → Spring properties in
application.yml.
Database
| Variable | Default | Consumed by |
|---|
POSTGRES_DB | motorph | Postgres container (creates DB) + backend datasource URL |
POSTGRES_USER | motorph | Postgres container + Flyway (migrations run as the owner and deliberately bypass RLS) |
POSTGRES_PASSWORD | motorph (dev) / openssl rand -hex 32 (production) | Postgres container + Flyway |
APP_DB_USER | falls back to POSTGRES_USER (application.yml); app_runtime in dev + prod compose | The role the app connects as. Row-level security only binds for a non-owner — leave this at the owner fallback and every tenant-isolation policy is a silent no-op (ADR-0013 "Activation"; the boot log's "Row-level security is in force" line is the check). |
APP_DB_PASSWORD | falls back to POSTGRES_PASSWORD | Password for APP_DB_USER. One-shot: migration V101 bakes it into the app_runtime role on first run; rotating later is a manual ALTER ROLE app_runtime LOGIN PASSWORD (vps-guide §5). |
Ports (host-side mappings, dev compose only)
| Variable | Default | Maps to |
|---|
FRONTEND_PORT | 5173 | frontend nginx :80 |
BACKEND_PORT | 8081 | backend :8080 |
POSTGRES_PORT | 5434 | Postgres :5432 |
PGADMIN_PORT | 5050 | pgAdmin :80 — bound to 127.0.0.1 only |
MAILPIT_UI_PORT | 8025 | Mailpit web UI :8025 — bound to 127.0.0.1 only. Its SMTP :1025 is never published. |
Change FRONTEND_PORT and you must add the new origin to
CORS_ALLOWED_ORIGINS. Production client stacks publish no host ports.
Auth / JWT
| Variable | Default | Notes |
|---|
JWT_SECRET | change-me-in-production | Signing key (HMAC256). The backend refuses to boot with the placeholder, a blank value, or <32 chars (JwtProperties.validateSecret). Generate: openssl rand -hex 64. |
JWT_ACCESS_TOKEN_EXPIRATION_MINUTE | 15 | Access-token TTL |
JWT_REFRESH_TOKEN_EXPIRATION_DAY | 14 | Refresh-token TTL (rotating, DB-backed) |
JWT_PORTAL_EXPIRATION_MINUTE | 1440 | Customer-portal token TTL (24 h, no refresh) |
CORS_ALLOWED_ORIGINS | http://localhost:5173,http://127.0.0.1:5173 | Comma-separated exact origins. Only matters for host-dev / split-origin setups — the dockerized nginx proxy is same-origin. |
Feature gates
| Variable | Default | Notes |
|---|
SWAGGER_ENABLED | true (dev) / false (client template) | Gates springdoc UI + /v3/api-docs |
ALTCHA_ENABLED | false (dev) / true (demo + client template) | Proof-of-work CAPTCHA on login/register. Must stay false on stacks used by the e2e suite. |
ALTCHA_HMAC_KEY | empty | Required when ALTCHA is enabled. openssl rand -hex 32. |
ALTCHA_MAX_NUMBER | 100000 (commented) | PoW difficulty ceiling |
ALTCHA_EXPIRY_SECONDS | 1800 (commented) | Challenge validity |
| Variable | Default | Effect |
|---|
PLATFORM_ADMIN_USERNAME | platform-admin | Username of the tenant-less operator account that provisions tenants (/admin/tenants). |
PLATFORM_ADMIN_PASSWORD | empty | Blank = no account is created (WARN logged; nobody can provision tenants). Set = account created once, forced to change its password at first login; from then on the variable is ignored forever. No password-recovery flow exists anywhere. Seeder: PlatformAdminSeeder. |
PLATFORM_ADMIN_EMAIL | platform-admin@localhost | Email on the seeded operator account. |
Tenants
| Variable | Default | Effect |
|---|
TENANT_TRIAL_DAYS | 14 | How long a self-serve workspace is served before it must be on a plan. Affects later signups only — existing trials carry the window they were created with, in tenant.trial_ends_at. See backend/self-serve-signup.md. |
Email (SMTP)
Full guide: backend/email.md.
| Variable | Default | Effect |
|---|
MAIL_ENABLED | false (application.yml) / true (dev compose) | Master switch. Enabled with a blank MAIL_HOST refuses to boot rather than dropping every message silently — Boot builds a sender for an empty host, and sends are swallowed by design. |
MAIL_HOST | localhost (application.yml) / motorph_payroll_mailpit (dev compose) | SMTP relay. Inside compose this is the service name; a container's localhost is itself. |
MAIL_PORT | 1025 | SMTP port (587 for a real relay) |
MAIL_SMTP_AUTH | false | true for a real relay |
MAIL_SMTP_STARTTLS | false | true for a real relay |
MAIL_USERNAME / MAIL_PASSWORD | empty | Relay credentials |
MAIL_FROM | [email protected] | Envelope sender. Must be a domain whose SPF/DKIM you control. |
MAIL_FROM_NAME | MotorPH Enterprise | Display name |
MAIL_BASE_URL | http://localhost:5173 | Origin that links inside emails point at. Per-deployment. |
pgAdmin (development only)
| Variable | Default | Effect |
|---|
PGADMIN_EMAIL | [email protected] | Never typed (desktop mode), but the image will not start without it — and it validates the address, rejecting reserved TLDs unless they are listed in PGADMIN_CONFIG_ALLOW_SPECIAL_EMAIL_DOMAINS. |
PGADMIN_PASSWORD | admin1234 | Likewise unused in desktop mode |
Billing (Polar.sh)
| Variable | Default | Notes |
|---|
BILLING_PROVIDER | stub (dev; unset in client template) | stub or polar — selects the PaymentProvider implementation |
BILLING_SUCCESS_URL_BASE | http://localhost:5173 | Where checkout returns to |
POLAR_SERVER | sandbox | sandbox or production |
POLAR_ACCESS_TOKEN | empty | Polar API token |
POLAR_WEBHOOK_SECRET | empty | Webhook signature verification |
POLAR_PRODUCT_ID_STARTER / POLAR_PRODUCT_ID_GROWTH | empty | Plan product IDs |
BILLING_CHECKOUT_LINK_STARTER / BILLING_CHECKOUT_LINK_GROWTH | empty | Self-serve checkout links surfaced by the public API |
Details: backend/billing.md.
Live chat (Chatwoot Cloud)
| Variable | Default | Notes |
|---|
CHATWOOT_BASE_URL | https://app.chatwoot.com | Chatwoot origin the widget SDK loads from. If this ever changes, the CSP in frontend/nginx.conf must change with it — the origin is hardcoded there. |
CHATWOOT_WEBSITE_TOKEN | empty | Website-inbox token. Blank = /api/public/chatwoot answers 204 and no widget loads. Public by design — it ships in the source of every page that renders the widget. |
Details: integrations/chatwoot.md.
Set by compose files, not by you
| Variable | Where | Purpose |
|---|
POSTGRES_DB_SERVER_ADDRESS / POSTGRES_DB_SERVER_PORT | every compose backend service | Datasource host/port inside the Docker network (motorph_payroll_db:5432 etc.) |
SERVER_PORT | application.yml default 8080 | Backend listen port inside the container |
SPRING_FLYWAY_LOCATIONS | docker-compose.demo.yml | Adds the mounted /demo-seed dir so the rich demo seed (V20–V24) applies |
SPRING_FLYWAY_IGNORE_MIGRATION_PATTERNS | local docker-compose.override.yml shims; deploy/docker-compose.rollback.yml on the production rollback path | Flyway tolerance for migrations missing from the running classpath — cross-branch dev shims, and rolled-back (older) jars booting against a newer schema. See troubleshooting.md and vps-guide §10. |
GF_SECURITY_ADMIN_USER / GF_SECURITY_ADMIN_PASSWORD | docker-compose.monitoring.yml | Grafana admin login (admin/admin — change it) |
Frontend
| Variable | Default | Notes |
|---|
VITE_API_URL | empty | Build-time arg baked into the bundle (frontend/Dockerfile). Empty = relative URLs through the nginx proxy (correct for all Docker deployments). Set it only for the Vite-dev-server loop (frontend/.env.local, e.g. http://localhost:8081). Changing it requires an image rebuild. |
VPS environments only (/srv/motorph/.env and .env.stage)
Production (.env, consumed by
deploy/docker-compose.prod.yml)
and staging (.env.stage, consumed by
deploy/docker-compose.stage.yml)
each carry their own copies of the secrets above. The source of
truth is the PROD_ENV_FILE / STAGE_ENV_FILE GitHub Secrets — every
pipeline run rewrites the VPS files from them, so a hand-edit on the VPS
lasts exactly until the next deploy. Templates with full comments:
deploy/.env.prod.example,
deploy/.env.stage.example; keep the
filled-in masters in a password manager (Secrets are write-only).
| Variable | Effect |
|---|
DOMAIN | The public apex hostname, in both files. Production derives CORS_ALLOWED_ORIGINS/MAIL_BASE_URL/BILLING_SUCCESS_URL_BASE as https://${DOMAIN}; staging derives them as https://stage.${DOMAIN}. |
ACME_EMAIL | Let's Encrypt account email (expiry/problem notices) — .env only; read by the edge (deploy/docker-compose.edge.yml). |
IMAGE_TAG | Which ghcr.io/jomariabejo/motorph-payroll-* tag that environment runs. Managed by deploy/deploy.sh on every deploy/promote/rollback — don't edit by hand. |
Legacy per-client stacks (.env.<slug>)
Generated by onboard-client.sh and consumed
by docker-compose.client.template.yml:
CLIENT_SLUG, BASE_DOMAIN (default motorphenterprise.com), REPO_PATH
(absolute repo checkout path, auto-detected by the script — build contexts and
the nginx-conf mount derive from it), the generated secrets
(POSTGRES_PASSWORD, JWT_SECRET, ALTCHA_HMAC_KEY), and the billing
variables above. Per-client files are gitignored; treat them as
secrets and back them up securely — losing JWT_SECRET invalidates sessions,
losing POSTGRES_PASSWORD locks you out of the DB container's auth.