DataStudios
ContactOS · Internal Ops

Blue/Green Architecture & Recovery Guide

How the ContactOS production redundancy is built, how it works, the hardening behind it, and exactly what to do when something goes wrong. Validated by game-day 2026-06-12

INTERNAL REFERENCE
docs/contactos/architecture/
Updated 2026-06-12
Owner: Diego Sanz
01

The architecture at a glance

Inbound · customers (WhatsApp) & agents (browser)
Cloudflare DNS
api / app .contactos.ai · A · TTL 60
DigitalOcean Reserved IP129.212.145.125 — remap = failover
↓  routes to exactly one active node
contactos-prod · BLUEACTIVE
104.131.3.106 · id 554189976 · s-2vcpu-4gb
traefikapiuiredis
⏱ Schedulers + outbound senders ON
contactos-prod-standby · GREENSTANDBY
167.172.16.133 · id 576964180 · s-2vcpu-4gb
traefikapiuiredis
⏱ Schedulers OFF — serves read/UI only
Both in nyc3, VPC 26781c18 · identical pinned image · NODE_ROLE decides who runs the timers · certs auto-sync active → standby
MongoDB Atlas M10 — DB contactos_prodcontactos.uqnqpph.mongodb.net · 3-node replica set · auto-failover · PITR + snapshots
02

How it works

The active-passive principle

Exactly one node is active at a time. DNS points at a Reserved IP, and the Reserved IP points at the active node. Both nodes always answer HTTP, but only the active one runs the background timers that send messages — so customers never get double-sent. Failover = remap the Reserved IP to the standby. No DNS change, no waiting on TTL.

Worker-gating (NODE_ROLE)

The api reads NODE_ROLE=active|standby. On standby it skips the two timer loops (CON-94 inactivity sender + stale-conversation cleanup) — the only paths that could double-fire, since inbound & broadcasts already reach one node only. Default is active, so a single node is unaffected. /status exposes the live role for monitoring.

Certificates across two nodes

The standby can't complete an HTTP-01 challenge without holding the IP, so acme-sync.sh runs on both nodes daily and the active node pushes its live Let's Encrypt acme.json to the standby. It self-gates on NODE_ROLE, so a promote flips the sync direction automatically.

Data tier

Both nodes share one Atlas M10 replica set (DB contactos_prod). A primary failure triggers an automatic election; the driver reconnects transparently — RPO ≈ 0, no app action. Continuous PITR + on-demand snapshots back it up.

03

Hardening we did

Immutable image tags

Deploys pin IMAGE_TAG=<sha> in the droplet .env → deterministic one-command rollback.

Least-priv DB creds

App user contactos_app (readWrite on the app DBs only); the over-privileged atlasAdmin user deleted; creds rotated.

Worker-gating · CON-132

Timer loops run on the active node only — no duplicate sends across two nodes.

Webhook idempotency · CON-133

Inbound WhatsApp deduped by message-id in Redis → Meta retries can't double-process.

Container limits + health

mem_limit on every container; UI healthcheck; DO uptime checks & down-alerts.

Atlas network lockdown

IP access list restricted to prod + standby + Hetzner; removed 0.0.0.0/0.

M10 data tier

Upgraded Flex → M10: 3-node replica set, auto-failover, PITR, termination protection.

SSH resilience

MaxStartups 100:30:200 on both nodes so the internet brute-force flood can't drop ops connections.

Backups

DO droplet backups + Atlas PITR + on-demand snapshots before any destructive change.

04

Proven in the game-day (2026-06-12)

ScenarioWhat we didResult
Planned flipPromote standby → remap Reserved IP → flip backPASS exactly-one-active throughout
Unplanned hard-killPowered off the active droplet, failed over to standbyPASS RTO ≈ 52s outage→recovery
Database failoverAtlas Test Failover while writing through the appPASS RPO ≈ 0, zero failed writes
Agent WebSocketLive session through a flipPASS auto-reconnect, no reload / re-login
05

Recovery guide — what to do when something breaks

Golden rule: exactly one node must report role:active. Two active = double-send risk (demote one). Two standby = silent outage (promote one). Check any time with: curl -s https://api.contactos.ai/status
🔴 The active node is down / unresponsive
Symptom: https://api.contactos.ai times out or 5xx; DO shows the active droplet down.

Fail over to the standby — one command (best-effort demote means a dead node doesn't block it):

# from a host with SSH to both droplets + a DO token
DO_TOKEN=<digitalocean token> scripts/contactos-promote.sh b   # promote standby (green)
# verify exactly one active, then bring the old node back later:
curl -s https://api.contactos.ai/status   # expect {"role":"active",...}

Manual equivalent if the script/token isn't handy: SSH the standby → set NODE_ROLE=active in backend/.envdocker compose -f docker-compose.prod.yml up -d --force-recreate api → remap the Reserved IP 129.212.145.125 to droplet 576964180 (DO console / doctl / MCP).

🔵 Planned maintenance / deploy to the standby first
When: you want zero-risk releases or to drain a node.
DO_TOKEN=<token> scripts/contactos-promote.sh b   # go green
# ...do the work on the now-idle blue node, smoke-test...
DO_TOKEN=<token> scripts/contactos-promote.sh a   # back to blue

Tip: for a planned flip, the script demotes-first to guarantee no double-send (a brief serving gap while the new active's api recreates). To avoid the gap, promote the new node to healthy before remapping the IP.

🟢 Database (Atlas) primary failed
Symptom: a brief write blip; Atlas shows an election.

Nothing to do — the M10 replica set elects a new primary and the driver reconnects automatically (RPO ≈ 0). Confirm the app is healthy: curl -s https://api.contactos.ai/health. Investigate the cause in the Atlas UI afterwards.

🟠 A bad deploy went out
Symptom: new build is broken in prod.

Roll back to a known-good immutable tag — no rebuild:

# re-run the Deploy workflow with image_tag set to a prior commit SHA,
# or the pre-launch snapshot:  prelaunch-2026-06-11
# or on the droplet directly:
ssh root@<active> "cd /opt/apps/datastudios-contactos && \
  sed -i 's/^IMAGE_TAG=.*/IMAGE_TAG=<good-sha>/' .env && \
  docker compose -f docker-compose.prod.yml up -d"
🟠 The standby is serving a bad / expired TLS cert
Symptom: cert warning when curling the standby by IP; standby traefik re-challenging.

Re-sync the live cert from the active node (the live volume is traefik_traefik-letsencrypt, not the stale bare traefik-letsencrypt):

ssh root@<active-node> /root/acme-sync.sh   # pushes acme.json + restarts standby traefik
🔵 SSH won't connect — "Connection closed by … port 22"
Cause: internet brute-force flood occasionally trips sshd even after the MaxStartups raise.

Use the Hetzner bastion (static IP, never flooded):

ssh -J root@hetzner-devbox root@<droplet-ip>  '<command>'
🔴 Data was lost / corrupted in the database
Symptom: wrong or missing data in contactos_prod.

Restore from Atlas — PITR (any second in the retention window) or an on-demand snapshot, via the Atlas UI/API. For a selective recovery (one collection) without reverting everything: restore the snapshot to a temporary cluster, export the wanted collections, re-import into contactos_prod.

06

Key facts & references

Identifiers

Reserved IP
129.212.145.125
Active (blue)
contactos-prod · 104.131.3.106 · 554189976
Standby (green)
contactos-prod-standby · 167.172.16.133 · 576964180
Region / VPC
nyc3 · 26781c18
Atlas
contactos.uqnqpph.mongodb.net · DB contactos_prod (M10)
App dir
/opt/apps/datastudios-contactos

Where things live

Promote script
scripts/contactos-promote.sh
Failover runbook
docs/contactos/guides/failover-runbook.md
Cert sync
/root/acme-sync.sh (both nodes, daily cron)
Role flag
NODE_ROLE in backend/.env · live at /status
Image pin
IMAGE_TAG in the project .env
Full plan
docs/contactos/plans/contactos-production-readiness-2026-06-10.md