Setting up a VPS for production

Every time I set up a new VPS, I do the same dance: patch it, lock it down, deploy Docker, set up backups. This guide is the playbook — every step I actually run, in order.

0. Pre-flight

Before I SSH in for the first time, I always:

  • Set the hostname in the cloud console
  • Add my SSH key (not password!)
  • Note the public IPv4 and private IP (if VPC)
  • Create a DNS A record pointing to the public IP

1. First 60 seconds

apt update && apt upgrade -y
apt install -y ufw fail2ban unattended-upgrades curl wget git htop vim
timedatectl set-timezone Asia/Bangkok

Set timezone first. Logs mean nothing without correct timestamps.

2. Lock down SSH

# Disable root login + password auth
sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config

# Restart — keep your session open until you verify a new connection works
systemctl restart sshd

Don’t close your current session yet. Open a new terminal and verify you can SSH in with the key. If something breaks, the original session is your lifeline.

3. Firewall (UFW)

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw enable

4. fail2ban

Already installed — defaults are fine. Logs go to /var/log/fail2ban.log.

5. Automatic security updates

dpkg-reconfigure -plow unattended-upgrades
# Choose "Yes" when prompted

6. Install Docker

# Use the official install script
curl -fsSL https://get.docker.com | sh

# Add your user to docker group (logout/login required)
usermod -aG docker $USER

Verify:

docker run hello-world

7. Install Caddy (reverse proxy + auto HTTPS)

apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy

8. Automated backups (restic → B2)

apt install -y restic

# Configure repo
export B2_ACCOUNT_ID="..."
export B2_ACCOUNT_KEY="..."
restic -r b2:my-bucket:vps-snapshots init

# Cron: nightly at 3am
cat > /etc/cron.d/restic-backup <<'EOF'
0 3 * * * root /usr/local/bin/restic-backup.sh
EOF

The restic-backup.sh script backs up:

  • /etc
  • /home
  • /var/lib/docker/volumes (mounted read-only)
  • /srv (where my compose files live)

9. Monitoring (Netdata)

docker run -d --name netdata \
  --pid=host \
  -v /proc:/host/proc:ro \
  -v /sys:/host/sys:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  --restart unless-stopped \
  --cap-add SYS_PTRACE \
  --security-opt apparmor=unconfined \
  netdata/netdata

Dashboard at http://<ip>:19999. Lock down with Caddy + auth if exposing publicly.

10. Final checklist

  • SSH key-only login
  • Firewall enabled, only 22/80/443 open
  • fail2ban running
  • Unattended upgrades enabled
  • Docker working without sudo
  • Caddy installed
  • Backups configured + tested (do a test restore!)
  • Monitoring live
  • DNS pointing to the IP
  • Document the IP/role/tags somewhere (I keep a servers.md in a private repo)

What’s next

This guide will grow as I refine the playbook. PRs welcome.