Source: docs/deployment-do.md
¶ DigitalOcean Deployment Guide
End-to-end runbook for deploying the API + Baileys connector to DigitalOcean using App Platform, Managed Databases, and Spaces. Frontend (Vercel) is covered in a separate doc.
Architecture
¶ One App Platform app, two components, three managed resources
DigitalOcean App Platform (single app, two components)
apiFastAPI + Socket.IO (auto-TLS, public)connectorBaileys, private :8787 only (1 replica ONLY)Original architecture diagram
Vercel (web)
│ HTTPS
▼
DigitalOcean App Platform (single app, two components)
├─ Web Service "api" — FastAPI + Socket.IO (auto-TLS, public)
└─ Internal Service "connector" — Baileys, private :8787 only (1 replica ONLY)
│
├─▶ Managed PostgreSQL (TLS required)
├─▶ Managed Redis (TLS required, BullMQ + Socket.IO + rate limit)
└─▶ Spaces (S3-compatible, encrypted session + media blobs)
Decisions baked in
DigitalOcean SGP1 (Singapore) region for all resources App Platform PaaS — no SSH, auto-deploy on git push, auto-TLS Connector pinned to 1 instance (Baileys can't run multi-replica per channel) Managed Postgres + Redis (no self-hosted) Spaces for object storage Custom domain recommended for cookie sharing with Vercel frontendRead this before you start
¶ Four things that break production deploys
¶ Baileys ≠ multi-replica
Each WhatsApp session must be held by exactly one process. Never scale the internal Service beyond 1 instance. The API can scale freely; the connector cannot. App Platform's spec lets you pin instance_count: 1 explicitly.
¶ Secrets handling
Generate fresh JWT/HMAC/encryption keys for prod — do NOT reuse the dev ones from your .env. Generators are in step 5.
¶ Custom domain strongly recommended
Before connecting Vercel. Without one, you're on *.ondigitalocean.app and *.vercel.app — cross-site cookies are unreliable.
¶ DO Managed Postgres / Redis require TLS
Connection strings use ?sslmode=require (Postgres) and rediss:// (Redis). App Platform's "trusted source" feature lets you whitelist the app without IP rules.
Cost estimate (USD/month)
¶ About $45 a month
Smaller than the AWS plan because App Platform Basic XS is cheaper than EC2 t3.medium, and you don't pay separately for nginx/load balancer.
Prerequisites & runbook
¶ Nine steps from registry to smoke test
winget install --id DigitalOcean.doctl then doctl auth init
Step 0 · Registry
¶ Container registry (DOCR) + CI
The api and connector deploy from prebuilt Docker images in the DigitalOcean Container Registry. GitHub Actions builds them on push to main.
0a
¶ Create the registry
doctl registry create <registry-name> # e.g. utopiabuilder — Basic plan
Basic (~$5/mo) is required because we use two repositories (replybox-api, replybox-connector); the free Starter tier allows only one. The repositories are created automatically on first image push.
registry.digitalocean.com/<registry-name>/replybox-api:latest (+ :<git-sha>)
registry.digitalocean.com/<registry-name>/replybox-connector:latest (+ :<git-sha>)
0b
¶ GitHub Actions credentials
In GitHub → repo Settings → Secrets and variables → Actions:
DIGITALOCEAN_ACCESS_TOKENsecretA DO API token with registry read/write + app scope (create at https://cloud.digitalocean.com/account/api/tokens).
DOCR_REGISTRYvariableYour <registry-name>.
The workflow .github/workflows/deploy-do.yml logs into DOCR with that token, builds both images, and pushes :latest + :<git-sha>. App Platform's autodeploy-on-push (enabled per component in step 4) then rolls them out — no doctl deploy trigger needed.
Step 1 · Spaces
¶ Spaces (object storage)
Private, S3-compatible storage for encrypted session and media blobs.
1a
¶ Create the Space
Open https://cloud.digitalocean.com/spaces/new
replybox-prod-<your-suffix> (e.g. replybox-prod-utopia)
ProjectDefault (or create one called replybox)
Click Create a Spaces Bucket, then record the values.
SPACES_BUCKET = replybox-prod-utopia
SPACES_ENDPOINT = https://sgp1.digitaloceanspaces.com
SPACES_REGION = sgp1
1b
¶ Create a Spaces access key
Spaces uses S3-compatible keys, separate from your DO API token.
Open https://cloud.digitalocean.com/account/api/spaces
replybox-app
3Scope: Limited Access → choose your Space → Read/Write/Delete
4Click Create Key
SPACES_ACCESS_KEY = DO00...
SPACES_SECRET_KEY = ...
Save to password manager NOW.
Verification
If you installed s3cmd or want to use doctl:
doctl compute ssh-key list # any command that uses your DO API token confirms auth
The real test of Spaces creds comes in step 5 when the API tries to upload — we'll catch it then.
Step 2 · PostgreSQL
¶ Managed PostgreSQL
Provision the cluster, then create the replybox database and user.
2a
¶ Create the cluster
Open https://cloud.digitalocean.com/databases/new
replybox-db
Projectreplybox (or default)
Click Create a Database Cluster. Provisioning takes ~5 min.
2b
¶ Configure database + user
Once provisioned, you land on the cluster page. Three things to do:
¶ Create the replybox database
replybox → Save
¶ Create the replybox user
Optional — doadmin works but is overprivileged.
replybox → Save
3Permissions auto-grant ALL on replybox db
¶ Get the connection string
replybox
4Change Database dropdown to replybox
5Toggle VPC network → use the public network view for now (we'll switch to VPC in step 4)
postgresql://replybox:AVNS_xxxx@replybox-db-do-user-12345-0.h.db.ondigitalocean.com:25060/replybox?sslmode=require
Save this entire string as DATABASE_URL_RAW. We'll convert it to the asyncpg variant in step 5.
Verification
# From your local machine, test connectivity:
psql "postgresql://replybox:AVNS_xxxx@replybox-db-do-user-12345-0.h.db.ondigitalocean.com:25060/replybox?sslmode=require" -c "select version();"
If you don't have psql locally, skip — App Platform will verify in step 5.
Step 3 · Redis
¶ Managed Redis (Caching)
One TLS Redis cluster serves BullMQ, Socket.IO, and rate limiting on separate logical DBs.
3a
¶ Create the cluster
Open https://cloud.digitalocean.com/databases/new
replybox-redis
Click Create. Provisioning takes ~3 min.
3b
¶ Get the connection string
rediss://default:AVNS_xxxx@replybox-redis-do-user-12345-0.h.db.ondigitalocean.com:25061
Note the rediss:// (double-s = TLS) and port 25061. Save as REDIS_URL_RAW. The API uses separate logical DBs for BullMQ / Socket.IO / rate limit — DO Managed Redis supports up to DB index 15 by default, so we'll append /0, /1, /2 in step 5.
Verification
# From local (requires redis-cli with TLS support):
redis-cli -u "rediss://default:AVNS_xxxx@replybox-redis-do-user-12345-0.h.db.ondigitalocean.com:25061" ping
# expected: PONG
Step 4 · App Platform
¶ App Platform: create the App + components
Source model: prebuilt images from DOCR (not git buildpacks).
The api and connector are deployed from Docker images that GitHub Actions builds and pushes to the DigitalOcean Container Registry on every push to main (see .github/workflows/deploy-do.yml and step 0). App Platform watches each component's :latest tag and auto-deploys on push — there is no git build step and no doctl trigger. The component tables below use DOCR image as the source instead of a repo + build command.
4a
¶ Start the App creation flow
Open https://cloud.digitalocean.com/apps/new
replybox-api, tag latest. This becomes the api component; we add connector and migrate below.
3Autodeploy: enable "Autodeploy on push" so a new :latest image triggers a rollout.
4Click Next, then configure each component per the tables below.
4b · Web Service
¶ API component
Click the api component → Edit.
api
SourceDOCR image replybox-api, tag latest
Run command(leave blank — use the image CMD: uvicorn app.main:app --port 8000)
HTTP port8000 ⚠️ (the image listens on 8000, not 8080)
HTTP request routes/ (default)
Instance sizeBasic XS ($5/mo, 512 MB / 1 vCPU)
Instance count1 (can scale later)
The image is built from apps/api/Dockerfile (python:3.11-slim) — no language detection or build command needed; DO just pulls and runs it.
Health check (expand "Advanced")
HTTP path/healthz
Initial delay seconds15
Period seconds10
Timeout seconds5
Failure threshold5
Don't set env vars yet — we'll batch them in step 5.
4c · Internal Service
¶ Connector component
Click Add Component → Service, then make it internal-only. A Worker has no ingress at all, including from the API, so it cannot host the HMAC-protected live Baileys probes. A Service with only internal_ports is reachable on the app's private LAN and has no public endpoint.
replybox-connector, tag latest
Component nameconnector
Run command(leave blank — use the image CMD: node dist/index.js)
Public HTTP port(none)
Internal ports8787
HTTP request routes(none; do not add an ingress rule)
Instance sizeBasic XS ($5/mo)
Instance count1 ⚠️ DO NOT INCREASE — each WhatsApp session must have exactly one connector process
The image is built from apps/baileys-connector/Dockerfile (node:20-alpine). It remains private: do not set http_port, routes, or an ingress rule. The existing Fastify server binds 0.0.0.0:8787; /health and /ready expose only liveness on the app-private LAN, while the disciplinary routes require the shared HMAC secret. No route on this port has public ingress.
4d · Pre-Deploy Job
¶ Migrations
Click Add Component → Job.
replybox-api, tag latest (reuses the api image)
Component namemigrate
Run commandsh scripts/release.sh (runs alembic upgrade head + python -m app.cli seed)
Instance sizeBasic XS (one-shot, only billed during runtime — pennies)
This auto-runs migrations on every deploy. Cleaner than SSH'ing in. It reuses the api image, so no separate build is needed.
4e
¶ Attach Managed Databases to the app
In the app creation flow, look for Add resource → Database. If you see your replybox-db and replybox-redis clusters, attach them. This:
DATABASE_URL and REDIS_URL as component env vars
2Switches the connection to the VPC private network (no public internet hop, faster + free bandwidth)
3Auto-adds the App as a "trusted source" on the DB firewall
If the attach UI doesn't show your clusters, skip this and we'll set the env vars manually in step 5 using the public connection strings.
4f — Don't launch yet. Scroll down to the bottom of the review page but don't click Create Resources yet. We need to set env vars first (step 5).
Step 5 · Env vars
¶ Environment variables
Generate fresh secrets, convert the Postgres URL, then set each component's variables. Mark secrets as Encrypted (the "Encrypt" checkbox).
5a
¶ Generate fresh production secrets
python -c "import secrets; print('JWT_SECRET=' + secrets.token_hex(32))"
python -c "import secrets; print('INTERNAL_HMAC_SECRET=' + secrets.token_hex(32))"
python -c "import secrets, base64; print('CHANNEL_SESSION_KEY=' + base64.b64encode(secrets.token_bytes(32)).decode())"
python -c "import secrets, base64; print('MEDIA_OBJECT_KEY=' + base64.b64encode(secrets.token_bytes(32)).decode())"
Save all four. The HMAC, CHANNEL_SESSION_KEY, and MEDIA_OBJECT_KEY must be identical between the api and connector components — they're shared secrets.
5b
¶ Convert the Postgres URL for asyncpg
postgresql://replybox:AVNS_xxxx@...:25060/replybox?sslmode=require
postgresql+asyncpg://replybox:AVNS_xxxx@...:25060/replybox?ssl=require
postgresql:// → postgresql+asyncpg://
Change 2?sslmode=require → ?ssl=require (asyncpg uses different param name)
5c
¶ API component env vars
In the App creation page, find the api component → Edit → Environment Variables. Add these one by one.
api
ENVproduction
LOG_LEVELinfo
DATABASE_URLEncrypt ✓postgresql+asyncpg://replybox:...?ssl=require
REDIS_URLEncrypt ✓rediss://default:...:25061/0
SOCKETIO_REDIS_URLEncrypt ✓rediss://default:...:25061/1
RATE_LIMIT_REDIS_URLEncrypt ✓rediss://default:...:25061/2
JWT_SECRETEncrypt ✓from 5a
JWT_ACCESS_TTL_SECONDS900
JWT_REFRESH_TTL_SECONDS1209600
INTERNAL_HMAC_SECRETEncrypt ✓from 5a
CHANNEL_SESSION_KEYEncrypt ✓from 5a
CHANNEL_SESSION_KEY_VERSION1
MEDIA_OBJECT_KEYEncrypt ✓from 5a
CORS_ORIGINShttps://app.<DOMAIN> (or your Vercel URL temporarily)
COOKIE_DOMAIN.<DOMAIN> (leading dot) — leave blank if no custom domain yet
COOKIE_SECUREtrue
S3_ENDPOINThttps://sgp1.digitaloceanspaces.com
S3_REGIONsgp1
S3_BUCKETreplybox-prod-utopia (your Space name)
S3_ACCESS_KEYEncrypt ✓from step 1b
S3_SECRET_KEYEncrypt ✓from step 1b
S3_PUBLIC_BASE_URLhttps://api.<DOMAIN> (or your ondigitalocean.app URL)
DISCIPLINARY_CONNECTOR_BASE_URL${connector.PRIVATE_URL}
BAILEYS_CONNECTOR_BASE_URL${connector.PRIVATE_URL}
DISCIPLINARY_SEND_SWEEP_SECONDS15
5d
¶ Connector component env vars
Find the connector component → Edit → Environment Variables.
connector
ENVproduction
LOG_LEVELinfo
INTERNAL_API_BASE_URL${api.PRIVATE_URL} (App Platform binding — see note below)
INTERNAL_HMAC_SECRETEncrypt ✓same value as api
REDIS_URLEncrypt ✓rediss://default:...:25061/0 (same DB as api)
BULLMQ_QUEUE_NAMEwhatsapp
WORKER_CONCURRENCY5
CHANNEL_SESSION_KEYEncrypt ✓same value as api
MEDIA_OBJECT_KEYEncrypt ✓same value as api
S3_ENDPOINThttps://sgp1.digitaloceanspaces.com
S3_REGIONsgp1
S3_BUCKETsame as api
S3_ACCESS_KEYEncrypt ✓same as api
S3_SECRET_KEYEncrypt ✓same as api
BAILEYS_LOG_LEVELwarn
HTTP_PORT8787
${api.PRIVATE_URL} binding: App Platform supports inter-component variable refs of the form ${component_name.VARNAME}. PRIVATE_URL resolves to the internal hostname like http://api.replybox-app.svc.cluster.local (or DO's equivalent), reachable only within the app. If the syntax errors on save, fall back to ${api.PUBLIC_URL} which uses the public hostname (works, just hops through the internet — wasted bandwidth but functional).
5e
¶ Migrate component env vars
migrate just runs alembic upgrade head against Postgres. Set:
migrate
DATABASE_URLEncrypt ✓same asyncpg URL as api
(Alembic uses the same DATABASE_URL env via app.config. No other vars needed.)
5f
¶ Trigger the first deploy
Click Create Resources at the bottom of the App creation page. DO starts building all three components in parallel. The build log streams live. Expect ~3-5 min for the first build.
Step 6 · Deploy
¶ Watch the deploy
Stay on the App's Activity tab. You'll see:
¶ Build phase
migrate, api, connector build images. Likely failure spots:
pip install -e . fails — usually a missing system lib for cryptography (asyncpg/bcrypt sometimes need build tools). DO's Python buildpack handles this, but if it fails, switch to a Dockerfile.
Connectorcorepack enable then pnpm install. If corepack isn't on PATH, swap the build command to: npm install -g pnpm@9 && pnpm install --frozen-lockfile && pnpm build
¶ Deploy phase
migrate runs first (pre-deploy). If migrations fail, the new version doesn't go live; old version (none on first deploy) keeps running.
¶ Health check
API must respond 200 on /healthz. If it doesn't within ~5 min, deploy is marked failed.
¶ Common first-deploy issues
migrate fails with connection refusedPostgres VPC binding isn't right. Use the public connection string explicitly in env var instead.
API healthcheck failsLikely the app didn't bind to $PORT — DO sets PORT=8080, our run command hardcodes --port 8080 to match. If you changed it, fix the mismatch.
Connector restart loops with MEDIA_OBJECT_KEY is not setenv var missing or differs from api's value
Connector logs ECONNREFUSED to INTERNAL_API_BASE_URLThe ${api.PRIVATE_URL} binding isn't valid. Replace with the public URL: https://api-xxxxx.ondigitalocean.app
Build fails with Python 3.11 not foundAdd a .python-version file with 3.12 (or Dockerfile) at apps/api/.python-version
Build fails with pnpm: command not foundSwap build command to npm install -g pnpm@9 && pnpm install --frozen-lockfile && pnpm build
Verification
Once both components show Running (green dot):
# Replace with your actual app URL from the App's Overview tab
curl -i https://replybox-app-xxxxx.ondigitalocean.app/healthz
# expected: HTTP/2 200, body something like {"status":"ok"}
Step 7 · Domain
¶ Custom domain + TLS
Strongly recommended before connecting Vercel.
¶ Add domain in App Platform
In your App → Settings → Domains → Add Domain.
api.<DOMAIN>
TypePrimary (or Alias if you want the ondigitalocean.app URL to stay primary)
CertificateManaged Certificate (Let's Encrypt, auto-renewed)
DO will give you DNS instructions — usually a CNAME record from api to replybox-app-xxxxx.ondigitalocean.app.
¶ Add DNS record at your registrar
At your DNS provider (Cloudflare, Namecheap, etc.):
CNAME
Hostapi
Valuereplybox-app-xxxxx.ondigitalocean.app
TTL300
DNS propagation takes 1-15 min. DO will auto-issue the Let's Encrypt cert once it can validate ownership.
¶ Update env vars to use the domain
Once https://api.<DOMAIN> resolves, go back to the API component env vars and update:
CORS_ORIGINS → https://app.<DOMAIN> (the planned Vercel URL)
2COOKIE_DOMAIN → .<DOMAIN> (leading dot)
3S3_PUBLIC_BASE_URL → https://api.<DOMAIN>
Save. App Platform redeploys automatically with the new values.
Verification
curl -i https://api.<DOMAIN>/healthz
# expected: HTTP/2 200
# Cert details
openssl s_client -connect api.<DOMAIN>:443 -servername api.<DOMAIN> < /dev/null 2>$null | openssl x509 -noout -subject -issuer -dates
Step 8 · Smoke test
¶ End-to-end smoke test
Health, register, and an authenticated call, then watch the connector pick up a job.
# 1. Health
curl https://api.<DOMAIN>/healthz
# 2. Register a user
curl -X POST https://api.<DOMAIN>/v1/auth/register `
-H "Content-Type: application/json" `
-d '{\"email\":\"test@example.com\",\"password\":\"...\",\"organization_name\":\"Test\"}' `
-c cookies.txt -i
# 3. Auth check (cookie sent back)
curl https://api.<DOMAIN>/v1/me -b cookies.txt
To verify the connector is consuming jobs, in the DO console
connector → Runtime Logs
2After step 2's signup, create a channel via the API and watch the connector log lines for connect job receipt.
CI/CD: it's already done
¶ Every push to main builds, pushes, and auto-deploys
Full pipeline reference: deployment-workflow.md — job graph, the alembic gate, the :latest tag mechanics, release procedure, failure modes and recovery. Read that before your first release. The summary here is orientation only.
.github/workflows/deploy-do.yml builds the api + connector images and pushes them to DOCR. App Platform watches each component's :latest tag and auto-deploys on push; the migrate pre-deploy job runs first so migrations land before traffic shifts. No doctl deploy-trigger step is required.
Smart buildsThe workflow only builds the component whose files changed, gates both builds on an alembic upgrade head rehearsal against a throwaway Postgres, and rolls the connector before the api when both changed.
Branchesmain is the production line; day-to-day work and feature-branch PRs land on development (which does not auto-deploy). Releasing = merging development → main. The Vercel frontend follows the same model via its own GitHub Actions workflow (.github/workflows/deploy-web.yml, also on main).
RollbackRepoint a component's image tag to a known-good :<git-sha> in the App Platform dashboard and redeploy.
Nothing runs on pull requests. Neither workflow has a pull_request trigger, so a promote PR shows "Checks 0" and CI never blocks the merge — every gate fires after the merge lands on main.
After every rollout
¶ The post-deploy check: API up, WhatsApp lines intact
After an App Platform image rollout, the Actions workflow waits for the deployment to report ACTIVE, verifies the API answers /healthz, and compares the number of connected WhatsApp lines before and after the rollout, so a broken release is caught early.
¶ Baseline before the push
The workflow records the number of connected WhatsApp lines before any image is pushed, so the post-deploy comparison can detect connections dropped by the rollout. The baseline uses a read-only database login from the repository secret REPLYBOX_READONLY_DATABASE_URL, and the job fails early and loudly if that secret is missing so the check never passes vacuously.
¶ Six-minute reconnect grace
Fewer connected lines is only a failure after a 6-minute reconnect grace window.
¶ Visibility only
The job runs always() after builds and never blocks the App Platform rollout, which has already been applied. A failing check marks the GitHub run failed so staff see the red result immediately instead of discovering the problem later.
¶ When it runs
The job is gated to API and connector rollouts. Changes that touch only the workflow file, and therefore skip a build, also skip the post-deploy check.
apps/api deploys restart connectors — schedule them with the connector owner. Merging apps/api to main triggers an App Platform deployment that restarts connector processes, including the WhatsApp connector lines. Connectors generally reconnect automatically and recover full line counts, but the restart is a real interruption: coordinate API merges with the team that manages the WhatsApp connector to avoid unexpected customer impact.
Troubleshooting
¶ Symptom → likely cause
api deploy times outEither the build is slow (first deploy can take 5+ min) or healthcheck is failing. Check logs.
migrate fails with relation already existsThe DB was migrated by a previous deploy; alembic state is corrupted. Check alembic_version table.
Connector logs Multi-device error 401A second connector instance grabbed the session — confirm instance_count: 1
Socket.IO disconnects every ~60sApp Platform should handle WS upgrades automatically — verify the API actually mounts socket.io at /socket.io/
Spaces upload fails with SignatureDoesNotMatchWrong S3_REGION (must be sgp1) or S3_ENDPOINT (must include https://)
Cookies don't reach Vercel-hosted frontendCOOKIE_DOMAIN must be .<DOMAIN> with leading dot AND the API must be on api.<DOMAIN> (not the ondigitalocean.app URL)
Random 503s from apiApp Platform restarting the container; check Components → api → Insights for OOM kills. If yes, bump to Basic S ($12/mo, 1 GB RAM)
Out of scope
¶ What this guide doesn't cover
SOCKETIO_REDIS_URL)
Bull Board dashboardConnector mounts it only when ENV=development. The production connector is an internal Service with no public ingress, so :8787/admin/queues is neither mounted nor internet-reachable.
Vercel frontend deploymentCovered in deployment-vercel.md (TODO)
Quick reference
¶ Placeholders
<DOMAIN>Your domain
<your-suffix>Your bucket suffix from step 1a
SPACES_BUCKET / _ENDPOINT / _REGION / _ACCESS_KEY / _SECRET_KEYStep 1
DATABASE_URL_RAWStep 2b (then converted to asyncpg in step 5b)
REDIS_URL_RAWStep 3b
JWT_SECRET / INTERNAL_HMAC_SECRET / CHANNEL_SESSION_KEY / MEDIA_OBJECT_KEYStep 5a (generated)