HOCO B2B
Ways of working

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

FileDoes
.github/workflows/ci.ymlOn 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.ymlBuilds 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.ymlBuilds and deploys the docs site from dev
docker/app.Dockerfile, docker/start.shThe server, worker, back-office and reseller web image recipe (APP=server|dashboard|web; the worker is the server image with APP=worker)
docker/mock.DockerfilePrism mock of the OpenAPI document (mock-api-staging)
docker/docs.DockerfileThe docs site, next start (not a static export: the plan pages read tasks.json at request time)
docker/backup.Dockerfile, docker/backup.shThe backup runner: pg_dump 17, age, rclone

Images and tags

All under ghcr.io/anisbetehi/.

ImageBuiltTagsNotes
hoco-serverOnce per commit<sha>, staging / preprod / production, X.Y.Z-rc.N / X.Y.ZRuns 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-webPer environment (NEXT_PUBLIC_API_URL and STORAGE_PUBLIC_URL are inlined at build time)<environment>-<sha>, <environment>, and the versionBuild arguments from the repository variables below
hoco-mockStaging only<sha>, stagingPrism, port 4010
hoco-backupEvery deploy<sha>, <environment>Idles; Dokploy schedules run hoco-backup … in it
hoco-docsOn docs changes on dev<sha>, latestPort 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:

EnvironmentProtectionDeployment branches and tagsSecrets
stagingNone (deploys on every merge to dev)Branch devDOKPLOY_API_WEBHOOK, DOKPLOY_WORKER_WEBHOOK, DOKPLOY_DASHBOARD_WEBHOOK, DOKPLOY_WEB_WEBHOOK, DOKPLOY_BACKUP_WEBHOOK, DOKPLOY_MOCK_WEBHOOK
preprodRequired reviewer: AnisTags v*-rc.*The same names without MOCK, with the pre-production apps' webhooks
productionRequired 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):

VariableExample
STAGING_NEXT_PUBLIC_API_URLhttps://api-staging.hoco.dz
STAGING_STORAGE_PUBLIC_URLhttps://cdn-staging.hoco.dz
PREPROD_NEXT_PUBLIC_API_URLhttps://api-preprod.hoco.dz
PREPROD_STORAGE_PUBLIC_URLhttps://cdn-preprod.hoco.dz
PRODUCTION_NEXT_PUBLIC_API_URLhttps://api.hoco.dz
PRODUCTION_STORAGE_PUBLIC_URLhttps://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

SecretFor
DOKPLOY_DOCS_WEBHOOKdocs.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:

CheckWorkflow
Lint · Types · Contracts · Buildci.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

ServiceImageSettings
hoco-postgres-<env>postgres:17Database 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).

AppImageRun-time variablesDomain → portReplicas
hoco-api-<env>hoco-server:<env>APP=server (default)api[-<env>].hoco.dz → 30001; production 2
hoco-worker-<env>hoco-server:<env>APP=worker, WORKER_HEALTH_PORT=3001None (never routed)1
hoco-dashboard-<env>hoco-dashboard:<env>—admin[-<env>].hoco.dz → 30001
hoco-web-<env>hoco-web:<env>—app[-<env>].hoco.dz → 30001
hoco-backup-<env>hoco-backup:<env>Backup variablesNone1
mock-api-staging (staging only)hoco-mock:staging—mock-api-staging.hoco.dz → 40101

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):

VariableSecretValue per environment
NODE_ENVNoproduction everywhere (the image default); it also refuses the demo seed and disables /reference
LOG_LEVELNoinfo (debug on staging when needed)
PORTNo3000 (the image default)
COMMIT_SHANoSet by the image build; do not override
DATABASE_URLYespostgresql://<role>:<password>@hoco-postgres-<env>:5432/hoco
REDIS_URLYesredis://:<password>@hoco-redis-<env>:6379
WORKER_HEALTH_PORTNoWorker only: 3001
S3_ENDPOINTNoInternal MinIO URL (http://hoco-minio-<env>:9000)
S3_REGIONNous-east-1
S3_ACCESS_KEY, S3_SECRET_KEYYesThe apps' MinIO key pair
S3_BUCKET_PUBLIC, S3_BUCKET_PRIVATENohoco-<env>-public, hoco-<env>-private
BETTER_AUTH_SECRETYesopenssl rand -base64 32; the server refuses to start in production without a random one
BETTER_AUTH_URLNohttps://api[-<env>].hoco.dz
COOKIE_DOMAINNo.hoco.dz
SESSION_COOKIE_NAMENohoco-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_SECRETYesOptional (fall back to BETTER_AUTH_SECRET); set them to rotate independently
API_PUBLIC_URLNohttps://api[-<env>].hoco.dz
CORS_ORIGINSNohttps://admin[-<env>].hoco.dz,https://app[-<env>].hoco.dz
DASHBOARD_URL, WEB_URLNohttps://admin[-<env>].hoco.dz, https://app[-<env>].hoco.dz
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROMNoThe provider's (sandbox on staging)
SMTP_PASSYesThe provider's
SMS_PROVIDERNoThe gateway (D-13)
SMS_API_KEYYesThe gateway's
SIMULATE_SMSNotrue on staging; false on pre-production and production
FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAILNohoco-staging on staging and pre-production, hoco-prod on production
FIREBASE_PRIVATE_KEYYesThe service account's
GOOGLE_MAPS_API_KEYYesStaging key, or the restricted production key
APPNoSet on the app (server or worker), not in the group
APP_ENVNostaging, 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

HostStagingPre-productionProductionPoints to
APIapi-staging.hoco.dzapi-preprod.hoco.dzapi.hoco.dzhoco-api-*
Back-officeadmin-staging.hoco.dzadmin-preprod.hoco.dzadmin.hoco.dzhoco-dashboard-*
Reseller webapp-staging.hoco.dzapp-preprod.hoco.dzapp.hoco.dzhoco-web-*
Public filescdn-staging.hoco.dzcdn-preprod.hoco.dzcdn.hoco.dzhoco-minio-* port 9000
Mock APImock-api-staging.hoco.dz——mock-api-staging
Docs——docs.hoco.dzhoco-docs
  • DNS at the registrar: A (and AAAA) records to VPS 2 for the -staging and -preprod hosts, 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.sh runs prisma migrate deploy before the server listens; with start-first updates and the /ready health 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 the production environment.
  • 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) for api and worker together, 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.

ScheduleCron (UTC)CommandPolicy
Nightly dump, 02:00 Algiers0 1 * * *hoco-backup dumppg_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:000 2 * * *hoco-backup bucketsPrivate and public buckets mirrored under <prefix>/buckets/; replaced or deleted objects kept 30 days (private) and 7 days (public)
Verification, 04:000 3 * * *hoco-backup verifyLast night's dump exists and is larger than 50 % of the previous one; otherwise the job fails (Dokploy notification by email)
Before a production deployManualhoco-backup predeployKept: the last 5

Retention per environment (data policy, backups):

EnvironmentBACKUP_KEEP_DAILYBACKUP_KEEP_WEEKLYBACKUP_KEEP_MONTHLY
Staging700
Pre-production1400
Production7412
  • 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.key on an offline machine, once for Anis and once for HOCO's designated person; both public keys (age1…) go in BACKUP_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, tags v*-rc.*) and production (required reviewer, tags v*) (environments).
  • The six repository variables *_NEXT_PUBLIC_API_URL and *_STORAGE_PUBLIC_URL (variables).
  • Branch protection on dev, master, release/* with the three required checks; tag rule on v* (branch protection).
  • After the first images.yml run: 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.io with a read:packages token 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:demo run once from a one-off container of the server image).
  • Backup target bucket and credentials; two age key 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: /health and /ready of every API, / of admin, app and docs, every minute (monitoring).
  • hoco-docs with basic auth; mock-api-staging.

Verification (OPS-02 tests):

  • A no-op PR merged into dev shows its commit at https://api-staging.hoco.dz/v1/system/version within 10 minutes.
  • A tag v0.1.0-rc.1 on release/0.1.0 waits 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.

On this page