Learn how to set up a VPS from scratch, secure it, deploy your apps, and self-host services using tools like Coolify, Caddy, and Docker. Stop paying $20/month for Vercel Pro when a $5 VPS can do it all.
Every time you deploy to Vercel, Netlify, or Railway, you're using someone else's server. Self-hosting means running your own server and controlling the entire stack. Here's why that matters:
| Managed Platforms | Self-Hosting |
|---|---|
| Easy to start, expensive to scale | Small learning curve, cheap forever |
| Vendor lock-in (proprietary configs) | Standard Linux -- works anywhere |
| Limited control over infra | Full root access, install anything |
| Bandwidth/build limits on free tiers | No artificial limits |
| $20+/month for Pro features | $4-6/month for a full VPS |
Self-host when you want to learn infrastructure, save money at scale, host multiple projects on one server, or need full control. Use managed platforms for quick prototypes, when you have zero time for ops, or when your team has no Linux experience and can't afford downtime.
Self-hosting teaches you Linux, networking, Docker, DNS, security, and deployment -- skills that make you a significantly more capable developer and a stronger hire.
A VPS (Virtual Private Server) is a virtual machine running on shared hardware. You get root access to your own isolated Linux environment. Here are the best options:
| Provider | Cheapest Plan | Strengths | Best For |
|---|---|---|---|
| Hetzner | ~$4/mo (2 vCPU, 4GB RAM) | Best price-to-performance ratio, EU data centers, great ARM options | Most self-hosters, best value |
| DigitalOcean | $6/mo (1 vCPU, 1GB RAM) | Excellent docs, simple UI, large community | Beginners, good tutorials |
| Linode (Akamai) | $5/mo (1 vCPU, 1GB RAM) | Reliable, good support, competitive pricing | General purpose |
| Vultr | $6/mo (1 vCPU, 1GB RAM) | Many locations, bare metal options | Global coverage |
| Oracle Cloud | Free tier (4 ARM cores, 24GB RAM) | Insanely generous free tier | Learning/experimenting (unreliable availability) |
Start with Hetzner. Their CX22 (2 vCPU, 4GB RAM, 40GB SSD) at ~$4/month is unbeatable. Choose Ubuntu 24.04 LTS as your OS. Pick the closest data center to your users (Falkenstein or Helsinki for EU, Ashburn for US East).
You just created a VPS. Here's the step-by-step to go from a fresh Ubuntu install to a secure, ready-to-deploy server.
Your provider gives you a root password or lets you add an SSH key during creation. Connect for the first time:
# Replace with your server's IP address
ssh root@YOUR_SERVER_IP
apt update && apt upgrade -y
Never run everything as root. Create a regular user with sudo privileges:
# Create user
adduser deploy
# Add to sudo group
usermod -aG sudo deploy
hostnamectl set-hostname myserver
timedatectl set-timezone UTC
Always set servers to UTC. Your logs, cron jobs, and timestamps stay consistent regardless of where you or your users are. Convert to local time in your application layer.
apt install -y curl wget git unzip htop ufw fail2ban
Passwords are weak. SSH keys are cryptographic key pairs -- a private key (stays on your machine) and a public key (goes on the server). The server can verify you hold the private key without it ever crossing the network.
# Generate Ed25519 key (recommended over RSA)
ssh-keygen -t ed25519 -C "your@email.com"
# This creates:
# ~/.ssh/id_ed25519 (private key -- NEVER share this)
# ~/.ssh/id_ed25519.pub (public key -- goes on server)
# Method 1: ssh-copy-id (easiest)
ssh-copy-id deploy@YOUR_SERVER_IP
# Method 2: Manual
cat ~/.ssh/id_ed25519.pub | ssh root@YOUR_SERVER_IP "mkdir -p /home/deploy/.ssh && cat >> /home/deploy/.ssh/authorized_keys"
Edit the SSH config to disable password login and root login:
sudo nano /etc/ssh/sshd_config
Find and change these lines:
# Disable password authentication
PasswordAuthentication no
# Disable root login
PermitRootLogin no
# Optional: change default port (makes automated scanners miss you)
# Port 2222
Restart SSH:
sudo systemctl restart sshd
Before disabling password auth, open a second terminal and verify you can log in with your SSH key as the deploy user. If you disable passwords and your key doesn't work, you'll be locked out permanently.
Save connection details so you can type ssh myserver instead of the full command:
# ~/.ssh/config
Host myserver
HostName YOUR_SERVER_IP
User deploy
IdentityFile ~/.ssh/id_ed25519
# Port 2222 (if you changed the port)
Now just run:
ssh myserver
UFW is a frontend for iptables that makes firewall rules human-readable:
# Allow SSH (do this FIRST or you'll lock yourself out)
sudo ufw allow OpenSSH
# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Enable the firewall
sudo ufw enable
# Check status
sudo ufw status verbose
Status: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
Fail2Ban watches log files and bans IPs that show malicious signs (like brute-force SSH attempts):
# It's already installed from our earlier step
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Check banned IPs
sudo fail2ban-client status sshd
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
1. SSH key auth only (no passwords). 2. Non-root user with sudo. 3. UFW firewall enabled. 4. Fail2Ban running. 5. Auto security updates. 6. Keep software updated. This covers 95% of threats for a personal VPS.
DNS (Domain Name System) translates human-readable domain names to IP addresses. To point your domain at your VPS, you need to create DNS records.
| Registrar | Why |
|---|---|
| Cloudflare Registrar | At-cost pricing (no markup), free DNS, free proxy/CDN |
| Namecheap | Good prices, easy UI, free WhoisGuard |
| Porkbun | Cheapest for many TLDs, clean interface |
| Record Type | Name | Value | What It Does |
|---|---|---|---|
| A | @ | YOUR_SERVER_IP | Points yourdomain.com to your server |
| A | * | YOUR_SERVER_IP | Wildcard -- points *.yourdomain.com to your server |
| CNAME | www | yourdomain.com | Points www.yourdomain.com to root domain |
Type Name Content Proxy TTL
A @ 203.0.113.42 DNS only Auto
A * 203.0.113.42 DNS only Auto
CNAME www yourdomain.com DNS only Auto
Set proxy to "DNS only" (grey cloud) if you're handling SSL yourself with Caddy or Let's Encrypt. Use "Proxied" (orange cloud) if you want Cloudflare's CDN and DDoS protection.
# Check if your domain resolves to your IP
dig yourdomain.com +short
# Or use nslookup
nslookup yourdomain.com
DNS changes can take 5 minutes to 48 hours to propagate worldwide, but usually it's under 30 minutes. If you're using Cloudflare DNS, changes are nearly instant.
A reverse proxy sits in front of your applications and routes incoming requests to the right service. It handles SSL, load balancing, and lets you run multiple apps on one server.
Internet
│
▼
┌──────────────────┐
│ Reverse Proxy │ ← Listens on ports 80 & 443
│ (Caddy/Nginx) │
└──────┬───────────┘
│
┌───┼────────────────┐
▼ ▼ ▼
:3000 :8080 :5000
App 1 App 2 App 3
(Next) (API) (Flask)
app1.yourdomain.com → :3000
api.yourdomain.com → :8080
app3.yourdomain.com → :5000
Caddy is a modern web server that automatically handles SSL via Let's Encrypt. Zero config for HTTPS. This is the easiest option.
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
# /etc/caddy/Caddyfile
# App 1 -- Next.js running on port 3000
app1.yourdomain.com {
reverse_proxy localhost:3000
}
# App 2 -- API running on port 8080
api.yourdomain.com {
reverse_proxy localhost:8080
}
# Static site
yourdomain.com {
root * /var/www/mysite
file_server
}
That's it. Caddy automatically provisions SSL certificates. Reload with:
sudo systemctl reload caddy
Nginx is the industry standard. More config required, but maximum flexibility.
sudo apt install -y nginx
# /etc/nginx/sites-available/app1
server {
listen 80;
server_name app1.yourdomain.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
# Enable the site
sudo ln -s /etc/nginx/sites-available/app1 /etc/nginx/sites-enabled/
sudo nginx -t # Test config
sudo systemctl reload nginx
Use Caddy for personal projects and when you want zero SSL hassle. Use Nginx when you need advanced features (rate limiting, caching, complex rewrites) or when you're in a team that already uses it. Both are production-ready.
SSL/TLS encrypts traffic between your users and your server. Let's Encrypt provides free, auto-renewing SSL certificates.
Skip this section. Caddy handles SSL automatically -- it provisions and renews Let's Encrypt certificates for every domain in your Caddyfile with zero configuration.
# Install Certbot
sudo apt install -y certbot python3-certbot-nginx
# Get a certificate (Certbot auto-configures Nginx)
sudo certbot --nginx -d app1.yourdomain.com -d api.yourdomain.com
# Test auto-renewal
sudo certbot renew --dry-run
Certbot adds a cron job that automatically renews certificates before they expire (every 90 days).
# Check certificate details
curl -vI https://app1.yourdomain.com 2>&1 | grep -A5 "Server certificate"
# Or use openssl
openssl s_client -connect app1.yourdomain.com:443 -servername app1.yourdomain.com
Docker is the standard way to deploy applications on a VPS. Instead of installing dependencies directly on the server, you package everything into containers. Check the Docker page for fundamentals -- this section covers VPS-specific setup.
# Install Docker using the official convenience script
curl -fsSL https://get.docker.com | sh
# Add your user to the docker group (no sudo needed for docker commands)
sudo usermod -aG docker deploy
# Log out and back in for group changes to take effect
exit
ssh myserver
# Verify
docker --version
docker compose version
A typical production setup with Docker Compose:
# docker-compose.yml
services:
app:
build: .
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://user:pass@db:5432/myapp
depends_on:
- db
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=myapp
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
# Deploy
docker compose up -d
# View logs
docker compose logs -f app
# Update and redeploy
git pull
docker compose up -d --build
Never hardcode secrets in docker-compose.yml. Use a .env file (and add it to .gitignore) or Docker secrets. The example above uses inline values for clarity only.
Coolify is an open-source, self-hosted alternative to Vercel, Netlify, and Heroku. It gives you a web UI to deploy applications, databases, and services with automatic SSL, GitHub integration, and zero-downtime deployments -- all running on your own VPS.
Vercel / Netlify → Frontend deployments with preview URLs
Heroku / Railway → Backend app hosting with env vars
PlanetScale / Neon → Managed databases (Postgres, MySQL, Redis)
GitHub Actions → Build & deploy pipelines
All of this in ONE tool, on YOUR server, for $0/month in software costs.
Coolify has a one-line installer. Run this on a fresh VPS (or one where ports 80/443 aren't already in use):
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
After installation, access the Coolify dashboard at http://YOUR_SERVER_IP:8000. Set up your admin account on first visit.
| Type | Examples |
|---|---|
| Apps from Git | Next.js, Nuxt, SvelteKit, Express, Django, Rails, Go, Rust -- anything with a Dockerfile or Nixpacks |
| Docker Compose | Paste your docker-compose.yml and Coolify manages it |
| Databases | PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Dragonfly |
| One-Click Services | Plausible Analytics, Umami, Ghost, WordPress, Gitea, Minio, n8n, Uptime Kuma |
Minimum: 2 CPU cores, 2GB RAM. Recommended: 2+ CPU cores, 4GB+ RAM. Coolify itself uses Docker, Traefik (as its built-in reverse proxy), and a SQLite/PostgreSQL database. A Hetzner CX22 ($4/mo) is perfect for this.
| Tool | What It Does | When to Use |
|---|---|---|
| Coolify | Full PaaS -- deploy apps, databases, services from Git | You want a Vercel-like experience on your own server |
| Dokku | Mini Heroku -- Git push to deploy with buildpacks | You love Heroku's git push workflow |
| CapRover | PaaS with web UI, one-click apps, and cluster support | Alternative to Coolify with Docker Swarm support |
| Portainer | Docker management UI -- see containers, logs, networks | You want a visual way to manage Docker |
| Tool | What It Does |
|---|---|
| Uptime Kuma | Self-hosted uptime monitoring with notifications (Slack, Discord, email) |
| Plausible Analytics | Privacy-friendly Google Analytics alternative |
| Umami | Simple, fast, privacy-focused web analytics |
| Grafana + Prometheus | Full metrics collection and dashboard visualization |
| Tool | What It Does |
|---|---|
| Gitea / Forgejo | Self-hosted Git (lightweight GitHub alternative) |
| Minio | S3-compatible object storage |
| Vaultwarden | Self-hosted Bitwarden password manager |
| Traefik | Cloud-native reverse proxy with auto-discovery for Docker containers |
| n8n | Self-hosted workflow automation (Zapier alternative) |
| Ghost | Self-hosted blog/newsletter platform |
With Docker and a reverse proxy, you can run 10+ services on a single $4/month VPS. Each gets its own subdomain (e.g., git.yourdomain.com, analytics.yourdomain.com, status.yourdomain.com) and automatic SSL.
# Check disk usage
df -h
# Check memory usage
free -h
# Check CPU and processes
htop
# Check who's logged in
who
# Check system uptime
uptime
# View logs for a specific container
docker logs -f --tail 100 container_name
# View logs for all services in a compose stack
docker compose logs -f
# View logs with timestamps
docker compose logs -f -t
Deploy with a single Docker command:
docker run -d \
--name uptime-kuma \
--restart unless-stopped \
-p 3001:3001 \
-v uptime-kuma:/app/data \
louislam/uptime-kuma:1
Then point status.yourdomain.com at port 3001 via your reverse proxy. You get a status page and notifications for when your apps go down.
If it's not backed up, it doesn't exist. Here's a practical backup strategy for a VPS.
# PostgreSQL dump
docker exec my-postgres pg_dumpall -U postgres > backup_$(date +%Y%m%d).sql
# MySQL dump
docker exec my-mysql mysqldump -u root -p --all-databases > backup_$(date +%Y%m%d).sql
#!/bin/bash
# /home/deploy/backup.sh
BACKUP_DIR="/home/deploy/backups"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
# Dump PostgreSQL
docker exec postgres pg_dumpall -U postgres > "$BACKUP_DIR/db_$DATE.sql"
# Compress
gzip "$BACKUP_DIR/db_$DATE.sql"
# Delete backups older than 7 days
find "$BACKUP_DIR" -name "*.gz" -mtime +7 -delete
echo "Backup complete: db_$DATE.sql.gz"
# Make it executable
chmod +x /home/deploy/backup.sh
# Add to crontab (runs daily at 3 AM)
crontab -e
# Add this line:
0 3 * * * /home/deploy/backup.sh >> /home/deploy/backups/backup.log 2>&1
Local backups aren't enough -- if the VPS dies, so do your backups. Send them off-site:
# Sync to another server
rsync -avz /home/deploy/backups/ backup-user@other-server:/backups/
# Or upload to S3-compatible storage (Backblaze B2, Cloudflare R2, Minio)
# Using rclone:
rclone sync /home/deploy/backups/ remote:my-backups/
If you're using Coolify, it has built-in scheduled database backups to S3-compatible storage. Configure it in the Coolify dashboard under each database's settings -- no scripts needed.
Let's put it all together. You have a Next.js app on GitHub and a fresh VPS. Here's the manual approach (without Coolify).
# 1. SSH into your server
ssh myserver
# 2. Install Docker (if not already)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker deploy
exit && ssh myserver
# 3. Install Caddy
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy
# 4. Clone your repo
cd /home/deploy
git clone https://github.com/yourusername/my-nextjs-app.git
cd my-nextjs-app
# 5. Create a Dockerfile (if you don't have one)
cat << 'DOCKERFILE' > Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
DOCKERFILE
# 6. Create docker-compose.yml
cat << 'COMPOSE' > docker-compose.yml
services:
app:
build: .
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
COMPOSE
# 7. Build and start
docker compose up -d --build
# 8. Configure Caddy
sudo tee /etc/caddy/Caddyfile << 'CADDY'
myapp.yourdomain.com {
reverse_proxy localhost:3000
}
CADDY
sudo systemctl reload caddy
Your app is now live at https://myapp.yourdomain.com with automatic SSL.
# Create a deploy script
cat << 'DEPLOY' > /home/deploy/my-nextjs-app/deploy.sh
#!/bin/bash
cd /home/deploy/my-nextjs-app
git pull
docker compose up -d --build
echo "Deployed at $(date)"
DEPLOY
chmod +x /home/deploy/my-nextjs-app/deploy.sh
# Redeploy anytime:
./deploy.sh
The manual approach above is great for learning. But for day-to-day use, Coolify does all of this automatically -- connect your repo, set a domain, and push to deploy. No scripts, no manual Docker builds.
| Command | What It Does |
|---|---|
ssh myserver | Connect to your server |
sudo apt update && sudo apt upgrade -y | Update all packages |
df -h | Check disk space |
free -h | Check memory usage |
htop | Interactive process viewer |
sudo reboot | Restart the server |
uptime | Check how long server has been running |
| Command | What It Does |
|---|---|
sudo ufw status | Show firewall rules |
sudo ufw allow 80/tcp | Open a port |
sudo ufw deny 8080/tcp | Block a port |
sudo ufw delete allow 8080/tcp | Remove a rule |
| Command | What It Does |
|---|---|
docker compose up -d | Start all services in background |
docker compose up -d --build | Rebuild and restart |
docker compose logs -f | Follow logs for all services |
docker compose down | Stop all services |
docker system prune -af | Clean up unused images/containers |
docker stats | Live resource usage per container |
| Command | What It Does |
|---|---|
sudo systemctl reload caddy | Reload config without downtime |
sudo systemctl restart caddy | Full restart |
sudo caddy validate --config /etc/caddy/Caddyfile | Validate config syntax |
journalctl -u caddy -f | View Caddy logs |
| Command | What It Does |
|---|---|
dig yourdomain.com +short | Check DNS resolution |
sudo certbot --nginx -d domain.com | Get SSL cert for Nginx |
sudo certbot renew --dry-run | Test certificate renewal |
curl -I https://yourdomain.com | Check SSL headers |
Here's what you can run on a single Hetzner CX22 ($4/month):
Coolify → Deploy & manage everything
Next.js App → Your main web app
PostgreSQL → Database
Redis → Caching
Uptime Kuma → Monitoring
Plausible → Analytics
Vaultwarden → Password manager
Total cost: ~$4/month + ~$10/year for a domain
vs. Vercel Pro + PlanetScale + Railway = $60+/month