Everyone tells you to use Vercel, PlanetScale, and Mailchimp. Nobody tells you to add up the bill first.
I did:
| Service | Monthly Cost |
|---|---|
| Vercel Pro | $20/mo per seat |
| PlanetScale (HA) | $30/mo |
| Clerk Pro (auth) | $25/mo |
| Mailchimp Standard | $20/mo (500 contacts, ~$45 at 2,500) |
| Total | $95-120/mo |
That's over $1,000/year before your first customer.
I replaced all of it with one VPS, Docker, and open-source tools. Same capabilities. ~$6-11/month.
No philosophy. No Kubernetes. Here's the actual setup.
What You'll Have at the End
- Next.js app running in production
- PostgreSQL database
- Listmonk + Amazon SES for emails ($0.10 per 1,000 emails — yes, really)
- Automatic HTTPS via Caddy (uses Let's Encrypt under the hood)
- Automated daily backups
- Auto-deploy on
git push
Total cost: ~$6-11/month (VPS + SES usage)
Step 1: Pick a VPS
You need: Ubuntu 22.04/24.04 LTS, 2 vCPU, 2GB+ RAM, NVMe storage, location near your users.
Recommended providers:
Raff Technologies — US-based, AMD EPYC processors, NVMe standard. 40-60% cheaper than DigitalOcean. Great for US/LATAM users. ($5-10/mo)
Hetzner — Unbeatable European pricing. Best for EU-based users. (€4-7/mo)
Pick based on where your users are. Don't overthink it.
Step 2: Server Setup (5 Minutes)
SSH into your Ubuntu server and run:
# Update system
apt update && apt upgrade -y
# Create deploy user
adduser deploy
usermod -aG sudo deploy
# Firewall
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
# Install Docker
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker deploy
# Install Git
apt install git -y
Point your domain A record to the server IP. Done.
Step 3: Docker Compose — The Entire Stack
One file. Six services. Everything you need.
Create docker-compose.yml:
version: '3.8'
services:
# Your Next.js app
app:
build: .
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://app:${DB_PASSWORD}@db:5432/app
- NODE_ENV=production
depends_on:
db:
condition: service_healthy
networks:
- web
- internal
# PostgreSQL
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=app
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 5s
retries: 5
# Listmonk (email campaigns + transactional)
listmonk:
image: listmonk/listmonk:latest
restart: unless-stopped
environment:
- TZ=UTC
volumes:
- ./listmonk/config.toml:/listmonk/config.toml
depends_on:
listmonk_db:
condition: service_healthy
networks:
- web
- internal
# Listmonk needs its own PostgreSQL
listmonk_db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=listmonk
- POSTGRES_PASSWORD=${LISTMONK_DB_PASSWORD}
- POSTGRES_DB=listmonk
volumes:
- listmonk_data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U listmonk"]
interval: 5s
timeout: 5s
retries: 5
# Caddy (reverse proxy + auto HTTPS via Let's Encrypt)
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- web
# Automated backups (every 6 hours)
backup:
image: postgres:16-alpine
restart: unless-stopped
environment:
- PGPASSWORD=${DB_PASSWORD}
- LISTMONK_PGPASSWORD=${LISTMONK_DB_PASSWORD}
volumes:
- ./backup.sh:/backup.sh:ro
- backup_data:/backups
entrypoint: ["/bin/sh", "-c", "while true; do sh /backup.sh; sleep 21600; done"]
networks:
- internal
volumes:
postgres_data:
listmonk_data:
caddy_data:
caddy_config:
backup_data:
networks:
web:
internal:
Create .env:
DB_PASSWORD=your-strong-password-here
LISTMONK_DB_PASSWORD=another-strong-password-here
Add .env to .gitignore. Never commit secrets.
Step 4: Caddy — Automatic HTTPS via Let's Encrypt
No certbot. No cron jobs. No nginx config files. Caddy handles it all.
Create Caddyfile:
yourdomain.com {
reverse_proxy app:3000
encode gzip
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
}
mail.yourdomain.com {
reverse_proxy listmonk:9000
}
What Caddy does automatically:
- Obtains SSL certificates from Let's Encrypt
- Renews them before expiry (no cron needed)
- Redirects HTTP → HTTPS
- Enables gzip compression
If you've ever fought with certbot + nginx, this alone is worth the switch.
Step 5: Listmonk + Amazon SES — Emails for Pennies
This is the part that saves the most money.
Mailchimp Standard: $20/mo for 500 contacts (jumps to ~$45 at 2,500 contacts)
Listmonk + Amazon SES: ~$1/mo for 10,000 emails
Why Amazon SES and Not Just Any SMTP?
You could technically connect Listmonk to any SMTP provider. But deliverability is everything. If your emails land in spam, it doesn't matter how cheap they are.
Amazon SES gives you:
- High deliverability backed by AWS infrastructure
- Built-in DKIM, SPF, and DMARC support
- Reputation monitoring dashboard
- $0.10 per 1,000 emails ($1 for 10,000 emails)
- Free tier: 3,000 emails/month for the first 12 months
A random SMTP server or self-hosted mail server will get your emails flagged as spam. SES handles IP reputation, authentication, and bounce management properly — that's what you're paying the $0.10/1,000 for. Worth every fraction of a cent.
Amazon SES Setup
- Go to AWS Console → SES
- Verify your domain (add the DNS records AWS provides)
- Request production access (takes 24-48 hours)
- Create SMTP credentials under Account Dashboard → SMTP Settings
Listmonk Config
Create listmonk/config.toml:
[app]
address = "0.0.0.0:9000"
admin_username = "admin"
admin_password = "your-admin-password"
[db]
host = "listmonk_db"
port = 5432
user = "listmonk"
password = "your-listmonk-db-password"
database = "listmonk"
ssl_mode = "disable"
After starting the stack, configure SES as your SMTP provider in Listmonk's admin panel:
-
Host:
email-smtp.us-east-1.amazonaws.com(use your SES region) - Port: 587
- Auth protocol: PLAIN
- Username/Password: Your SES SMTP credentials
- TLS: STARTTLS
Now you have campaign emails, transactional emails, and subscriber management with analytics. Self-hosted. For pennies.
Step 6: Backups
Create backup.sh:
#!/bin/bash
set -e
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups"
# Backup app database
pg_dump -h db -U app -d app -Fc > "$BACKUP_DIR/app_$TIMESTAMP.dump"
# Backup listmonk database
PGPASSWORD=$LISTMONK_PGPASSWORD pg_dump -h listmonk_db -U listmonk -d listmonk -Fc > "$BACKUP_DIR/listmonk_$TIMESTAMP.dump"
# Clean backups older than 30 days
find "$BACKUP_DIR" -name "*.dump" -mtime +30 -delete
echo "Backup done: $TIMESTAMP"
Test a Restore (Do This Once)
docker exec -it db createdb -U app app_test
docker exec -i db pg_restore -U app -d app_test < app_20250201_120000.dump
docker exec -it db psql -U app -d app_test -c "SELECT COUNT(*) FROM users;"
docker exec -it db dropdb -U app app_test
If you haven't tested a restore, you don't have backups. You have hopes.
Step 7: Auto-Deploy on Git Push
Forget running ssh deploy@server manually every time. Set up a GitHub Webhook so your server pulls and deploys automatically when you push to main.
Option A: Lightweight Webhook Listener (Recommended)
Install webhook on your Ubuntu server:
sudo apt install webhook -y
Create /home/deploy/hooks.json:
[
{
"id": "deploy",
"execute-command": "/home/deploy/app/deploy.sh",
"command-working-directory": "/home/deploy/app",
"trigger-rule": {
"and": [
{
"match": {
"type": "payload-hash-sha256",
"secret": "your-webhook-secret",
"parameter": {
"source": "header",
"name": "X-Hub-Signature-256"
}
}
},
{
"match": {
"type": "value",
"value": "refs/heads/main",
"parameter": {
"source": "payload",
"name": "ref"
}
}
}
]
}
}
]
Run the webhook listener:
webhook -hooks /home/deploy/hooks.json -port 9001 -verbose
(Run it as a systemd service so it survives reboots.)
Add to your Caddyfile:
hooks.yourdomain.com {
reverse_proxy localhost:9001
}
Then in GitHub → Settings → Webhooks:
-
Payload URL:
https://hooks.yourdomain.com/hooks/deploy -
Content type:
application/json -
Secret: same secret from
hooks.json - Events: Just the push event
Option B: Simple Cron Pull
If webhooks feel like overkill, just poll:
# crontab -e
*/5 * * * * cd /home/deploy/app && git fetch origin main && [ $(git rev-parse HEAD) != $(git rev-parse origin/main) ] && bash deploy.sh >> /var/log/deploy.log 2>&1
Checks every 5 minutes. Not instant, but dead simple.
The Deploy Script
Either way, deploy.sh stays the same:
#!/bin/bash
set -e
cd /home/deploy/app
git pull origin main
docker compose build app
docker compose run --rm app npm run db:migrate
docker compose up -d --no-deps app
docker image prune -f
echo "Deployed at $(date)"
Push to main → server builds and deploys automatically. No GitHub Actions YAML to debug.
Step 8: Production Checklist
- [ ] Ubuntu 22.04/24.04 LTS fully updated
- [ ] HTTPS working (Caddy + Let's Encrypt)
- [ ]
.envnot in git - [ ] Firewall active (
ufw status) - [ ] PostgreSQL healthcheck passing
- [ ] Listmonk admin panel accessible
- [ ] SES domain verified + production access granted
- [ ] Backup container running (
docker logs backup) - [ ] Tested a restore manually
- [ ] Webhook or cron deploy working
- [ ] Health endpoint exists (
/api/health)
The Real Cost Comparison
| Managed Stack | Self-Hosted VPS | |
|---|---|---|
| Hosting | $20 (Vercel Pro) | $5-10 (VPS) |
| Database | $30 (PlanetScale HA) | $0 (PostgreSQL, included) |
| $20-45 (Mailchimp) | ~$1 (Listmonk + SES) | |
| Auth | $25 (Clerk Pro) | $0 (self-hosted) |
| Monthly | $95-120 | $6-11 |
| Yearly | $1,140-1,440 | $72-132 |
Tools Summary
Paid Services
| Tool | Purpose | Cost |
|---|---|---|
| Raff Technologies | VPS hosting (US-based, AMD EPYC, NVMe) | $5-10/mo |
| Amazon SES | Email delivery (SMTP for Listmonk) | $0.10/1,000 emails |
Open-Source / Free
| Tool | Purpose | Replaces |
|---|---|---|
| Docker | Containerization | — |
| Next.js | Web framework | — |
| PostgreSQL | Database | PlanetScale ($30/mo) |
| Caddy | Reverse proxy + auto HTTPS (Let's Encrypt) | nginx + certbot |
| Listmonk | Email campaigns + transactional | Mailchimp ($20-45/mo) |
| webhook | Auto-deploy on git push | GitHub Actions / Vercel CI |
| Ubuntu 22.04/24.04 LTS | Operating system | — |
Two paid services. Everything else is free and open-source. Total: ~$6-11/month.
When This Stops Being Enough
- You need multi-region high availability
- Database needs dedicated resources
- Compliance requires managed services with audit logs
- You'd rather pay than manage infrastructure
Upgrade when reality demands it, not because a tutorial told you to.
If you're looking for affordable VPS to try this setup — Raff Technologies for US/LATAM users and Hetzner for Europe.
Top comments (0)