homeprojectsblogabout
  • github

  • email

  • x / twitter

  • linkedin

  • personal docs

  • rss

© 2026 Yehezkiel Wiradhika

Ubuntu 22.04 VPS Linux Server Hardening from Scratch

9/7/2026

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

  1. Port 22 --> Port 2299 # non-standard port kills 90% of bots
  2. PermitRootLogin yes --> PermitRootLogin no # root should never log in directly
  3. PasswordAuthentication yes --> PasswordAuthentication no # keys only
  4. X11Forwarding yes --> X11Forwarding no
  5. UsePAM yes --> UsePAM no
  6. MaxAuthTries 6 --> MaxAuthTries 3
  7. LoginGraceTime 120 --> LoginGraceTime 30
  8. 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.

On this page