I spun up a fresh Ubuntu 22.04 VPS last month. Within four hours of going live, the auth log had over 3,000 failed SSH login attempts from IPs across six countries. I hadn't changed a single default.
That was the wake-up call. Here's exactly what I did to harden it — not a theoretical checklist, but the specific changes, configs, and commands I ran, in order.
1. SSH — Lock the Front Door First
The default SSH config is embarrassingly permissive. Let's fix that.
Edit /etc/ssh/sshd_config:
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
sudo nano /etc/ssh/sshd_config
What I Changed
- Port 22 --> Port 2299 # non-standard port kills 90% of bots
- PermitRootLogin yes --> PermitRootLogin no # root should never log in directly
- PasswordAuthentication yes --> PasswordAuthentication no # keys only
- X11Forwarding yes --> X11Forwarding no
- UsePAM yes --> UsePAM no
- MaxAuthTries 6 --> MaxAuthTries 3
- LoginGraceTime 120 --> LoginGraceTime 30
- AllowUsers --> AllowUsers deploy # whitelist only your user(s)
And added these (weren't in the file at all):
Protocol 2
ClientAliveInterval 300
ClientAliveCountMax 2
Banner /etc/ssh/banner.txt
Create a login banner so scanners see a warning:
echo "Unauthorized access is prohibited. All sessions are logged." \
| sudo tee /etc/ssh/banner.txt
Restart SSH — but do not close your current session until you've verified the new port works in a second terminal:
sudo systemctl restart sshd
in other host:
ssh -p 2299 deploy@your-server-ip
2. Firewall — Default Deny Everything
I use ufw (Uncomplicated Firewall). The philosophy: deny all inbound by default, open only what you explicitly need.
sudo ufw default deny incoming
sudo ufw default allow outgoing
# only allow your new SSH port (replace 2299 with yours)
sudo ufw allow 2299/tcp comment 'SSH'
# web server ports (skip if not applicable)
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
sudo ufw enable
sudo ufw status verbose
Output I got:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
2299/tcp ALLOW IN Anywhere # SSH
80/tcp ALLOW IN Anywhere # HTTP
443/tcp ALLOW IN Anywhere # HTTPS
2299/tcp (v6) ALLOW IN Anywhere (v6) # SSH
80/tcp (v6) ALLOW IN Anywhere (v6) # HTTP
443/tcp (v6) ALLOW IN Anywhere (v6) # HTTPS
If you're on a specific static IP, lock SSH down further:
sudo ufw allow from 203.0.113.42 to any port 2299 proto tcp comment 'SSH from office'
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
2299/tcp ALLOW IN Anywhere # SSH
80/tcp ALLOW IN Anywhere # HTTP
443/tcp ALLOW IN Anywhere # HTTPS
2299/tcp ALLOW IN 192.168.34.134 # SSH from kali
2299/tcp (v6) ALLOW IN Anywhere (v6) # SSH
80/tcp (v6) ALLOW IN Anywhere (v6) # HTTP
443/tcp (v6) ALLOW IN Anywhere (v6) # HTTPS
3. Users — Least Privilege from Day One
Never use root for daily operations. I created a deploy user with limited access:
# create the user
sudo adduser deploy
# give it sudo access (we'll restrict this further below)
sudo usermod -aG sudo deploy
# switch to it
su - deploy
# set up SSH key authentication
mkdir -p ~/.ssh && chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys # Paste your public key here
chmod 600 ~/.ssh/authorized_keys
Then from your local machine, verify login works before you lock anything else down:
ssh -p 2299 -i ~/.ssh/your_key deploy@your-server-ip
Also disabled the root password entirely:
sudo passwd -l root
This means root can't be logged into via password, period. The only path is through a sudo-capable account with a key.
4. sudo — Restrict What Deploy Can Actually Do
Default sudo lets a user run anything. That's too broad. I scoped it.
sudo visudo -f /etc/sudoers.d/deploy
What I put in that file:
# deploy can restart specific services only, no password required for those
deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart nginx, /bin/systemctl restart myapp
# Everything else requires password and gets logged
deploy ALL=(ALL) ALL
# Require re-authentication every session (disable timestamp caching)
Defaults:deploy timestamp_timeout=0
Now deploy can restart nginx without a password (needed for deploy scripts) but can't, say, sudo bash without being challenged.
Check it works:
sudo -l -U deploy
5. Fail2ban — Auto-Ban Repeat Offenders
Even with key-only SSH, bots still hammer the port. Fail2ban watches logs and bans IPs automatically.
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
Key settings I changed in jail.local:
[DEFAULT]
bantime = 1h # Default was 10 minutes — too short
findtime = 10m
maxretry = 3 # Default was 5 — lowered it
banaction = ufw # Use ufw to actually apply the ban
[sshd]
enabled = true
port = 2299 # Must match your SSH port
logpath = /var/log/auth.log
maxretry = 3
Start and enable:
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
Check bans in real time:
sudo fail2ban-client status sshd
Within an hour, my jail had blocked 40+ IPs. You can unban manually if needed:
sudo fail2ban-client set sshd unbanip 1.2.3.4
6. File Permissions — Tighten the World-Readable Defaults
Ubuntu ships with reasonable defaults, but several things need tightening:
Find SUID/SGID binaries (should be a short, expected list):
sudo find / -perm /6000 -type f 2>/dev/null
Review each one. Anything unexpected? Investigate.
Lock down sensitive config files:
# SSH config should not be world-readable
sudo chmod 600 /etc/ssh/sshd_config
sudo chmod 644 /etc/ssh/ssh_config
# crontabs
sudo chmod 600 /etc/crontab
sudo chmod 700 /etc/cron.d /etc/cron.daily /etc/cron.weekly /etc/cron.monthly
# passwd/shadow
sudo chmod 644 /etc/passwd
sudo chmod 640 /etc/shadow
sudo chown root:shadow /etc/shadow
Set a umask of 027 so new files default to no world permissions:
echo "umask 027" | sudo tee -a /etc/profile
echo "umask 027" | sudo tee -a /etc/bash.bashrc
7. Automatic Updates — Patch While You Sleep
Manual patching is a promise you'll break. Unattended upgrades for security patches only.
sudo apt install unattended-upgrades apt-listchanges -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
Then edit the config to fine-tune:
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
The relevant section I set:
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
// "${distro_id}:${distro_codename}-updates"; // Left commented — review these manually
};
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false"; // Never auto-reboot a prod server
Unattended-Upgrade::Mail "you@yourdomain.com";
Enable and verify:
sudo systemctl enable unattended-upgrades
sudo unattended-upgrades --dry-run --debug
8. Logging — Know What Happened and When
Default logging is good. Centralized, structured logging is better. At minimum, I made sure logs were retained long enough to be useful and weren't world-readable.
Restrict log access:
sudo chmod 640 /var/log/auth.log
sudo chmod 640 /var/log/syslog
sudo chown root:adm /var/log/auth.log
Extend log retention in /etc/logrotate.conf:
# changed from:
rotate 4
# changed to:
rotate 52 # keep a full year of weekly logs
Enable process accounting (tracks every command executed):
sudo apt install acct -y
sudo accton on
View recent commands by user:
sudo lastcomm deploy
Set up auditd for file-level audit trails on critical paths:
sudo apt install auditd -y
sudo systemctl enable auditd
# watch for writes to /etc/passwd or /etc/shadow
sudo auditctl -w /etc/passwd -p wa -k passwd_changes
sudo auditctl -w /etc/shadow -p wa -k shadow_changes
sudo auditctl -w /etc/ssh/sshd_config -p wa -k sshd_config_changes
Check the audit log:
sudo ausearch -k passwd_changes
9. Time Synchronization — The Silent Dependency
Logs mean nothing if the timestamps are wrong. TLS certificates fail if the clock is off. NTP is mandatory.
sudo apt install systemd-timesyncd -y
Check current status:
timedatectl status
Output I expected:
Local time: Mon 2026-09-07 14:23:11 UTC
Universal time: Mon 2026-09-07 14:23:11 UTC
RTC time: Mon 2026-09-07 14:23:11
Time zone: UTC (UTC, +0000)
System clock synchronized: yes
NTP service: active
If not synchronized, force it:
sudo timedatectl set-ntp true
sudo systemctl restart systemd-timesyncd
For higher accuracy (especially in regulated environments), I swapped to chrony:
sudo apt install chrony -y
sudo systemctl enable chrony
sudo chronyc tracking
Set your timezone appropriately — I keep servers in UTC and convert in the application layer:
sudo timedatectl set-timezone UTC
10. Service Isolation — Kill What You Don't Use
Running services are attack surface. Every daemon you don't need is a potential vulnerability.
Audit what's running:
sudo systemctl list-units --type=service --state=running
On a fresh Ubuntu 22.04 minimal install, I disabled these (they were running and unnecessary for my workload):
sudo systemctl disable --now snapd.service
sudo systemctl disable --now snapd.socket
sudo systemctl disable --now avahi-daemon.service # mDNS (not needed on a server)
sudo systemctl disable --now cups.service # printing (definitely not needed)
sudo systemctl disable --now ModemManager.service # mobile modem management?? Nope
List open ports and the processes behind them:
sudo ss -tulpn
Compare before and after disabling services. If a port appears that you didn't expect, dig in:
sudo lsof -i :PORT_NUMBER
Isolate services with systemd security directives — I added these to my app's service file:
sudo nano /etc/systemd/system/myapp.service
[Service]
User=myapp
Group=myapp
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/myapp
These use Linux namespacing to cage the process. Even if it's compromised, it can't read /home, write to the system, or escalate privileges.
sudo systemctl daemon-reexec
sudo systemctl restart myapp
The Verification Pass
After all of this, I ran a quick self-audit:
# check listening ports
sudo ss -tulpn
# verify firewall state
sudo ufw status verbose
# check failed logins from the past day
sudo grep "Failed password" /var/log/auth.log | tail -20
# review current bans
sudo fail2ban-client status sshd
# confirm NTP is synced
timedatectl | grep "synchronized"
# list all sudoers rules
sudo cat /etc/sudoers /etc/sudoers.d/*
I also ran lynis — a hardening audit tool — to catch anything I missed:
sudo apt install lynis -y
sudo lynis audit system
It gave me a hardening index score and flagged a few things I'd overlooked (kernel parameters, /proc mount options, and a few sysctl values). Worth running.
What This Setup Doesn't Cover
Hardening is layered, and this gets you a solid foundation — not a fortress. Things not covered here that matter in production:
- Intrusion detection (AIDE, Tripwire) for file integrity monitoring
- AppArmor / SELinux profiles per application
- Network segmentation and private VPC networking
- Secrets management (Vault, environment variable hygiene)
- Backups and recovery testing — the one thing everyone delays until it's too late
Security is a practice, not a state. Set a calendar reminder to re-run lynis and review logs monthly. The four hours it takes to do this once is nothing compared to the aftermath of a breach.