Skip to main content

Environment variables

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

VariableDefaultConsumed by
POSTGRES_DBmotorphPostgres container (creates DB) + backend datasource URL
POSTGRES_USERmotorphPostgres container + Flyway (migrations run as the owner and deliberately bypass RLS)
POSTGRES_PASSWORDmotorph (dev) / openssl rand -hex 32 (production)Postgres container + Flyway
APP_DB_USERfalls back to POSTGRES_USER (application.yml); app_runtime in dev + prod composeThe 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_PASSWORDfalls back to POSTGRES_PASSWORDPassword 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)

VariableDefaultMaps to
FRONTEND_PORT5173frontend nginx :80
BACKEND_PORT8081backend :8080
POSTGRES_PORT5434Postgres :5432
PGADMIN_PORT5050pgAdmin :80 — bound to 127.0.0.1 only
MAILPIT_UI_PORT8025Mailpit 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

VariableDefaultNotes
JWT_SECRETchange-me-in-productionSigning 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_MINUTE15Access-token TTL
JWT_REFRESH_TOKEN_EXPIRATION_DAY14Refresh-token TTL (rotating, DB-backed)
JWT_PORTAL_EXPIRATION_MINUTE1440Customer-portal token TTL (24 h, no refresh)
CORS_ALLOWED_ORIGINShttp://localhost:5173,http://127.0.0.1:5173Comma-separated exact origins. Only matters for host-dev / split-origin setups — the dockerized nginx proxy is same-origin.

Feature gates

VariableDefaultNotes
SWAGGER_ENABLEDtrue (dev) / false (client template)Gates springdoc UI + /v3/api-docs
ALTCHA_ENABLEDfalse (dev) / true (demo + client template)Proof-of-work CAPTCHA on login/register. Must stay false on stacks used by the e2e suite.
ALTCHA_HMAC_KEYemptyRequired when ALTCHA is enabled. openssl rand -hex 32.
ALTCHA_MAX_NUMBER100000 (commented)PoW difficulty ceiling
ALTCHA_EXPIRY_SECONDS1800 (commented)Challenge validity

Platform operator

VariableDefaultEffect
PLATFORM_ADMIN_USERNAMEplatform-adminUsername of the tenant-less operator account that provisions tenants (/admin/tenants).
PLATFORM_ADMIN_PASSWORDemptyBlank = 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_EMAILplatform-admin@localhostEmail on the seeded operator account.

Tenants

VariableDefaultEffect
TENANT_TRIAL_DAYS14How 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.

VariableDefaultEffect
MAIL_ENABLEDfalse (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_HOSTlocalhost (application.yml) / motorph_payroll_mailpit (dev compose)SMTP relay. Inside compose this is the service name; a container's localhost is itself.
MAIL_PORT1025SMTP port (587 for a real relay)
MAIL_SMTP_AUTHfalsetrue for a real relay
MAIL_SMTP_STARTTLSfalsetrue for a real relay
MAIL_USERNAME / MAIL_PASSWORDemptyRelay credentials
MAIL_FROM[email protected]Envelope sender. Must be a domain whose SPF/DKIM you control.
MAIL_FROM_NAMEMotorPH EnterpriseDisplay name
MAIL_BASE_URLhttp://localhost:5173Origin that links inside emails point at. Per-deployment.

pgAdmin (development only)

VariableDefaultEffect
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_PASSWORDadmin1234Likewise unused in desktop mode

Billing (Polar.sh)

VariableDefaultNotes
BILLING_PROVIDERstub (dev; unset in client template)stub or polar — selects the PaymentProvider implementation
BILLING_SUCCESS_URL_BASEhttp://localhost:5173Where checkout returns to
POLAR_SERVERsandboxsandbox or production
POLAR_ACCESS_TOKENemptyPolar API token
POLAR_WEBHOOK_SECRETemptyWebhook signature verification
POLAR_PRODUCT_ID_STARTER / POLAR_PRODUCT_ID_GROWTHemptyPlan product IDs
BILLING_CHECKOUT_LINK_STARTER / BILLING_CHECKOUT_LINK_GROWTHemptySelf-serve checkout links surfaced by the public API

Details: backend/billing.md.

Live chat (Chatwoot Cloud)

VariableDefaultNotes
CHATWOOT_BASE_URLhttps://app.chatwoot.comChatwoot 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_TOKENemptyWebsite-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

VariableWherePurpose
POSTGRES_DB_SERVER_ADDRESS / POSTGRES_DB_SERVER_PORTevery compose backend serviceDatasource host/port inside the Docker network (motorph_payroll_db:5432 etc.)
SERVER_PORTapplication.yml default 8080Backend listen port inside the container
SPRING_FLYWAY_LOCATIONSdocker-compose.demo.ymlAdds the mounted /demo-seed dir so the rich demo seed (V20–V24) applies
SPRING_FLYWAY_IGNORE_MIGRATION_PATTERNSlocal docker-compose.override.yml shims; deploy/docker-compose.rollback.yml on the production rollback pathFlyway 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_PASSWORDdocker-compose.monitoring.ymlGrafana admin login (admin/admin — change it)

Frontend

VariableDefaultNotes
VITE_API_URLemptyBuild-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).

VariableEffect
DOMAINThe 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_EMAILLet's Encrypt account email (expiry/problem notices) — .env only; read by the edge (deploy/docker-compose.edge.yml).
IMAGE_TAGWhich 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.