Running in production
Production runs three deployables plus two stateful services, all deployed through Coolify from the main branch.
| Service | What it is | Deploy trigger |
|---|---|---|
backend (API) | Gin API; applies shared migrations on start | push to main |
scheduler | Hourly lifecycle, reminders, alerts, retention jobs | same image |
worker | Asynchronous work | same image |
frontend | Vue app, built as static files | same push |
web | Marketing site, built as static files | same push |
| PostgreSQL 17 | Shared schema + one schema per facility | managed |
| Valkey | Cache, sessions, rate limits, idempotency | managed |
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
| Variable | Notes |
|---|---|
APP_ENV | production enables strict behaviour (see metrics below) |
PORT | API listen port |
OVAHOL_DB_* | Host, port, database, user, password, SSL mode |
JWT_SECRET | Must be a real secret; rotation invalidates live tokens |
CORS_ALLOWED_ORIGINS | Comma-separated app origins |
FRONTEND_BASE_URL | Used 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:
| Variable | Purpose |
|---|---|
VITE_API_URL | API base URL, e.g. https://api.ovahol.com/api/v1 |
APP_VERSION | Version 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=productionmeans closed: every request gets403.
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:
| Job | Effect |
|---|---|
| Subscription lifecycle | Grace → expired/overdue transitions, plan revert, facility access re-derivation, notifications |
| PM reminders | Notifications for preventive maintenance coming due |
| Threshold alerts | Usage/stock threshold notifications |
| Payment webhook retention | Purges 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.
# 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 remainsBoth 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
make backup # pg_dump to backups/
make restore BACKUP_FILE=backups/<file>.sqlBoth 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
- Merge the release into
main. - Coolify rebuilds and redeploys; the API applies shared migrations on start.
- Watch
/healthuntilstatusisupanddistributed_state_statusisup. - Confirm the scheduler logged its first run.
- 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
| Symptom | First thing to check |
|---|---|
Every /tenant call returns 402 | Organization status is OVERDUE, CANCELLED or PENDING_ACTIVATION |
Writes return 423 | Facility is outside the plan's allowance — see facility access lock |
/metrics returns 403 | METRICS_ALLOWED_CIDRS empty in production, or the scraper is behind a proxy |
503 from /health | Database or Valkey unreachable |
| New facility 500s on first request | Tenant schema provisioning failed — check the tenant migrator log line |
| Payment succeeded, plan unchanged | Webhook signature/URL, then Reconciliation in the admin billing page |