DEV Community

Cover image for I Replaced $100/Month in SaaS Tools With a $5 VPS
Serdar Tekin
Serdar Tekin

Posted on

I Replaced $100/Month in SaaS Tools With a $5 VPS

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
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

Create .env:

DB_PASSWORD=your-strong-password-here
LISTMONK_DB_PASSWORD=another-strong-password-here
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Go to AWS Console → SES
  2. Verify your domain (add the DNS records AWS provides)
  3. Request production access (takes 24-48 hours)
  4. 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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
            }
          }
        }
      ]
    }
  }
]
Enter fullscreen mode Exit fullscreen mode

Run the webhook listener:

webhook -hooks /home/deploy/hooks.json -port 9001 -verbose
Enter fullscreen mode Exit fullscreen mode

(Run it as a systemd service so it survives reboots.)

Add to your Caddyfile:

hooks.yourdomain.com {
    reverse_proxy localhost:9001
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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)
  • [ ] .env not 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)
Email $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)