Skip to content

Running in production ​

Production runs three deployables plus two stateful services, all deployed through Coolify from the main branch.

ServiceWhat it isDeploy trigger
backend (API)Gin API; applies shared migrations on startpush to main
schedulerHourly lifecycle, reminders, alerts, retention jobssame image
workerAsynchronous worksame image
frontendVue app, built as static filessame push
webMarketing site, built as static filessame push
PostgreSQL 17Shared schema + one schema per facilitymanaged
ValkeyCache, sessions, rate limits, idempotencymanaged

The scheduler and worker use the same binary as the API and differ only in their command. They must be deployed together with the API — a scheduler running older code than the API can apply the wrong transitions.

Environment variables ​

The full list with comments is .env.example. The ones that matter in production:

Core ​

VariableNotes
APP_ENVproduction enables strict behaviour (see metrics below)
PORTAPI listen port
OVAHOL_DB_*Host, port, database, user, password, SSL mode
JWT_SECRETMust be a real secret; rotation invalidates live tokens
CORS_ALLOWED_ORIGINSComma-separated app origins
FRONTEND_BASE_URLUsed in emails and provider redirects

Auth and session ​

ACCESS_TOKEN_TTL, REFRESH_TOKEN_TTL, REFRESH_TOKEN_MAX_TTL, MAX_FAILED_ATTEMPTS, MAX_IP_FAILED_ATTEMPTS, LOGIN_FAILURE_WINDOW, LOGIN_LOCKOUT_TTL, AUTH_REFRESH_COOKIE_*. Login lockout state lives in Valkey, so the API and Valkey must agree on a shared, stable instance.

Distributed state ​

DISTRIBUTED_STATE_PROVIDER=valkey, VALKEY_ADDR, VALKEY_USERNAME, VALKEY_PASSWORD, VALKEY_PREFIX. Use a distinct prefix per environment so a shared Valkey never mixes sessions across deployments.

Payment ​

PAYMENT_ENABLED, PAYMENT_PROVIDER, PAYMENT_PROVIDERS, PAYSTACK_SECRET_KEY, PAYMENT_PLAN_SYNC_ENABLED, PAYMENT_WEBHOOK_RETENTION_DAYS. Leave plan sync disabled in production and link reviewed Paystack plans in the database. The full launch checklist is in Paystack production validation.

Email and storage ​

EMAIL_PROVIDER=resend, RESEND_API_KEY, MAIL_FROM, EMAIL_REPLY_TO (optional), EMAIL_SENDER_NAME, EMAIL_INVITE_BASE_URL; R2_* and the TENANT_DEVICE_RECORD_IMAGE_* settings for uploads.

MAIL_FROM should stay a no-reply@ address. Setting EMAIL_REPLY_TO routes replies to a monitored mailbox; leaving it unset keeps replies on MAIL_FROM.

Feature flags ​

FEATURE_RELEASE_PROFILE (full or beta), FEATURE_FLAGS_ENABLE, FEATURE_FLAGS_DISABLE. See roles and access.

Frontend build ​

The frontend is compiled, so its variables are build-time:

VariablePurpose
VITE_API_URLAPI base URL, e.g. https://api.ovahol.com/api/v1
APP_VERSIONVersion string baked into the build and /version.json
VITE_PWA_OFFLINE_*Offline capability toggles

If the build does not run inside a git checkout with tags, set APP_VERSION to the release version, otherwise the app reports a commit SHA or dev.

Health and observability ​

GET /health ​

Returns the database status, connection-pool stats, and (when configured) the distributed-state status. It returns 503 when the database or Valkey is unreachable, so point the platform health check at it.

GET /metrics ​

Prometheus exposition. Access is restricted by METRICS_ALLOWED_CIDRS, which accepts CIDRs or bare IPs.

  • Entries match the TCP peer address, not X-Forwarded-For. A scrape that goes through a proxy arrives from the proxy's address; allow the proxy, or scrape the process directly.
  • Empty in development means open.
  • Empty with APP_ENV=production means closed: every request gets 403.

There is usually no reason to expose this publicly. Keep the allowlist to the internal network range the scraper uses.

Logs ​

Structured logs include a request ID, path, status, and latency. The request ID is returned on the response, which makes it possible to jump from a user report to the exact log line.

Background jobs ​

The scheduler runs at startup and then hourly:

JobEffect
Subscription lifecycleGrace → expired/overdue transitions, plan revert, facility access re-derivation, notifications
PM remindersNotifications for preventive maintenance coming due
Threshold alertsUsage/stock threshold notifications
Payment webhook retentionPurges raw provider payloads past PAYMENT_WEBHOOK_RETENTION_DAYS

A missed hour is safe: each job is written to converge on the next run, not to require exactly-once execution.

Operator commands ​

Run these against the production database from a one-off container or shell.

bash
# Report organizations holding more facilities than their plan covers.
facility-access
facility-access --json
facility-access --apply --org <uuid>

# Report device status drift left by work orders created before v1.2.1.
status-drift
status-drift --json
status-drift --apply --confirm
status-drift --verify     # exit 1 when drift remains

Both are deliberately not migrations: they rewrite live asset state and must be reviewed before they are applied. See facility access lock and status drift repair.

Backups ​

bash
make backup                                   # pg_dump to backups/
make restore BACKUP_FILE=backups/<file>.sql

Both target the compose database container. In production, use the managed database's snapshot mechanism and verify restores on a schedule — a backup that has never been restored is a hypothesis.

Upgrade procedure ​

  1. Merge the release into main.
  2. Coolify rebuilds and redeploys; the API applies shared migrations on start.
  3. Watch /health until status is up and distributed_state_status is up.
  4. Confirm the scheduler logged its first run.
  5. Run any operator steps named in the release notes.

If a migration fails, the version is marked dirty: make migrate-status shows it, and make migrate-force VERSION=<n> (or force 0) recovers. Fix forward where possible; make migrate-down rolls back one step only if the .down.sql was written and tested.

Troubleshooting ​

SymptomFirst thing to check
Every /tenant call returns 402Organization status is OVERDUE, CANCELLED or PENDING_ACTIVATION
Writes return 423Facility is outside the plan's allowance — see facility access lock
/metrics returns 403METRICS_ALLOWED_CIDRS empty in production, or the scraper is behind a proxy
503 from /healthDatabase or Valkey unreachable
New facility 500s on first requestTenant schema provisioning failed — check the tenant migrator log line
Payment succeeded, plan unchangedWebhook signature/URL, then Reconciliation in the admin billing page

Ovahol developer documentation