Run Govula entirely on customer-controlled infrastructure: a single VM for small deployments, or a small cluster behind a load balancer for higher availability. The application is stateless — every request can be served by any backend instance — so horizontal scaling is purely a matter of provisioning.
Status: Partial. Single-VM is fully supported (docker-compose.on-prem.yml exists). Small-cluster is Aspirational — pattern documented; no Helm chart, no Terraform module, no Ansible playbook ships with the repo.
Architecture — single VM
┌──────────────────────────────────────────────────────────┐
│ Single VM (Linux, 4 vCPU / 8 GB RAM minimum) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Caddy / │ → │ govula- │ → │ postgres │ │
│ │ nginx │ │ api+fe │ │ 16 │ │
│ │ TLS term │ │ (docker) │ │ (docker) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ▲ │ │
│ :443 │ │ /var/lib/postgres│
│ │ ▼ │
└────────┼──────────────────────────────────────────────────┘
│
▼
Public DNS → VM
Architecture — small cluster
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Load │→ │ api-1, 2, 3 │→ │ Postgres │
│ balancer │ │ (stateless) │ │ primary + │
│ (ALB / HA │ └──────────────┘ │ read replica│
│ proxy) │ └──────────────┘
└─────────────┘
│
▼ frontend served by same LB or static CDN
Single-VM step-by-step
- Provision a Linux VM (Ubuntu 22.04 LTS recommended). Open ports 80, 443. Block 5000 / 3000 / 5432 from public.
- Install Docker + Compose:
curl -fsSL https://get.docker.com | sh - Clone repo and configure:
git clone <repo-url> /opt/govula && cd /opt/govula cp .env.example .env # Fill required vars — see "Required env vars" below - Bring up the stack:
docker-compose -f docker-compose.on-prem.yml up -d - Reverse proxy — terminate TLS in Caddy or nginx (examples below) and forward to
127.0.0.1:5000(api) and127.0.0.1:3000(frontend). - Verify:
curl -fsS https://your-host/health # → {"status":"ok"} curl -fsS https://your-host/api/v1/health # → success:true, db:"ok"
Required env vars
All enforced by src/config/validateEnv.ts at boot:
| Variable | Required in prod | Notes |
|---|---|---|
NODE_ENV=production | yes | triggers boot-envelope hardening |
DATABASE_URL | yes | pooled connection if Postgres is co-resident, single-DSN otherwise |
JWT_SECRET | yes (≥32 chars) | hard-fail otherwise |
JWT_REFRESH_SECRET | yes (≥32 chars) | without it, every restart silently logs out every user |
CORS_ALLOWED_ORIGINS | yes | comma-separated, no * |
PUBLIC_URL | yes | absolute frontend URL — used in outbound replay links |
RESEND_API_KEY + ALERT_EMAIL_TO + EMAIL_FROM | all three or none | partial transport hard-fails boot |
Full list in .env.example.
Reverse proxy — Caddy (recommended)
/etc/caddy/Caddyfile:
api.example.com {
reverse_proxy 127.0.0.1:5000
encode gzip
header X-Frame-Options "DENY"
}
app.example.com {
reverse_proxy 127.0.0.1:3000
encode gzip
}
Caddy handles TLS automatically via Let's Encrypt.
Reverse proxy — nginx
upstream govula_api { server 127.0.0.1:5000; }
upstream govula_fe { server 127.0.0.1:3000; }
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
proxy_pass http://govula_api;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Postgres — co-resident vs managed
| Option | When | Notes |
|---|---|---|
| Postgres on the same VM | Small deployments, single tenant | docker-compose.on-prem.yml already includes a postgres:16-alpine service + nightly backup sidecar |
| Managed Postgres (RDS / Cloud SQL / Neon) | Anything customer-facing or > 100 tenants | Move DATABASE_URL to the managed endpoint; remove the postgres compose service |
Small-cluster pattern (Aspirational)
The application is stateless: any backend pod can serve any request. To scale horizontally:
- Move Postgres off the VM (managed instance with an external DSN).
- Run ≥ 2 backend instances behind a load balancer with sticky-by-nothing (round-robin).
- Healthcheck both
/health(liveness) and/api/v1/health(readiness — only this one is gated on the boot envelope). - Run the frontend either as a third pod or as a static deploy on the LB.
Govula does not currently ship a Helm chart or a Terraform module — these patterns are advisory.
Rollback
Same as Docker: tag-swap the image and restart. See ../rollback-plan.md.
Troubleshooting
See troubleshooting-matrix.md.
Where to read more
docker.md— the underlying container runtimebackup-recovery.md— self-hosted Postgres backupscaling.md— moving past one VM- In-app:
/docs/deployment/self-hosted