If you ever deploy to your own VPS — and most app developers eventually do — there’s a good chance the box has already been scanned by 50 different bots since you spun it up. Most of those scans are looking for specific easy wins: default passwords, exposed admin panels, unpatched services. Block those, and you’ve stopped 95% of attacks without needing to be a security expert.
This post is the pragmatic hardening checklist for someone who isn’t a sysadmin but does deploy services to Linux. We’ll go through SSH, users, firewall, fail2ban, automatic updates, and a handful of small habits. By the end you’ll have a server that won’t get owned by a script kiddie.
This is app developer hardening, not enterprise hardening. It’s not exhaustive. It is the high-leverage 20% that delivers most of the safety.
Assumed setup
A fresh Ubuntu LTS (24.04 or 22.04) VPS. You SSH in as root with the credentials your provider emailed you. The hardening below is the first 20 minutes of every server’s life.
If you use Ansible, Terraform, or cloud-init, automate this. If you don’t yet, do it manually a few times — you’ll appreciate why automation exists.
Step 1: Update everything, immediately
apt update && apt upgrade -y
Boring but essential. The default image is whatever was current when your provider built it; security patches likely shipped since.
Step 2: Create a non-root user
Working as root is dangerous. One typo can wipe the disk. Create a regular user:
adduser deploy # set a password
usermod -aG sudo deploy
Verify you can sudo:
su - deploy
sudo whoami # → root
From here, never log in as root again. Use deploy and sudo.
Step 3: SSH keys, not passwords
On your local machine:
ssh-keygen -t ed25519 -C "[email protected]"
# saves to ~/.ssh/id_ed25519 (private) and id_ed25519.pub (public)
Copy the public key to the server:
ssh-copy-id deploy@<server-ip>
Test:
ssh deploy@<server-ip> # should not prompt for password
ed25519 is the right key type in 2026. Don’t use RSA-2048 or smaller.
Step 4: Lock down SSH
Edit /etc/ssh/sshd_config (or better, drop a file in /etc/ssh/sshd_config.d/):
# /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
PubkeyAuthentication yes
X11Forwarding no
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
MaxAuthTries 3
AllowUsers deploy
Apply:
sudo systemctl restart ssh
What this does:
PermitRootLogin no— root can’t SSH in directly.PasswordAuthentication no— keys only.AllowUsers deploy— even more restrictive: only this user.- Reasonable timeouts so abandoned connections don’t hang around forever.
Also consider changing the SSH port from 22 to something else (5022, 2222, whatever). It doesn’t add real security — but it cuts the noise from automated scans dramatically and makes your logs more readable.
Port 5022
If you do, update your firewall rules accordingly.
Step 5: A firewall (UFW)
Ubuntu ships with UFW (Uncomplicated Firewall):
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH # or "5022/tcp" if you changed the port
sudo ufw allow 80/tcp # if you serve HTTP
sudo ufw allow 443/tcp # if you serve HTTPS
sudo ufw enable
sudo ufw status verbose
Default-deny is the safe baseline: nothing is reachable except what you explicitly allow.
Did you set up kubectl, install Postgres, run a Redis without binding it to localhost? Those services think they’re internal, but a misconfiguration could expose them. UFW is your safety net.
Step 6: fail2ban
Even with key-only SSH, log spam from brute-force attempts is annoying and blocks legitimate scanning of your auth logs.
sudo apt install fail2ban -y
Drop a sensible config:
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
Now anyone who fails 5 SSH auth attempts in 10 minutes gets banned for an hour. The bots quickly figure out you’re not worth scanning further.
Step 7: Automatic security updates
You won’t manually apt upgrade daily. Configure unattended-upgrades to apply security patches automatically:
sudo apt install unattended-upgrades apt-listchanges -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
Verify and tweak /etc/apt/apt.conf.d/50unattended-upgrades:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
Unattended-Upgrade::Mail "[email protected]"; # optional notification
Test it ran:
sudo unattended-upgrade --dry-run --debug
This patches the boring CVEs that 99% of opportunistic attacks rely on. Set it and forget it.
Step 8: Time sync
Authentication systems (TLS certificates, Kerberos, JWTs) hate clock drift. Make sure NTP is running:
timedatectl status
# System clock synchronized: yes
# NTP service: active
On Ubuntu, systemd-timesyncd is enabled by default. Don’t disable it.
Step 9: Filesystem hygiene
A few small things that pay off:
/tmpshould benoexec— a common foothold is to drop a payload in/tmpand run it. Edit/etc/fstab(or usesystemd-tmpfiles) to mount/tmpwithnoexec,nosuid,nodev.- Disable core dumps for setuid binaries —
fs.suid_dumpable=0in/etc/sysctl.d/. - Don’t run your app as root — it should run as a dedicated user (
deploy,appuser, etc.) with only the permissions it needs. - Limit
sudo— ifdeployonly needssudofor specific commands, list them in/etc/sudoers.d/deployinstead of granting full sudo.
Step 10: Log monitoring
You can’t react to attacks you don’t see.
journalctl -u ssh -fto follow SSH logs in real time.- fail2ban logs bans to
/var/log/fail2ban.log. - Ship logs off the box — even just to a free Logtail/Better Stack tier — so a compromised box can’t hide evidence.
If your service is more than a side project, set up alerting: Slack/email me when SSH logs in from an unusual IP, when fail2ban bans more than X IPs/hour, when the disk hits 90%.
Step 11: Backups
Hardening doesn’t help if your only copy of the data is on this server. At minimum:
- Daily DB dumps (
pg_dump) to off-server storage (S3, B2, Restic). - Weekly full system snapshots (your provider usually offers this).
- Test the restore. Untested backups are hopeful files.
Things you can skip (despite what blog posts say)
A few common pieces of advice that are overkill for most app deployers:
- SELinux / AppArmor — useful but complicated. Default Ubuntu AppArmor profiles are fine; don’t try to write your own unless you know why.
- Kernel hardening (grsecurity, lockdown) — diminishing returns for a single-tenant app box.
- Custom IDS (OSSEC, Wazuh) — fail2ban + log shipping covers most needs. Real IDS is for security-team setups.
- VPN-only access — useful for big teams; overkill for solo. SSH with keys + fail2ban is sufficient.
The default Ubuntu LTS, with the steps above, is in great shape. Fancier defenses are for specific threat models, not “I have a side project.”
A complete script
If you do this often, codify it. A small shell script that brings a fresh box to a known good baseline:
#!/usr/bin/env bash
set -euo pipefail
NEW_USER="deploy"
SSH_PORT="${SSH_PORT:-22}"
# 1. Update
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get -y upgrade
# 2. Packages
apt-get -y install ufw fail2ban unattended-upgrades
# 3. User
if ! id -u "$NEW_USER" >/dev/null 2>&1; then
adduser --disabled-password --gecos "" "$NEW_USER"
usermod -aG sudo "$NEW_USER"
fi
# 4. UFW
ufw default deny incoming
ufw default allow outgoing
ufw allow "$SSH_PORT"/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
# 5. SSH hardening
cat > /etc/ssh/sshd_config.d/99-hardening.conf <<EOF
Port $SSH_PORT
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AllowUsers $NEW_USER
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
systemctl restart ssh
# 6. fail2ban
cat > /etc/fail2ban/jail.local <<EOF
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
EOF
systemctl restart fail2ban
# 7. unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
Run as root on a fresh box. Make sure your SSH key is in /home/deploy/.ssh/authorized_keys first, or you’ll lock yourself out.
For more deployment context, see Deploying Django to Production .
What about Docker and Kubernetes?
If you’re deploying via Docker or Kubernetes, the host hardening above still applies — don’t run K8s nodes as root-accessible jump boxes. The container layer adds its own hardening (running as non-root inside the container, using read-only root filesystems, security contexts) — see Docker for Python Developers
and Kubernetes for App Developers
.
Conclusion
Server hardening sounds intimidating but isn’t. Update the system, use SSH keys, run as a non-root user, deny by default with UFW, ban brute force with fail2ban, automate security updates, and back up your data. Eleven steps; thirty minutes of work; resistance to almost every opportunistic attack on the internet.
It’s the kind of work where you’ll never know exactly how many bullets you dodged. Good security looks like nothing happening — and that’s the point.
Stay safe out there.
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .