Deployment runbook
How CI, the image workflow and Dokploy fit together for staging, pre-production and production: GitHub environments, variables and secrets, Dokploy projects and apps, env groups, domains and TLS, migrations and rollback, backups, the docs site, branch protection, and the one-time setup checklist.
The rules (who deploys what, when) are in environments and releases; the targets (sizing, backups, monitoring, restore) in deployment and operations. This page is the how: what lives in the repository (OPS-02) and the steps done by hand in GitHub and Dokploy. hoco.dz and the host names are placeholders until the domain is confirmed (D-12).
What runs where
| File | Does |
|---|---|
.github/workflows/ci.yml | On every PR and on pushes to dev, master, release/**, hotfix/**: Biome, type-check, contract coverage (check-coverage), OpenAPI drift (pnpm openapi:export must not change hoco-v1.json), docs check-mdx, pnpm build (no secret needed); the full Vitest suite on PostgreSQL 17, Redis 7 and MinIO after prisma migrate deploy, a migration drift check and the reference + demo seeds, so no integration suite skips; gitleaks |
.github/workflows/images.yml | Builds and pushes the images, then deploys through the Dokploy webhooks of the target environment; on PRs touching docker/** or the lockfile, builds the images without pushing |
.github/workflows/docs.yml | Builds and deploys the docs site from dev |
docker/app.Dockerfile, docker/start.sh | The server, worker, back-office and reseller web image recipe (APP=server|dashboard|web; the worker is the server image with APP=worker) |
docker/mock.Dockerfile | Prism mock of the OpenAPI document (mock-api-staging) |
docker/docs.Dockerfile | The docs site, next start (not a static export: the plan pages read tasks.json at request time) |
docker/backup.Dockerfile, docker/backup.sh | The backup runner: pg_dump 17, age, rclone |
Images and tags
All under ghcr.io/anisbetehi/.
| Image | Built | Tags | Notes |
|---|---|---|---|
hoco-server | Once per commit | <sha>, staging / preprod / production, X.Y.Z-rc.N / X.Y.Z | Runs api (default, APP=server, migrates on start) and worker (APP=worker). A production tag whose tree equals the latest vX.Y.Z-rc.N promotes that candidate image (same digest, no rebuild); otherwise it is rebuilt with a warning |
hoco-dashboard, hoco-web | Per environment (NEXT_PUBLIC_API_URL and STORAGE_PUBLIC_URL are inlined at build time) | <environment>-<sha>, <environment>, and the version | Build arguments from the repository variables below |
hoco-mock | Staging only | <sha>, staging | Prism, port 4010 |
hoco-backup | Every deploy | <sha>, <environment> | Idles; Dokploy schedules run hoco-backup … in it |
hoco-docs | On docs changes on dev | <sha>, latest | Port 3000 |
Dokploy apps follow the moving tag of their environment (hoco-server:staging, hoco-dashboard:production…); a deploy webhook makes Dokploy pull it again. Rollback pins the previous immutable tag instead (rollback).
GitHub setup
Environments
Settings → Environments:
| Environment | Protection | Deployment branches and tags | Secrets |
|---|---|---|---|
staging | None (deploys on every merge to dev) | Branch dev | DOKPLOY_API_WEBHOOK, DOKPLOY_WORKER_WEBHOOK, DOKPLOY_DASHBOARD_WEBHOOK, DOKPLOY_WEB_WEBHOOK, DOKPLOY_BACKUP_WEBHOOK, DOKPLOY_MOCK_WEBHOOK |
preprod | Required reviewer: Anis | Tags v*-rc.* | The same names without MOCK, with the pre-production apps' webhooks |
production | Required reviewer: Anis; prevent self-review off (single owner) | Tags v* (the workflow refuses a tag not on master) | The same names without MOCK, with the production apps' webhooks |
Every webhook secret is optional: the deploy job skips a missing one with a notice, so the images can be built before Dokploy exists. The deploy job waits for approval on preprod and production, then deploys api, waits until GET /v1/system/version reports the new commit and /ready answers (10 minutes at most), then the worker, back-office, reseller web and backup runner.
Repository variables
Settings → Secrets and variables → Actions → Variables (not secrets: they end up in the browser bundle):
| Variable | Example |
|---|---|
STAGING_NEXT_PUBLIC_API_URL | https://api-staging.hoco.dz |
STAGING_STORAGE_PUBLIC_URL | https://cdn-staging.hoco.dz |
PREPROD_NEXT_PUBLIC_API_URL | https://api-preprod.hoco.dz |
PREPROD_STORAGE_PUBLIC_URL | https://cdn-preprod.hoco.dz |
PRODUCTION_NEXT_PUBLIC_API_URL | https://api.hoco.dz |
PRODUCTION_STORAGE_PUBLIC_URL | https://cdn.hoco.dz |
They are repository variables, not environment variables, so the images build before the approval and the approval gates only the deploy. The API URL is the origin, without /v1.
Repository secret
| Secret | For |
|---|---|
DOKPLOY_DOCS_WEBHOOK | docs.yml (optional) |
The registry needs no secret: the workflows push with GITHUB_TOKEN (packages: write).
Branch protection
On dev, master and release/* (repository rules), require these checks:
| Check | Workflow |
|---|---|
Lint · Types · Contracts · Build | ci.yml |
Tests (PostgreSQL · Redis · MinIO) | ci.yml |
Secret scan (gitleaks) | ci.yml |
The Image build check (…) jobs run only when docker/** or the lockfile changes, so they are not required (a required check that does not run blocks the PR). Tags v* are restricted to the repository owner (Settings → Rules → Rulesets → Tag rules).
Dokploy
Two VPS (sizing): VPS 2 runs the projects hoco-staging and hoco-preprod, VPS 1 the project hoco-production (and hoco-docs, restricted). On each: Ubuntu 24.04, Dokploy installed, SSH by key only, firewall open on 22, 80 and 443 only (and the Dokploy panel behind its own domain with HTTPS).
Registry. Settings → Registry: add ghcr.io with Anis's GitHub username and a fine-grained or classic token with read:packages only. Every app below uses it.
Services per environment
| Service | Image | Settings |
|---|---|---|
hoco-postgres-<env> | postgres:17 | Database hoco; an application role (DATABASE_URL) owning the schema; internal network only, no published port |
hoco-redis-<env> | redis:7 | --appendonly yes --appendfsync everysec --maxmemory 1gb --maxmemory-policy noeviction (smaller maxmemory on staging and pre-production); internal only |
hoco-minio-<env> | ghcr.io/anisbetehi/hoco-minio (pinned release, built from source by images.yml: MinIO no longer publishes images) | Buckets hoco-<env>-public (anonymous read) and hoco-<env>-private (versioning on: mc version enable); bucket CORS allowing PUT with Content-Type from the back-office and reseller web origins; a key pair for the apps limited to the two buckets; domain cdn[-<env>].hoco.dz → port 9000 |
Apps per environment
Every app: provider Docker, registry ghcr.io, the image and tag below, container port 3000 unless noted, environment from the env group. <env> is staging, preprod or production; the production apps drop the suffix (hoco-api, hoco-worker, hoco-dashboard, hoco-web).
| App | Image | Run-time variables | Domain → port | Replicas |
|---|---|---|---|---|
hoco-api-<env> | hoco-server:<env> | APP=server (default) | api[-<env>].hoco.dz → 3000 | 1; production 2 |
hoco-worker-<env> | hoco-server:<env> | APP=worker, WORKER_HEALTH_PORT=3001 | None (never routed) | 1 |
hoco-dashboard-<env> | hoco-dashboard:<env> | — | admin[-<env>].hoco.dz → 3000 | 1 |
hoco-web-<env> | hoco-web:<env> | — | app[-<env>].hoco.dz → 3000 | 1 |
hoco-backup-<env> | hoco-backup:<env> | Backup variables | None | 1 |
mock-api-staging (staging only) | hoco-mock:staging | — | mock-api-staging.hoco.dz → 4010 | 1 |
For each app, Deployments → Webhook URL is the value of its DOKPLOY_*_WEBHOOK secret in the matching GitHub environment.
Health checks and zero-downtime updates (Advanced → Swarm settings), so a failed migration or a crash keeps the previous version serving:
// hoco-api-<env> — Health Check
{ "Test": ["CMD", "node", "-e", "fetch('http://localhost:3000/ready').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"],
"Interval": 10000000000, "Timeout": 5000000000, "StartPeriod": 120000000000, "Retries": 3 }
// hoco-worker-<env> — the same with http://localhost:3001/health
// every app — Update Config (Rollback Config: the same without FailureAction)
{ "Parallelism": 1, "Delay": 10000000000, "FailureAction": "rollback", "Order": "start-first", "Monitor": 30000000000 }The Next apps and the docs can use / as their health URL on port 3000.
Env groups
One group per environment: the project environment of hoco-staging, hoco-preprod and hoco-production (Project → Environment), referenced from each app as NAME=${{project.NAME}}. Every value is distinct per environment; production values are generated in Dokploy and kept in the password vault (secrets). The names come from packages/env (validated at boot: a missing or malformed value stops the process).
hoco-api and hoco-worker (packages/env/src/server.ts):
| Variable | Secret | Value per environment |
|---|---|---|
NODE_ENV | No | production everywhere (the image default); it also refuses the demo seed and disables /reference |
LOG_LEVEL | No | info (debug on staging when needed) |
PORT | No | 3000 (the image default) |
COMMIT_SHA | No | Set by the image build; do not override |
DATABASE_URL | Yes | postgresql://<role>:<password>@hoco-postgres-<env>:5432/hoco |
REDIS_URL | Yes | redis://:<password>@hoco-redis-<env>:6379 |
WORKER_HEALTH_PORT | No | Worker only: 3001 |
S3_ENDPOINT | No | Internal MinIO URL (http://hoco-minio-<env>:9000) |
S3_REGION | No | us-east-1 |
S3_ACCESS_KEY, S3_SECRET_KEY | Yes | The apps' MinIO key pair |
S3_BUCKET_PUBLIC, S3_BUCKET_PRIVATE | No | hoco-<env>-public, hoco-<env>-private |
BETTER_AUTH_SECRET | Yes | openssl rand -base64 32; the server refuses to start in production without a random one |
BETTER_AUTH_URL | No | https://api[-<env>].hoco.dz |
COOKIE_DOMAIN | No | .hoco.dz |
SESSION_COOKIE_NAME | No | hoco-stg.session_token, hoco-pp.session_token, hoco.session_token (the default) — the same value on the back-office and the reseller web |
INVITE_SECRET, SYNC_CURSOR_SECRET | Yes | Optional (fall back to BETTER_AUTH_SECRET); set them to rotate independently |
API_PUBLIC_URL | No | https://api[-<env>].hoco.dz |
CORS_ORIGINS | No | https://admin[-<env>].hoco.dz,https://app[-<env>].hoco.dz |
DASHBOARD_URL, WEB_URL | No | https://admin[-<env>].hoco.dz, https://app[-<env>].hoco.dz |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM | No | The provider's (sandbox on staging) |
SMTP_PASS | Yes | The provider's |
SMS_PROVIDER | No | The gateway (D-13) |
SMS_API_KEY | Yes | The gateway's |
SIMULATE_SMS | No | true on staging; false on pre-production and production |
FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL | No | hoco-staging on staging and pre-production, hoco-prod on production |
FIREBASE_PRIVATE_KEY | Yes | The service account's |
GOOGLE_MAPS_API_KEY | Yes | Staging key, or the restricted production key |
APP | No | Set on the app (server or worker), not in the group |
APP_ENV | No | staging, preprod or production — read by the seed, which refuses the demo data when it is production |
hoco-dashboard and hoco-web (packages/env/src/dashboard.ts, web.ts): NEXT_PUBLIC_API_URL and STORAGE_PUBLIC_URL are build arguments, set as repository variables, not in Dokploy. At run time they need only SESSION_COOKIE_NAME, the API's value, read by the sign-in redirect (NODE_ENV=production and PORT=3000 come from the image).
Backup runner
hoco-backup: DATABASE_URL (the same database; a read-only role is enough), BACKUP_S3_ENDPOINT, BACKUP_S3_REGION, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY, BACKUP_S3_BUCKET, BACKUP_PREFIX (staging, preprod, production), BACKUP_AGE_RECIPIENTS, BACKUP_KEEP_DAILY, BACKUP_KEEP_WEEKLY, BACKUP_KEEP_MONTHLY, BACKUP_KEEP_PREDEPLOY, optional BACKUP_HEARTBEAT_URL; for the bucket copy, the S3_* variables of the environment and BACKUP_KEEP_PRIVATE_DAYS, BACKUP_KEEP_PUBLIC_DAYS.
Cookies, reference page and Sentry
Every environment shares the parent domain of COOKIE_DOMAIN=.hoco.dz, so a browser sends each environment's session cookie to the others' hosts. SESSION_COOKIE_NAME keeps them apart (a host only reads its own name), but a production cookie still reaches the staging hosts: use separate browser profiles for production and the test environments. /reference is served only when NODE_ENV is not production, so it is off on every deployed environment, unlike the table in environments and releases. Sentry is not wired yet, so there is no SENTRY_DSN.
Domains and TLS
| Host | Staging | Pre-production | Production | Points to |
|---|---|---|---|---|
| API | api-staging.hoco.dz | api-preprod.hoco.dz | api.hoco.dz | hoco-api-* |
| Back-office | admin-staging.hoco.dz | admin-preprod.hoco.dz | admin.hoco.dz | hoco-dashboard-* |
| Reseller web | app-staging.hoco.dz | app-preprod.hoco.dz | app.hoco.dz | hoco-web-* |
| Public files | cdn-staging.hoco.dz | cdn-preprod.hoco.dz | cdn.hoco.dz | hoco-minio-* port 9000 |
| Mock API | mock-api-staging.hoco.dz | — | — | mock-api-staging |
| Docs | — | — | docs.hoco.dz | hoco-docs |
- DNS at the registrar:
A(andAAAA) records to VPS 2 for the-stagingand-preprodhosts, to VPS 1 for production and docs;CAA 0 issue "letsencrypt.org"on the apex (domains). - In Dokploy, each domain: HTTPS on, certificate Let's Encrypt. Traefik redirects HTTP to HTTPS and renews the certificates.
- HSTS is sent by the apps themselves (helmet on the API, the security headers of the Next apps), so Traefik needs no extra middleware.
Migrations and rollback
- Migrations are the pre-start step of
api.docker/start.shrunsprisma migrate deploybefore the server listens; withstart-firstupdates and the/readyhealth check, a failed migration leaves the new task unhealthy, Swarm rolls back, and the previous version keeps serving. The worker never migrates. Migrations are additive (expand then contract). - Before a production deploy with a migration, run the backup app's Schedules → predeploy → Run now (
hoco-backup predeploy), then approve theproductionenvironment. - Rollback (10 minutes): in Dokploy, set the image of each affected app to the previous immutable tag —
hoco-server:<previous sha>or:X.Y.(Z-1)forapiandworkertogether,hoco-dashboard:<env>-<previous sha>… — and deploy. Return to the moving tag (:<env>) once the fix is released. A database rollback is a restore (restore runbook), never a down-migration.
Backups
The hoco-backup-<env> app runs docker/backup.sh from Dokploy Schedules (type Application, command run in the container). Dokploy's cron is UTC; Algiers is UTC+1 all year.
| Schedule | Cron (UTC) | Command | Policy |
|---|---|---|---|
| Nightly dump, 02:00 Algiers | 0 1 * * * | hoco-backup dump | pg_dump -Fc, checked with pg_restore --list, encrypted with age, uploaded under <prefix>/postgres/daily/; copied to weekly/ on Sundays and monthly/ on the 1st |
| Bucket copy, 03:00 | 0 2 * * * | hoco-backup buckets | Private and public buckets mirrored under <prefix>/buckets/; replaced or deleted objects kept 30 days (private) and 7 days (public) |
| Verification, 04:00 | 0 3 * * * | hoco-backup verify | Last night's dump exists and is larger than 50 % of the previous one; otherwise the job fails (Dokploy notification by email) |
| Before a production deploy | Manual | hoco-backup predeploy | Kept: the last 5 |
Retention per environment (data policy, backups):
| Environment | BACKUP_KEEP_DAILY | BACKUP_KEEP_WEEKLY | BACKUP_KEEP_MONTHLY |
|---|---|---|---|
| Staging | 7 | 0 | 0 |
| Pre-production | 14 | 0 | 0 |
| Production | 7 | 4 | 12 |
- Target: a bucket at another provider or account than the VPS, with credentials that can write and delete in it only; one prefix per environment. Object lock if the provider has it.
- Encryption keys:
age-keygen -o hoco-backup.keyon an offline machine, once for Anis and once for HOCO's designated person; both public keys (age1…) go inBACKUP_AGE_RECIPIENTS, the private keys never reach a server. Restore:age -d -i hoco-backup.key -o hoco.dump <file>.dump.age, then the restore runbook. - Not covered by the script: continuous WAL archiving and the weekly base backup for point-in-time recovery in production — set up with the PostgreSQL service (for example WAL-G to the same target) in SRV-28, together with the timed restore drill on the VPS. Dokploy's built-in database backups are not used: they are not encrypted.
Docs site
docs.yml builds hoco-docs on every change to apps/docs on dev and calls DOKPLOY_DOCS_WEBHOOK. In Dokploy: app hoco-docs, image hoco-docs:latest, port 3000, domain docs.hoco.dz with Let's Encrypt, and Security → Basic auth with one user per reader (restricted access). It needs no environment variable.
Mock API
mock-api-staging serves examples generated from the committed OpenAPI document of dev (--dynamic, CORS on) — the same as pnpm mock. It redeploys with staging, so it always matches the contract on dev.
Setup checklist for Anis
GitHub:
- Environments
staging,preprod(required reviewer, tagsv*-rc.*) andproduction(required reviewer, tagsv*) (environments). - The six repository variables
*_NEXT_PUBLIC_API_URLand*_STORAGE_PUBLIC_URL(variables). - Branch protection on
dev,master,release/*with the three required checks; tag rule onv*(branch protection). - After the first
images.ymlrun: the GHCR packages stay private; link them to the repository (Package settings → Manage Actions access) if the push is refused.
Infrastructure:
- VPS 1 and VPS 2 provisioned and hardened; Dokploy installed on both; the panel on HTTPS.
- DNS records and CAA (domains).
- Registry
ghcr.iowith aread:packagestoken in Dokploy. - Projects
hoco-staging,hoco-preprod,hoco-production; in each, PostgreSQL 17, Redis 7 and MinIO with their buckets, versioning and CORS (services). - Project environment (env group) of each project filled from the tables; secrets generated per environment and stored in the vault.
- Apps created with images, domains, Let's Encrypt, health checks and update config (apps); each webhook URL copied into the matching GitHub environment secret.
- Pre-production: reference seed and the anonymised recette data, never the demo seed with
APP_ENV=production; staging: reference and demo seeds (pnpm db:seed:demorun once from a one-off container of the server image). - Backup target bucket and credentials; two
agekey pairs (Anis, HOCO);hoco-backup-<env>app and its three schedules; Dokploy email notifications on failed schedules; check after the first three nights that the files are there and pruned (OPS-02 acceptance). - Uptime monitor outside VPS 1:
/healthand/readyof every API,/of admin, app and docs, every minute (monitoring). -
hoco-docswith basic auth;mock-api-staging.
Verification (OPS-02 tests):
- A no-op PR merged into
devshows its commit athttps://api-staging.hoco.dz/v1/system/versionwithin 10 minutes. - A tag
v0.1.0-rc.1onrelease/0.1.0waits for approval, then deploys pre-production. - Rollback drill on staging: pin the previous
hoco-server:<sha>on api and worker, check/v1/system/version, return to:staging. - A broken migration on a throwaway branch deployed to staging leaves the previous version serving.
Environments and releases
Local, staging, pre-production and production — what each holds, who deploys and when — and how deployments, migrations, mobile release trains, versions, changelogs, rollbacks, feature flags and maintenance mode work.
Rituals and communication
The weekly rhythm with agendas, the channels and what each is for, response times, meeting notes, progress reporting, the escalation path and the Ramadan schedule.