Locking down your server: a practical guide to VPS security

Locking down your server: a practical guide to VPS security

OpenClaw — formerly Moltbot & Clawdbot — has massively increased our need for reliable VPS setups. That is a great thing, but it also means more of us need to learn basic server security so we don’t get burned early.

The moment you spin up a fresh VPS, automated scanners start knocking. It doesn’t take days — it takes minutes. Bots crawl the public internet 24/7, hunting for open ports, default configurations, or weak passwords. If your machine is online, it is actively being scouted.

When we started spinning up more instances for our automation setups, I sat down with Zuri to lock down our servers systematically. You don’t need to be a network security specialist to protect your builds. This guide walks through the exact practical layers we set up, in plain English. Everything here is free and open-source.

Deciding how you’ll slip inside your own server

You have two sane ways to administer a VPS:

  1. Public SSH (classic): Your server is reachable on the public internet for SSH, but you harden it with SSH keys, a firewall, and tools like fail2ban (which watches login logs and auto-bans IPs that fail too many times).
  2. Tailscale-only admin (recommended): Your server is not reachable publicly for admin access. You only SSH over Tailscale, a VPN that creates a private encrypted network between your devices. More on this in Layer 1.

If you choose Tailscale-only, you can skip “change SSH port” and you can treat fail2ban as optional — because the public internet can’t reach your SSH port.

The big picture: think like a building

Imagine your server is a building. Security isn’t one giant lock on the front door — it’s layers: a fence around the property (firewall + VPN), key-card entry with no master keys floating around (SSH keys, no passwords), each office with its own lock (Docker isolation, TLS encryption), security cameras and an alarm system (file integrity checks, log analysis, alerts), vetting the contractors and materials (dependency scanning in CI), and an insurance policy plus a fire escape plan (automated backups, auto-restart, auto-patching).

No single layer is perfect. Together, they make breaking in really, really hard — and if someone does get in, you’ll know fast and recover faster.

Before you touch anything: don’t lock yourself out

If you take one thing from this guide, take this: keep one SSH session open while you change SSH or firewall settings. After each change, open a second terminal and verify you can still connect. Only when the new path works do you close the original session. If you’re using a cloud provider like DigitalOcean, Hetzner, or Linode, make sure you know where their web console or recovery shell is. That is your last-resort way back in.

Layer 1: network security

By default, your server listens on the public internet. Anyone can try to connect. Think of it like leaving every door and window in your building wide open.

Firewall: close every door you don’t need

A firewall controls which network traffic is allowed in and out. The golden rule: block everything by default, only allow what you specifically need. On Ubuntu, the built-in firewall is UFW (Uncomplicated Firewall):

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw enable
sudo ufw status

Change the default SSH port

SSH runs on port 22 by default and every bot on the internet knows it. Changing it to something else (like 8822) won’t stop a determined attacker, but it does cut down automated scanning noise. Edit /etc/ssh/sshd_config, set Port 8822, then restart SSH and update your firewall:

sudo ufw allow 8822/tcp
sudo ufw delete allow 22/tcp
sudo systemctl restart ssh

Before closing port 22, make sure you can connect on the new port. Keep your current session open as a safety net.

fail2ban: auto-ban attackers

Even on a non-standard port some bots will find you. Install fail2ban and create /etc/fail2ban/jail.local:

[sshd]
enabled = true
port = ssh
maxretry = 5
bantime = 3600
findtime = 600

If someone fails to log in 5 times in 10 minutes, ban them for 1 hour. Fail2ban is like a bouncer who remembers faces.

sudo apt install fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
sudo fail2ban-client status sshd

Tailscale: make your server invisible

If fail2ban is a bouncer, Tailscale removes the building from the public map entirely. It creates a private encrypted network (a “tailnet”) between your devices using WireGuard, a modern VPN protocol that’s faster and simpler than OpenVPN or IPSec. Devices connect directly to each other — peer-to-peer, no central VPN server to manage. Your server gets a private IP like 100.x.x.x that only your Tailscale devices can reach.

Setting it up on your VPS:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --ssh

On your laptop, download Tailscale from tailscale.com and sign in with the same account. Both devices are now on the same private network. And one thing many guides skip: Tailscale SSH replaces your entire SSH key management system. Instead of managing authorized_keys files, Tailscale authenticates you based on your identity tied to your Google, GitHub, or Microsoft login. No keys to manage, rotate, or lose. If someone steals your laptop, you revoke that Tailscale device instantly.

Once Tailscale is set up, close every public admin port:

sudo ufw allow in on tailscale0 to any port 22 proto tcp
sudo ufw status numbered
# Delete the old public SSH rule by number
sudo ufw delete 3

Now your server ignores SSH traffic from the public internet. Before Tailscale, anyone could try to connect — bots scanning 24/7, brute force attempts every minute. After Tailscale, the public internet sees nothing. Only your authorised devices on the tailnet can reach it.

One nuance: IPv6 is important for Tailscale — it uses IPv6 internally for its mesh network. Don’t disable it system-wide. Instead, make sure your firewall covers it too:

sudo grep IPV6 /etc/default/ufw
# Should show: IPV6=yes

Verify nothing is exposed: sudo ss -tlnp6 should show very little on public interfaces.

Layer 2: access control

Passwords are weak. People reuse them, they can be guessed, they can be brute-forced. SSH keys are dramatically more secure — they work like a lock-and-key pair where the public key goes on your server and the private key stays on your computer, never shared.

Create a non-root user first

If you’re logging in as root, fix that now:

sudo adduser deploy
sudo usermod -aG sudo deploy

Open a new terminal, verify you can log in as deploy, then lock down root:

PermitRootLogin no

SSH keys: throw away the password

If you’re doing Tailscale-only admin, skip this — Tailscale SSH handles auth. If you’re doing Public SSH, generate a key pair and copy it to your server:

ssh-keygen -t ed25519 -C "you@example.com"
ssh-copy-id -p 22 deploy@your-server-ip

Then disable passwords entirely:

PasswordAuthentication no
PubkeyAuthentication yes

Even if someone guesses “password123,” it doesn’t matter — the server won’t even ask for a password.

Lock down the rest

Audit your SSH keys regularly: ssh-keygen -l -f ~/.ssh/authorized_keys — remove any you don’t recognise. Disable X11 forwarding (X11Forwarding no in sshd_config) since you almost certainly don’t need it on a VPS. And lock down file permissions on anything sensitive:

chmod 600 ~/.my-app/config.json
chmod 600 ~/.ssh/authorized_keys
chmod 700 ~/.ssh

600 means owner read/write only. 700 means owner everything, nobody else. If a config file is readable by “others” (like 644 or 755), any process on the system can read your secrets.

Layer 3: application security

Even behind a firewall with perfect SSH config, the applications running on your server need their own protection.

Docker: keep services isolated

Two critical settings. First, bind to localhost only — Docker port mappings bypass UFW by default, so even if your firewall blocks port 5432, Docker’s -p 5432:5432 opens it anyway:

ports:
  - "127.0.0.1:5432:5432"   # Only accessible from the server itself
  # NOT "5432:5432"             # This exposes it to the world!

Second, set resource limits so a buggy app or a crypto-miner injected by an attacker can’t eat all your RAM and CPU:

deploy:
  resources:
    limits:
      cpus: '1.0'
      memory: 512M

TLS: encrypt everything

Any service that communicates over the network should use TLS — the “S” in HTTPS. For web services, use Let’s Encrypt for free certificates:

sudo apt install certbot
sudo certbot certonly --standalone -d yourdomain.com

If you’re using Tailscale, it provides automatic TLS certificates for your private network — no setup needed.

Layer 4: monitoring and detection

Prevention is not enough. You also need to know when something goes wrong.

Keep your system updated

The majority of successful breaches exploit vulnerabilities that already had patches available. Update regularly:

sudo apt update && sudo apt upgrade -y

Automate it with unattended upgrades — this installs security patches every day with no action needed from you:

sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Enable auto-reboot so kernel updates actually take effect. Without it, your server could be running a vulnerable kernel for weeks. With it, the patch activates at 3 AM — about 20 seconds of downtime. Edit /etc/apt/apt.conf.d/50unattended-upgrades:

Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";

File integrity monitoring

AIDE (Advanced Intrusion Detection Environment) takes a snapshot of your system files and alerts you if anything changes unexpectedly — a modified SSH config, a replaced binary, a rootkit install.

sudo apt install aide
sudo aideinit
sudo mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db

Set up a daily check and you’ll get a log of any unexpected file changes.

Log analysis and real-time alerts

Logwatch reads your server logs and sends a daily summary — failed login attempts, fail2ban bans, service restarts — instead of thousands of raw log lines:

sudo apt install logwatch

For critical issues, set up a simple monitoring script that checks every hour and alerts you (Telegram, email, Slack) if something’s wrong. Start simple: a 20-line bash script in cron is better than a complex monitoring stack you never finish setting up. Alert on more than 10 failed SSH attempts in an hour, critical services going down, or Docker containers crashing.

Layer 5: supply chain security

Your code might be fine, but your dependencies can still introduce vulnerabilities. A lot of breaches happen through third-party packages, not the application’s own code.

Scan dependencies in your CI pipeline — npm audit --audit-level high or pip audit — and set up Dependabot or Renovate to automatically create PRs when dependencies have updates. GitHub’s Dependency Review action can also block PRs that introduce packages with known vulnerabilities.

Layer 6: recovery

Even with good security, things break. Hardware fails, humans make mistakes. You need a plan for getting back up quickly.

Automated backups

Back up your critical configuration files daily and keep 30 days:

#!/bin/bash
BACKUP_DIR="/root/backups"
DATE=$(date +%Y%m%d)
mkdir -p $BACKUP_DIR
tar czf $BACKUP_DIR/config-$DATE.tar.gz \
    /etc/ssh/sshd_config \
    /etc/ufw \
    /path/to/your/app/config \
    2>/dev/null
find $BACKUP_DIR -name "config-*.tar.gz" -mtime +30 -delete

Schedule it with echo "0 2 * * * /usr/local/bin/backup-configs.sh" | sudo crontab -. Important: backups on the same server only protect against config mistakes, not hardware failure. For true disaster recovery, copy backups off-server.

Auto-restart services

If your app crashes, it should come back on its own. Systemd can do this:

[Service]
ExecStart=/usr/bin/node /path/to/app.js
Restart=always
RestartSec=10

On our production server, a kernel update triggered a reboot. The server came back up, systemd restarted all services, and the total downtime was 23 seconds. No one had to wake up at 3 AM.

Running a security audit

After you’ve set everything up, run through this monthly or after any significant changes:

# SSH hardening
sudo sshd -T | grep -E 'passwordauthentication|pubkeyauthentication|x11forwarding|port'

# Firewall
sudo ufw status verbose

# fail2ban
sudo fail2ban-client status sshd

# Tailscale
tailscale status

# Open ports
sudo ss -tlnp

# IPv6 coverage
sudo grep IPV6 /etc/default/ufw

# Config permissions (should produce no output if all secure)
find "$HOME" \( -name "*.json" -o -name "*.env" -o -name "*.key" \) -print0 2>/dev/null | \
  xargs -0 ls -la 2>/dev/null | grep -v "^-rw-------" | grep -v "^d"

# Pending updates
apt list --upgradable 2>/dev/null | tail -n +2

# Docker binding
docker ps --format '{{.Names}}: {{.Ports}}' 2>/dev/null

# Backups
ls -lh /root/backups/ 2>/dev/null | tail -3

Everything passes? Your server is in a much better state than most.

Quick reference

# --- Daily (automated) ---
# Security updates, AIDE check, backups, auto-reboot

# --- Weekly (5 minutes) ---
sudo fail2ban-client status sshd
ls -lh /root/backups/
tail -20 /var/log/security-monitor.log

# --- Monthly (15 minutes) ---
docker stats --no-stream
ssh-keygen -l -f ~/.ssh/authorized_keys
aide --check

# --- Emergency ---
sudo fail2ban-client status sshd
sudo journalctl -u myapp -n 50
sudo ufw status verbose

Security is not about being perfect. It is about reducing avoidable risk and making your server harder to abuse. If you work through this checklist, your VPS will be in much better shape than most. Total time: ~90 minutes. Total cost: $0.