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 networks —
webfor public-facing services,internalfor 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 images —
postgres:17-alpinevspostgres:17saves ~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.