Tutorials Search / Shipping & infrastructure / Prepare a fresh Linux server for production
📝 Written ● Intermediate Updated 2026-05-13

Prepare a fresh Linux server for production

A blank Ubuntu VPS is not production-ready. Within hours of booting on a public IP, automated scanners are trying root/admin/test passwords on SSH. The universal checklist — updates, non-root user, SSH key-only, firewall, fail2ban, swap, auto-patches — turns a fresh box into something you can responsibly point a domain at. Twenty minutes of work, paid back the first time it matters.

The default state of a fresh Linux VPS is "everything passes, please come in." Root login is enabled on SSH. Password authentication is enabled (often with the provider's emailed default password). No firewall is running. No automatic updates are configured. Within minutes of a public IP being assigned, log entries start showing thousands of SSH attempts per hour from automated botnets trying common usernames and weak passwords. Most of them fail. Some don't.

None of the hardening below is exotic. It's the same checklist DigitalOcean, AWS, Hetzner, Linode, and every other VPS provider has been linking to for a decade. The reason to do it deliberately rather than skip it is that the steps are conditionally fast: the first time, twenty minutes; every time after, the same image clone or Ansible role. Once you've done it once, it's never the bottleneck.

This tutorial walks the universal first-boot checklist for an Ubuntu 22.04 / 24.04 server (Debian works identically; Alpine differs and is called out where it matters). After the steps, your box has: a non-root deploy user, SSH key-only auth, an active firewall, an SSH brute-force throttle, a swap file sized for the instance, log rotation in place, and unattended security updates configured. Then you're ready to deploy your application.

What you'll learn

Prerequisites: A fresh Ubuntu 22.04 or 24.04 server with a public IP and root access. Your local SSH public key (~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub). If you don't have an SSH key on your laptop, generate one first: ssh-keygen -t ed25519 -C "[email protected]". Do not skip this and continue with password auth — that defeats the rest of the tutorial.

Step 1: Update everything before doing anything else

1

The first command on a fresh box

The image you booted from is some snapshot in time. Between the snapshot and your boot, security patches happened. Pull them before anything else, because the steps below assume a current package set:

ssh root@<PUBLIC_IP>
apt update
apt full-upgrade -y
# A kernel update may queue a reboot prompt — accept it.
reboot

Wait a minute, reconnect. You're now on the latest patches. full-upgrade rather than upgrade because the latter holds back packages that need dependency changes — usually exactly the security ones.

Step 2: Create a non-root deploy user

2

Root is for emergencies; daily work goes through sudo

Every common username — root, admin, ubuntu, deploy, www — is being guessed by scanners. The mitigation is combining a non-default username with key-only auth, but the username matters less than people think — the firewall and key-only auth do the real work. Pick something memorable; deploy is fine.

adduser deploy
# Set a password. You'll basically never type it; pick a long random one
# and store it in your password manager. It's only used if sudo asks
# (which you can disable too).

usermod -aG sudo deploy

Copy your SSH key into the new user's authorized_keys so you can log in as deploy without a password:

rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
# rsync copies your root .ssh/authorized_keys to /home/deploy/.ssh/
# and fixes ownership. Verify:
ls -la /home/deploy/.ssh/

Open a second terminal (don't disconnect from root yet) and test:

ssh deploy@<PUBLIC_IP>
# Should let you in without a password. From here on, do work as deploy;
# escalate with sudo when needed.
Don't disconnect the root session yet. If the next step misconfigures SSH and locks you out, the still-open root session is how you fix it. Keep it open until Step 4 succeeds end-to-end.

Step 3: Disable root login and password auth on SSH

3

Two lines in sshd_config; this is the biggest single security win

As the deploy user (so you can verify sudo works):

sudo nano /etc/ssh/sshd_config

Find and set (uncomment if needed):

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
# Optional but recommended:
ChallengeResponseAuthentication no
UsePAM yes
KbdInteractiveAuthentication no

Save. On Ubuntu 22.04+, SSH config is often split across /etc/ssh/sshd_config.d/*.conf files; provider images sometimes drop a 50-cloud-init.conf that re-enables password auth. Check:

sudo grep -rE "^(Password|PermitRoot)" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/
# Any line setting PasswordAuthentication yes or PermitRootLogin yes in
# the .d/ directory will override your edit. Comment them out.

Apply:

sudo systemctl restart ssh
# (On older Ubuntu the unit is named sshd.service. systemctl restart sshd
#  is the same; systemd resolves the alias.)

From your laptop, in a new terminal, try:

ssh deploy@<PUBLIC_IP>     # should still work
ssh root@<PUBLIC_IP>       # should be rejected: "Permission denied (publickey)"

If both behave correctly, your original root session can close. If anything went wrong, the open root terminal in the original window is how you re-edit sshd_config.

Step 4: Enable ufw with the three rules every web server needs

4

Default deny, allow only what you need

ufw ("uncomplicated firewall") is iptables with a sane interface. On Ubuntu it's pre-installed but inactive:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH       # port 22
sudo ufw allow http          # port 80
sudo ufw allow https         # port 443
sudo ufw enable
# It'll warn about disconnecting SSH — you allowed it on the previous line, so safe.
sudo ufw status verbose

If your provider has its own external firewall (Lightsail's firewall tab, AWS security groups, DigitalOcean Cloud Firewalls), keep ufw and the provider firewall both running. Two layers; either can save the other.

If your app listens on a non-standard port (3000 for Node dev, 5432 for Postgres, 6379 for Redis) — do not open those ports in ufw. Run them on 127.0.0.1 only and front them with nginx on 80/443. The firewall rule list should be three lines forever; everything else is internal.

Step 5: Install fail2ban

5

Auto-ban IPs that try and fail to log in

fail2ban watches log files for failed login attempts and adds firewall rules banning the offending IP. With key-only SSH it's belt-and-suspenders, but the belt is cheap:

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

The default jail watches SSH. Override defaults in a local config file (don't edit the shipped one — package updates overwrite it):

sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled = true
EOF
sudo systemctl restart fail2ban

Verify: sudo fail2ban-client status sshd. Currently-banned IPs accumulate over time; sudo fail2ban-client unban <IP> lets you out of jail if you ever ban yourself.

Step 6: Add swap (especially on small boxes)

6

Not for performance — for OOM survival

Most VPS providers don't allocate swap by default. On a 512 MB or 1 GB box, a sudden memory spike (a runaway log parse, a misconfigured Node app) triggers the Linux OOM killer, which usually shoots whatever is using the most memory — often your app. Swap doesn't make things fast; it gives the OOM killer breathing room and prevents the worst-case "service randomly disappears."

# For a 1 GB instance, 2 GB swap is reasonable. Scale proportionally.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# Make it persist across reboots:
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

# Tune: how aggressive swap usage should be. Lower = avoid swap until necessary.
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swap.conf
sudo sysctl -p /etc/sysctl.d/99-swap.conf

# Verify:
free -h

The output of free -h now shows a Swap row with 2.0 GB. The box is no longer one memory spike away from OOM-killing your app.

Step 7: Configure unattended-upgrades

7

Security patches apply themselves while you sleep

The patch you didn't apply is the one the exploit hits. Ubuntu ships unattended-upgrades for automatic security updates; on most provider images it's installed but not enabled:

sudo apt install -y unattended-upgrades apt-listchanges
sudo dpkg-reconfigure --priority=low unattended-upgrades
# Answer "Yes" to "automatically download and install stable updates"

# Verify it's running on a schedule:
systemctl status unattended-upgrades.timer

By default, only security updates are applied automatically. Application updates (Node, Postgres) stay manual — those can break compatibility and shouldn't auto-apply. To enable broader auto-updates (not recommended for production), edit /etc/apt/apt.conf.d/50unattended-upgrades and uncomment additional origins.

Auto-restart after kernel updates is off by default. If you want it on (recommended for non-stateful boxes), set Unattended-Upgrade::Automatic-Reboot "true"; and Unattended-Upgrade::Automatic-Reboot-Time "03:00"; in the same file. Boxes with persistent state (databases) should leave reboot manual.

Step 8: Log rotation sanity check

8

Already configured; verify it's working

Ubuntu ships logrotate with sane defaults. You don't need to set it up, but verify it actually runs — a misconfigured one fills the disk with old logs over months:

sudo logrotate -d /etc/logrotate.conf 2>&1 | head -20
# -d is "debug" mode — shows what it would do without doing it.
# You should see logs being identified for rotation.

# Manual run (force):
sudo logrotate -f /etc/logrotate.conf

When you install your own services (nginx, postgres, custom apps), they typically drop config files into /etc/logrotate.d/. The defaults — rotate weekly, keep 4 weeks, compress old logs — are fine for most services.

Step 9: Final reboot and verify

9

Survive a reboot before declaring victory

sudo reboot
# Wait 30 seconds, then reconnect:
ssh deploy@<PUBLIC_IP>

After reconnect, run a quick five-check:

# 1. You're the deploy user, not root.
whoami    # → deploy

# 2. Swap is active.
free -h | grep Swap    # → 2.0Gi total

# 3. Firewall is active and configured.
sudo ufw status    # → Status: active, with your three rules

# 4. fail2ban is running.
sudo systemctl is-active fail2ban    # → active

# 5. Unattended-upgrades is running.
sudo systemctl is-active unattended-upgrades.timer    # → active

All five green: the box is ready. Time to install your application stack and deploy.

What this didn't cover (and where to go)

Database / cache — Postgres, Redis, and friends are separate beasts with their own hardening. See Install Postgres and Redis.

SSH on a non-standard port — moving SSH from 22 to a random high port is debated: it cuts most botnet noise from your logs but is security-through-obscurity rather than real protection. fail2ban + key-only auth + no root is the meaningful defense. If you do move it, remember to ufw allow <new-port>/tcp and update your local ~/.ssh/config.

Monitoring and log shipping — local syslog is fine for an audit trail but useless for debugging if the box is unreachable. CloudWatch agent (AWS), Grafana Cloud (free tier), or Sentry (for app errors) ship logs off-machine.

Backups — provider snapshots are the easy floor. For database state, schedule pg_dump | gzip | aws s3 cp - via cron and verify restores periodically.

Idempotent re-runs — when you do this twice, write it as an Ansible playbook or a bash script. The provider-specific image is the next step up; the boring text-file script is the right starting point.

What's next