Journal

Self-Hosting Your Web Stack on a $5 VPS

Cloud platforms are convenient, but they’re also expensive and opaque. A $5/month VPS gives you a full Linux server with 1GB of RAM and 25GB of storage — enough to run multiple web apps, databases, and services. Here’s how to set it up properly.

Choosing a Provider

Any reputable VPS provider works. The main considerations:

  • Location — pick a datacenter close to your users
  • Network — look for generous bandwidth (1TB+ transfer)
  • Snapshots — free or cheap server snapshots for disaster recovery

Provision an Ubuntu 24.04 LTS instance. The LTS designation means five years of security updates.

Initial Server Setup

After your first SSH login as root:

# Create a non-root user
adduser deploy
usermod -aG sudo deploy

# Set up SSH keys
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh

# Disable root login
sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart sshd

Set up the firewall:

ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Docker Compose: The Foundation

Docker Compose lets you define your entire stack in a single file. Install Docker:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker deploy

A typical docker-compose.yml for a web app with a database:

services:
  app:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/myapp
    volumes:
      - ./storage:/app/storage
    networks:
      - web
      - internal

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - internal

  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

volumes:
  pgdata:
  caddy_data:
  caddy_config:

networks:
  web:
  internal:

Key design decisions:

  • Two networksweb for public-facing services, internal for database access. The database is never exposed to the internet.
  • Named volumes — persist data across container rebuilds
  • restart: unless-stopped — auto-restart on crash or reboot

Caddy as Reverse Proxy

The Caddyfile for multiple sites:

myapp.com {
    reverse_proxy app:3000
}

blog.myapp.com {
    root * /srv/blog
    file_server
}

Caddy handles TLS certificates automatically for both domains. No certbot, no cron jobs, no renewal scripts.

Automated Backups

Create a backup script at /opt/scripts/backup.sh:

#!/bin/bash
set -euo pipefail

BACKUP_DIR="/var/backups/myapp"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=30

mkdir -p "$BACKUP_DIR"

# Database backup
docker compose -f /home/deploy/myapp/docker-compose.yml \
    exec -T db pg_dump -U app myapp | gzip > "$BACKUP_DIR/db_$DATE.sql.gz"

# Application files
tar czf "$BACKUP_DIR/files_$DATE.tar.gz" /home/deploy/myapp/storage/

# Clean old backups
find "$BACKUP_DIR" -type f -mtime +$RETENTION_DAYS -delete

echo "Backup completed: $DATE"

Schedule it with a systemd timer:

# /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

For offsite backups, sync to an object storage provider:

# Add to the backup script
rclone sync "$BACKUP_DIR" remote:myapp-backups --max-age 30d

Simple CI/CD

For small projects, a webhook-triggered deploy script works well. Create a lightweight deployment endpoint:

#!/bin/bash
# /opt/scripts/deploy.sh
set -euo pipefail

cd /home/deploy/myapp
git pull origin main
docker compose build --no-cache app
docker compose up -d app

echo "Deployed at $(date)"

Trigger it from your Git provider’s webhook, or use a simple approach with a cron pull:

# Crontab: check for changes every 5 minutes
*/5 * * * * cd /home/deploy/myapp && git fetch && [ $(git rev-parse HEAD) != $(git rev-parse @{u}) ] && /opt/scripts/deploy.sh >> /var/log/deploy.log 2>&1

For more sophisticated setups, Gitea + Woodpecker CI runs entirely self-hosted with minimal resources.

Monitoring

You don’t need Datadog for a $5 VPS. Simple tools work:

# Live resource usage
htop

# Disk usage
df -h

# Docker container stats
docker stats

# Check logs
journalctl -u docker -f
docker compose logs -f app

For alerts, a lightweight monitoring tool like Uptime Kuma runs as a single Docker container and monitors your services with notifications via email, Slack, or Telegram.

Resource Management on 1GB RAM

Memory is your bottleneck. Some tips:

  • Use Alpine-based imagespostgres:17-alpine vs postgres:17 saves ~600MB of disk and reduces memory overhead
  • Set memory limits per container in compose
  • Use swap as a safety net: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
  • Run one database and share it across apps instead of one per project
  • Consider SQLite for low-traffic apps — zero memory overhead

What You Can Run on $5/month

With careful resource management, a single $5 VPS comfortably handles:

  • 2-3 low-traffic web applications
  • A PostgreSQL or MySQL database
  • A reverse proxy with automatic TLS
  • Automated backups
  • A monitoring dashboard

When you outgrow it, upgrading is usually a click away. But you’d be surprised how far a single well-configured server takes you.