Patet

Ops security

A fresh VPS gets scanned by the whole internet within minutes—this guide uses a few concrete commands to block ~99% of automated attacks.

You just plugged a brand-new server into the public internet—surely nobody knows it's there yet? In reality, within minutes, automated scanners will find it. They sweep the entire IPv4 address space around the clock, trying weak SSH passwords and probing for known-vulnerable ports. You don't need any heroic hardening; just doing the few things below correctly blocks about 99% of automated attacks—the targeted stuff most projects will never see.

Every section below gives you copy-pasteable commands (for Ubuntu / Debian). Work through them in order.

Harden SSH

SSH is an attacker's favorite door, and the first one you should lock. There are really just two ideas: don't log in as root directly, and don't log in with a password (use a key instead).

Create a non-root user with sudo

Doing daily work as root is too risky—a single slip can wipe the whole machine. Create a normal user for everyday use and elevate with sudo only when you need admin rights.

# Run as root after logging in (replace deploy with your username)
adduser deploy
usermod -aG sudo deploy

adduser asks you to set a password and fill in a few fields (just press Enter to skip). usermod -aG sudo adds the user to the sudo group so it can elevate.

Copy your SSH public key to the new user

Key-based login is far more secure than passwords—and more convenient. Do this on your own computer (not on the server).

If you don't have a key locally yet, generate one:

# On your own computer
ssh-keygen -t ed25519 -C "your_email@example.com"

Then copy the public key to the server. The easiest way is ssh-copy-id:

# On your own computer
ssh-copy-id deploy@your-server-ip

If you don't have ssh-copy-id, manually append your public key to the server's ~/.ssh/authorized_keys:

# On the server, as the deploy user
mkdir -p ~/.ssh && chmod 700 ~/.ssh
# Paste the contents of your local ~/.ssh/id_ed25519.pub into this file
nano ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Once set up, open a new terminal and verify key login works: ssh deploy@your-server-ip. Only continue once you can get in.

Disable password auth and root login

Now that key login works, turn off both password login and direct root login. Edit /etc/ssh/sshd_config:

sudo nano /etc/ssh/sshd_config

Make sure these lines exist and are set correctly (some may be commented out—remove the leading # and fix the value):

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes

Save, then restart SSH for the changes to take effect:

sudo systemctl restart ssh

(On some systems the service is named sshd. If the command above errors, use sudo systemctl restart sshd.)

Don't close your current window yet

After restarting SSH, keep your current logged-in session open and untouched, then open a new terminal and test that ssh deploy@your-server-ip works. If you got the config wrong and locked yourself out, you can still fix it from the original session. Only close the old window once the new terminal logs in cleanly.

Firewall (ufw)

By default a server may have a bunch of ports open that you aren't even using. Use ufw (Uncomplicated Firewall) to allow only what you actually need—SSH, HTTP, HTTPS—and deny the rest.

# Allow SSH (use the OpenSSH preset so you don't lock yourself out)
sudo ufw allow OpenSSH

# Allow HTTP and HTTPS for your website
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

# Enable the firewall (it warns SSH may drop—type y; OpenSSH is allowed above, so you're safe)
sudo ufw enable

# Check the current rules
sudo ufw status verbose

Run allow OpenSSH first, then ufw enable—don't reverse the order. If you've changed your SSH port (say to 2222), allow that new port (sudo ufw allow 2222/tcp), otherwise enabling the firewall will lock you out.

Automatic security updates

Patches need to be applied promptly, but you can't babysit the server every day. unattended-upgrades makes the system install security updates automatically.

sudo apt update
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

That last command opens a dialog—choose Yes to enable automatic security updates. Once set up, security patches install in the background and you stop worrying about most known vulnerabilities.

fail2ban

Even with password login disabled, attackers will keep hammering SSH. fail2ban watches the logs, and when an IP fails to log in repeatedly, it automatically bans that IP for a while, shutting brute-force attempts down for good.

sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban

# Check banned IPs and SSH protection status
sudo fail2ban-client status sshd

It protects SSH out of the box with almost no configuration. To tweak ban duration or the failure threshold, edit /etc/fail2ban/jail.local.

Least privilege

A plain but extremely important principle: give everything only the minimum privileges it needs to do its job.

  • Don't run your app as root. Create a dedicated non-root user for the app, so that if it's ever compromised, the attacker only gets that restricted user—not full control of the whole machine.
# Create a non-login system user dedicated to running the app
sudo adduser --system --group --no-create-home appuser
  • Tighten file permissions. Make the app code and data directories owned by this dedicated user, and don't make them world-writable:
# Change ownership of the app directory to the dedicated user
sudo chown -R appuser:appuser /opt/yourapp
# Directories 755, files 644—avoid wide-open 777 permissions
sudo find /opt/yourapp -type d -exec chmod 755 {} \;
sudo find /opt/yourapp -type f -exec chmod 644 {} \;
  • When running the service with systemd, set User=appuser in the unit file so the process runs as the dedicated user. A ready-made systemd template is in Templates & checklist.

Secrets & env

Leaking secrets is one of the most common and most damaging beginner mistakes. The moment a database password or API key lands in a public repo, it can be scanned and abused within minutes.

  • Never commit a .env file or keys to Git. Add a .gitignore to your repo:
# .gitignore
.env
.env.*
*.pem
*.key
id_rsa
  • Restrict permissions on secret files so only the owner can read them:
chmod 600 .env
chmod 600 ~/.ssh/id_ed25519
  • If a secret leaks, rotate it immediately. Don't assume "deleting it from Git is enough"—it already lives in history and in everyone's clones. The correct fix is to revoke the old key and generate a new one on the relevant platform, and change the database password right away too.

If a secret ever touched Git history, treat it as public

Even if you later delete the file and amend the commit, the secret still sits in Git history, and anyone who can pull the repo can dig it out. The only reliable remedy is to rotate the secret, not delete the file. Configure production secrets via your platform's environment variables or a secrets manager—don't write them to a file and commit it.

Don't expose your database

This is one of the most-exploited misconfigurations: a database listening on 0.0.0.0, reachable from the entire internet. Postgres, MySQL, Redis, and MongoDB have all been hit (some even ransomed and wiped).

  • Bind to the loopback address so only apps on the same server can connect.

Postgres (postgresql.conf):

listen_addresses = 'localhost'

MySQL (my.cnf):

bind-address = 127.0.0.1

Redis (redis.conf):

bind 127.0.0.1
  • Back it up with the firewall: make sure the database ports (Postgres 5432, MySQL 3306, Redis 6379) are not opened to the public by ufw. Earlier we only allowed 22/80/443, so the database ports are denied by default—exactly what you want.
  • Always use strong passwords, never default credentials. For things like Redis that ship with no password, be sure to set requirepass.
  • If the app and database are on different machines, don't take the lazy route of exposing the public internet—use a private network / VPC, or add TLS.

Backups

Servers crash, disks fail, and fingers slip. Backups are not optional.

  • Automate backups of both the database and files—don't rely on remembering to run them by hand. Use cron:
# Back up a Postgres database every day at 3 AM (put this in crontab -e)
0 3 * * * pg_dump yourdb | gzip > /backups/yourdb-$(date +\%F).sql.gz
  • Store them offsite. Backups sitting on the same server vanish with the machine. Sync them to object storage (S3, Cloudflare R2, Backblaze B2):
# Example: use rclone to sync backups to object storage
rclone copy /backups remote:my-backups
  • Give backups a retention policy (e.g. keep the last 30 days) so old backups don't fill the disk.

A backup you haven't restored is not a backup

A backup script running daily ≠ backups that actually work. Files can be corrupt, the contents can be empty, the restore steps may not even run—you'll only find out by actually trying a restore. Periodically pick a backup and fully restore and verify the data on another machine (or locally). Only what you can restore counts as a backup.

Monitoring & alerts

When something goes wrong, you want to be the first to know—not to hear about it when users start complaining.

  • Uptime monitor: use UptimeRobot or healthchecks.io to probe your site on a schedule and alert you by email / push the moment it goes down. The free tier is usually plenty.
  • Error logging: collect application errors (something like Sentry) so exceptions don't quietly rot in a log nobody reads.
  • Disk / CPU alerts: a full disk will take your database down instantly. Set a threshold alert, or at minimum check df -h regularly.
  • Auto-restart the process: if the app crashes, it should come back on its own. Use systemd (Restart=always) or PM2, which is common for Node projects. A systemd service template is in Templates & checklist.
# systemd unit snippet: auto-restart after the process exits
[Service]
User=appuser
Restart=always
RestartSec=5

Grab ready-made configs

The sshd_config, systemd service files, ufw rules, and backup scripts mentioned above all have copy-paste-ready versions in Templates & checklist. Related reading: Publish to the internet, Buying a server, and Set up Cloudflare (Cloudflare adds another layer of DDoS and WAF protection).

Checklist for this step

  • Created a non-root sudo user, set up SSH key login, and disabled password & root login
  • Enabled the ufw firewall, allowing only SSH / 80 / 443
  • Installed unattended-upgrades and fail2ban
  • App runs as a dedicated non-root user with tightened file permissions
  • .env and keys are in .gitignore, set to 600, and leaked secrets are rotated immediately
  • Database listens on localhost only, is not exposed to the public, and uses strong passwords
  • Backups are automated, stored offsite, and actually tested with a real restore
  • Uptime monitoring, alerts, and process auto-restart are configured

See the full launch checklist.

On this page