Setting Up a Production VPS — My Stack and Why
A walkthrough of how I set up my VPS for Certeli and CV Human: Caddy, PM2, Cloudflare, PostgreSQL, and zero-downtime deployments.
The Stack
After building on Vercel and Supabase for a while, I needed something more hands-on for the larger platforms. Here's what I settled on:
| Component | Choice |
|---|---|
| OS | Ubuntu 24.04 |
| Web server | Caddy (auto-TLS) |
| Process manager | PM2 |
| Reverse proxy | Cloudflare (CDN + DDoS) |
| Database | PostgreSQL 16 (self-hosted) |
| CI/CD | GitHub Actions self-hosted runner |
Caddy
Caddy handles TLS automatically via Let's Encrypt. The configuration is clean — a few dozen lines for each domain. No certbot dance, no nginx config gymnastics.
PM2
Simple, battle-tested process management. I run:
- Next.js apps on :3001
- FastAPI backend on :8000
- A tcp-proxy for zero-downtime deploys
Database
Self-hosted PostgreSQL 16. Backups are automated via cron. The connection is local-only (no exposed port), so the database isn't accessible outside the VPS.
Deployment Flow
Push to main → GitHub Actions picks it up on the self-hosted runner → builds → swaps traffic via tcp-proxy → done. No downtime.
Things I'd Do Differently
I'd automate the initial server setup with Ansible or a script. Setting up PostgreSQL, Caddy, PM2, and the deploy runner manually works, but it's not repeatable.