Setting Up a VPS from Scratch: The Missing Guide
Every month, someone posts "I bought a VPS, now what?" on Hacker News. The answers are fragmented across ten blog posts, three Stack Overflow threads, and a dated DigitalOcean tutorial.
Here's the entire workflow I follow, end to end.
1. Initial SSH Hardening
First thing after receiving the root password:
# Create your user
adduser ubuntu
usermod -aG sudo ubuntu
# Copy your SSH key
mkdir -p /home/ubuntu/.ssh
cp /root/.ssh/authorized_keys /home/ubuntu/.ssh/
chown -R ubuntu:ubuntu /home/ubuntu/.ssh
# Test in another terminal, then disable root login
sudo sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl restart sshd
The most common mistake is skipping the second-terminal test. If you didn't copy the key correctly, you're locked out of a root-only server.
2. Firewall Basics
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow http
sudo ufw allow https
sudo ufw enable
UFW is simple enough that there's no excuse not to use it. I've seen too many boxes running with all ports open because "the cloud firewall handles it."
3. Nginx as Reverse Proxy
The pattern for every web service on the VPS:
server {
listen 80;
server_name abzaek.dev;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name abzaek.dev;
ssl_certificate /etc/letsencrypt/live/abzaek.dev/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/abzaek.dev/privkey.pem;
location / {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The critical detail: proxy_set_header Host $host. Without it, your Next.js app sees all requests as localhost:3001 and breaks absolute URL generation.
4. Systemd Service Files
Running npx next start in a tmux session is not a deployment strategy.
[Unit]
Description=Portfolio Next.js
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/apps/portf
ExecStart=/usr/bin/npx next start -p 3001
Restart=always
RestartSec=5
Environment=NODE_ENV=production
Environment=NEXT_PUBLIC_SITE_URL=https://abzaek.dev
[Install]
WantedBy=multi-user.target
The three things that always trip me up:
Restart=alwayscatches crashes,RestartSec=5prevents restart loops from burning CPU- Environment variables go in the service file, not in a
.envthat might not be sourced - The
WorkingDirectorymust be the project root, not the binary location
5. Certificates with Let's Encrypt
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d abzaek.dev
Add a cron job for renewal:
echo "0 3 * * * /usr/bin/certbot renew --quiet" | sudo crontab -
6. Monitoring Essentials
At minimum:
htopfor resource usagejournalctl -u portf -ffor service logsdf -hto watch disk space (Docker logs eat surprising amounts)- A cron job that pings your endpoints and alerts if they return non-200
The Philosophy
A VPS is not harder than a managed platform. It's different. You trade deployment convenience for full control over the stack. For side projects and portfolios, this trade is almost always worth it — you learn more in one afternoon of nginx debugging than a month of clicking through a dashboard.