Local-development and production-grade Docker recipes for Govula's backend (Express) and frontend (Next.js). Compose files already exist on main (docker-compose.yml, docker-compose.single-tenant.yml, docker-compose.on-prem.yml) — see docs/ENTERPRISE-DEPLOYMENT.md for the deployment-mode flags they consume.
Status: Implemented for local dev and single-host production. Multi-host orchestration (Swarm / Kubernetes) is Aspirational — pattern only.
Architecture
┌─────────────────┐ ┌────────────────────┐ ┌─────────────────┐
│ govula-fe │ → │ govula-api │ → │ postgres │
│ Next.js (3000) │ │ Express (5000) │ │ 16-alpine │
│ NEXT_PUBLIC_ │ │ NODE_ENV=prod │ │ named volume │
│ API_URL=... │ │ PORT=5000 │ │ pg_data │
└─────────────────┘ └────────────────────┘ └─────────────────┘
▲ ▲
│ host:80/443 │ host:5000 (internal only behind reverse proxy)
▼ ▼
reverse proxy (Caddy / nginx / Traefik)
Local dev
cp .env.example .env
docker-compose up -d
# Wait for health
docker-compose ps
curl -fsS http://localhost:5000/health # → {"status":"ok"}
curl -fsS http://localhost:3000 # → 200
The compose file mounts a pg_data named volume so a docker-compose down does not drop your dev DB.
Production single-host
- Build with explicit version + hash (matches the deterministic build pattern in
docs/ENTERPRISE-DEPLOYMENT.md§"Deterministic Builds"):docker build \ --build-arg BUILD_HASH=$(git rev-parse --short HEAD) \ --build-arg BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ -t govula-api:$(git rev-parse --short HEAD) . - Run with hardened env (every variable below is enforced at boot by
src/config/validateEnv.ts):NODE_ENV=productionJWT_SECRET(≥32 chars) andJWT_REFRESH_SECRET(≥32 chars) — both required, both hard-failDATABASE_URL(pooled Postgres connection)CORS_ALLOWED_ORIGINS(comma-separated, no*)PUBLIC_URL(absolute frontend URL)RESEND_API_KEY+ALERT_EMAIL_TO+EMAIL_FROM(all three or none)
- Healthcheck (the unconditional liveness probe at
src/app.ts):healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:5000/health"] interval: 30s timeout: 5s retries: 3 start_period: 30s - Log driver — Govula already emits structured JSON via Pino (
src/utils/logger.ts). Pipe stdout to your aggregator:logging: driver: "json-file" # or "fluentd", "awslogs", "gcplogs" options: { max-size: "50m", max-file: "5" }
Multi-stage Dockerfile pattern
Govula's repo root contains a single Node image. For tighter prod images:
# ── builder ──────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # tsc → dist/
# ── runtime ──────────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --spider -q http://localhost:5000/health || exit 1
USER node
CMD ["node", "dist/index.js"]
Docker Compose modes
The repo ships three compose files (see docs/ENTERPRISE-DEPLOYMENT.md):
| File | Mode flag | Notes |
|---|---|---|
docker-compose.yml | DEPLOYMENT_MODE=saas | Default multi-tenant |
docker-compose.single-tenant.yml | DEPLOYMENT_MODE=single-tenant | Dedicated DB |
docker-compose.on-prem.yml | DEPLOYMENT_MODE=on-prem | Includes backup sidecar |
Rollback
docker-compose rollback is image-tag swap:
docker-compose pull govula-api:<previous-tag>
docker-compose up -d --no-deps govula-api
Schema is forward-only and additive (every CREATE TABLE and ADD COLUMN is IF NOT EXISTS per src/scripts/ensureSchema.ts), so rolling the image back is safe — older code simply doesn't read newer columns. See ../rollback-plan.md for full procedure.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
govula-api exits immediately, log says Production environment validation failed | A required env var is missing/short | Inspect log for the variable name; set it; restart |
/health 200, /api/v1/health 503 with db:"down" | Postgres container not ready | Wait for postgres healthcheck; check DATABASE_URL |
| Frontend can't reach backend | Wrong NEXT_PUBLIC_API_URL (build-time!) | Rebuild frontend image after env change |
| CORS 403 from your domain | Origin not in CORS_ALLOWED_ORIGINS | Add origin, restart api container |
Where to read more
../deployment-guide.md— Railway alternativescaling.md— moving past one boxobservability-setup.md— log shipping + Sentry- In-app:
/docs/deployment/docker