The VPS layout
One VPS. One Postgres. One Redis. Two sibling processes (Asterisk for SIP, Go for SMS). nginx terminates TLS. PM2 supervises the Node and Python services. UFW blocks everything that doesn’t need to be open.Why VPS, not Kubernetes
The first question every infrastructure conversation hits.
The decision is reversible. The day Guild crosses the threshold where one host can’t hold the daily traffic, we add a second host behind a load balancer in front of nginx. The application is already stateless; the move is mechanical.
CloudPanel
CloudPanel is a free, open-source server administration panel for Ubuntu. We use it for three concrete things.1. nginx vhost templating
Every vhost is rendered from a CloudPanel template with substitution placeholders. The reference vhost lives inbackend/infra/nginx.conf (checked in for review). CloudPanel substitutes {{ssl_certificate}}, {{ssl_certificate_key}}, {{nginx_access_log}}, and {{app_port}} at render time.
After editing, the change is reloaded with nginx -t && systemctl reload nginx (or CloudPanel’s UI reload button). Zero downtime. Active connections drain.
2. Automatic certificate management
Cloudflare DNS validates ownership; CloudPanel renews Origin Certs and Let’s Encrypt certs automatically. No 3 AM page about an expired cert.3. Audit trail of system changes
Every config change goes through CloudPanel’s audit log. Useful when something stops working at 2 PM and we need to know what changed at 1:55 PM. We don’t use CloudPanel for application deployment — that’s PM2’s job. We use it strictly for the system-edge concerns (nginx, certs, firewall, system users).nginx configuration
The full vhost lives atbackend/infra/nginx.conf. Three things in it worth highlighting.
Loopback-only webhook routes
Each webhook destination is an exact-matchlocation block routed to the specific service’s loopback port.
proxy_request_buffering off is non-negotiable
Webhooks sign the raw request body. If nginx buffers and re-concatenates the body, the bytes change and signature verification fails. Setting proxy_request_buffering off makes nginx stream the body through without touching it.
This was a real bug we hit. Diagnosed by re-reading OPay’s signature documentation, then re-reading nginx’s buffering documentation. The fix is one line. Knowing to write that line came from understanding both sides.
Everything else → core-api
The catch-alllocation / block routes anything not webhook-specific to core-api. The proxy timeouts are generous (900s) because some endpoints (file uploads, batch operations) legitimately take longer than the default 60s.
PM2 ecosystem
Every service runs under PM2 with explicit configuration inecosystem.config.cjs.
Graceful reload
pm2 reload <service> rolls services one at a time. The new process boots, passes health checks, then PM2 stops the old one. Open connections drain. No interruption visible to users.
A full deploy (all services) takes about 90 seconds end to end.
Process supervision
PM2 restarts a crashed process automatically.max_memory_restart triggers a restart if memory exceeds the threshold (defends against slow leaks). Logs go to PM2’s log directory, rotated daily, archived to R2 after 7 days.
System services
Postgres 16 + PostGIS
Installed via apt. PostGIS gives us proper geographic queries (distance between city centroids, point-in-polygon for job location matching). Six schemas, one database:
Cross-schema joins are cheap inside one cluster. Each service has its own Prisma schema file declaring
@@schema(...). Migrations are owned by the service.
pgbouncer (transaction pool mode)
Connection pooling between application services and Postgres. Transaction mode means a connection from the pool is held only for the duration of one transaction; Prisma’s connection handling plays well with this. Reduces Postgres’s connection-handling overhead at scale.Redis
One Redis instance. Used for:- Sessions (key prefix
sess:) - OTPs (key prefix
otp:, short TTL) - Idempotency cache (key prefix
idem:) - BullMQ queues (key prefix
bull:) - Bandit state (key prefix
ml:bandit:) - Read-only OPay cache (key prefix
sq:cache:, used by circuit-breaker fallback)
asterisk
Sibling process. Listens on TLS 5061 for SIP traffic. Bridges WhatsApp Calling to voice-svc. Full deep dive at SIP infrastructure.sms-server (Go)
Sibling process. RESTful API on a loopback port. Bridges sms-svc to paired Android devices with Nigerian SIMs. Full deep dive at SMS infrastructure.ufw
Default-deny firewall.Cloudflare R2
Object storage for everything that doesn’t belong in Postgres.
Egress-free bandwidth to Cloudflare-served viewers — this is the cost advantage that pushed us away from S3 at our access patterns.
DNS and certs
Cloudflare is authoritative DNS. Six A records on Cloudflare orange-cloud mode:guild.com.ng→ VPS IP (Next.js frontend via the BFF)api.guild.com.ng→ VPS IP (core-api via nginx)wa.guild.com.ng→ VPS IP (loopback to whatsapp-svc via nginx)sip.guild.com.ng→ VPS IP (Asterisk directly on 5061)ml.guild.com.ng→ VPS IP (loopback to ml-service via nginx)docs.guild.com.ng→ Mintlify-hosted (CNAME)
The deploy pipeline
Capistrano-style symlink release.~/releases/<commit-sha>/ holds the new build; ~/current/ symlinks to the active release. A bad deploy is rolled back by changing the symlink — ~/releases/ keeps the last five releases.
pm2 reload triggers the rolling restart described earlier. No request loss during deploy.
Total time from push to live: ~5 minutes for the full pipeline.
Backup and disaster recovery
The RTO objective for a worst-case (entire host gone) is under one hour: provision new host, restore database from R2, restore Redis snapshot, redeploy the application from git, restore secrets, point DNS. We test this once a quarter.
Observability summary
(Deeper detail in Observability.)- Logs: Pino JSON, request-ID propagated through every log line, PII-redacted at the logger layer. Shipped to a central log store.
- Errors: Sentry. Every uncaught exception surfaces; per-environment quotas.
- Metrics: Prometheus exporter via
@guild/observability. Scrape-able for any dashboard tool. - Health checks:
/healthz(liveness),/readyz(readiness, includes downstream checks). - Audit: the audit ledger itself doubles as a compliance log. Every state change writes a row.
Cost picture
At the current scale (pre-launch), the entire infrastructure stack runs under a few hundred dollars per month. The cost curve scales with daily active users and OPay transaction volume. Cloudflare bandwidth is the rare cost item — R2 egress is free to Cloudflare-served viewers, and Cloudflare’s bandwidth from origin to user is included. The cost line that will move first: OPay transaction fees. They scale with revenue, which is the right direction for a payment platform.What to read next
SMS infrastructure
The Go gateway. The paired Android devices. The two-way SMS rail.
SIP infrastructure
Asterisk. WhatsApp Calling. Opus to PCMU transcoding.
Inter-service comms
The HMAC layer that signs every internal call.
Resilience & scale
Circuit breakers, demo resilience, the partition path.