Secure your VPS: firewall, SSH keys & fail2ban
A freshly provisioned VPS is exposed to the public internet within seconds. Automated bots scan all IPv4 addresses and attempt to log in via SSH continuously. This guide covers the essential hardening steps every VPS — whether you are running an AI agent, WordPress, or n8n — should have before you do anything else.
Do this first. Every other guide in this Learn hub assumes you have completed these steps. The whole process takes about 15 minutes.
Step 1 — Create a non-root sudo user
Running everything as root is dangerous — a single mistake can wipe the server. Create a regular user and grant it sudo access:
# As root (initial login):
adduser deploy
# Follow the prompts to set a password
usermod -aG sudo deploy
# Verify
su - deploy
sudo whoami # should print: rootStep 2 — SSH key authentication
SSH keys are far more secure than passwords. Generate a key pair on your local machine (if you don't already have one), then copy the public key to the server:
# Generate an Ed25519 key pair (if needed)
ssh-keygen -t ed25519 -C "your@email.com"
# Copy the public key to the server
ssh-copy-id deploy@YOUR_SERVER_IP
# Or manually:
cat ~/.ssh/id_ed25519.pub | ssh root@YOUR_SERVER_IP "mkdir -p /home/deploy/.ssh && cat >> /home/deploy/.ssh/authorized_keys && chmod 700 /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys && chown -R deploy:deploy /home/deploy/.ssh"Step 3 — Harden SSH configuration
Disable password authentication and root login. Optionally move SSH to a non-standard port to reduce automated scan noise:
sudo nano /etc/ssh/sshd_config
# Change or uncomment these lines:
Port 2222 # Optional: non-standard port
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
sudo systemctl restart sshd
# IMPORTANT: keep your current session open while you verify
# the new config works in a second terminal window first!If you changed the port, log in with ssh -p 2222 deploy@YOUR_SERVER_IP in a second terminal before closing your existing session.
Step 4 — Configure UFW firewall
UFW (Uncomplicated Firewall) wraps iptables in a simple interface. Allow only the ports your server needs:
sudo apt install -y ufw
# Default: deny all incoming, allow all outgoing
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH (use your custom port if you changed it)
sudo ufw allow 2222/tcp comment 'SSH'
# Or: sudo ufw allow 22/tcp comment 'SSH'
# Allow web traffic
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
# Enable the firewall
sudo ufw enable
sudo ufw status verboseOnly open additional ports as you actually need them — for example port 5432 for PostgreSQL should only ever be open to specific trusted IPs, never to the world.
If you run Docker, UFW is not protecting your containers
This is the single most common false sense of security on a VPS. Docker writes its own iptables rules in a chain that is evaluated before UFW's, so a published container port is reachable from the internet even when UFW says the port is denied. sudo ufw status will show your tidy allow-list and be telling you nothing about your containers.
We measured this rather than repeating it. On a fresh Ubuntu 22.04 box configured exactly as above — default deny incoming, only 22, 80 and 443 allowed — we started a PostgreSQL container with -p 5432:5432 and probed the public IP from an unrelated host:
# before the container started
22=OPEN 80=OPEN 443=OPEN 5432=closed
# ~20 seconds later, container running, UFW rules unchanged
22=OPEN 80=OPEN 443=OPEN 5432=OPEN # <-- open to the entire internetNothing about the firewall changed. The database simply became publicly reachable because Docker published the port. Anyone scanning that address finds a PostgreSQL server, and from there it is a password-guessing problem rather than a network one.
The fix is to stop publishing container ports to every interface. Bind them to loopback and reach them through a reverse proxy or an SSH tunnel:
# WRONG on a public server — binds 0.0.0.0, bypasses UFW
docker run -d -p 5432:5432 postgres:16
# RIGHT — only reachable from the host itself
docker run -d -p 127.0.0.1:5432:5432 postgres:16
# Then connect over an SSH tunnel from your laptop:
ssh -L 5432:127.0.0.1:5432 you@your-server
# psql now talks to localhost:5432 on YOUR machineCheck what is actually listening rather than what you intended — the two disagree more often than you would like. 0.0.0.0:* or [::]:* in this output means “every interface, including the public one”:
sudo ss -ltnp # every listening socket and its owner
docker ps --format 'table {{.Names}}\t{{.Ports}}' # what Docker published
# From ANOTHER machine — the only test that really counts:
nc -z -w4 YOUR.SERVER.IP 5432 && echo "REACHABLE FROM THE INTERNET"If you genuinely need a container port exposed, restrict it explicitly with Docker's own rules (the DOCKER-USER iptables chain) rather than assuming UFW covers it — or put the service behind the reverse proxy and let that be the only thing listening.
Step 5 — Install fail2ban
fail2ban watches your log files and automatically bans IPs that show signs of brute-force activity (too many failed SSH attempts):
sudo apt install -y fail2ban
# Create a local override — never edit jail.conf directly
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
port = 2222
# Change port to match your SSH port
EOF
sudo systemctl enable --now fail2ban
sudo systemctl status fail2ban
# View currently banned IPs
sudo fail2ban-client status sshdStep 6 — Unattended security upgrades
Most server compromises exploit known, patched vulnerabilities. Automate security-only package updates so you do not fall behind:
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
# Select "Yes" to enable automatic updates
# Verify the configuration
cat /etc/apt/apt.conf.d/20auto-upgradesStep 7 — Basic monitoring
Know when something goes wrong. A lightweight first step is setting up logwatch for daily email digests, or checking the authentication log for suspicious activity:
# Watch auth log for failed logins
sudo journalctl -u sshd --since "1 hour ago" | grep "Failed"
# Check fail2ban bans
sudo fail2ban-client status sshd
# Install htop for real-time resource monitoring
sudo apt install -y htop
htopFor production workloads, consider a lightweight agent like netdata (open-source, zero config, web dashboard) or vector for log shipping to your alerting stack.
Want this done for you?
AgentOcean VPS plans are provisioned with these hardening steps applied by default — SSH keys only, UFW enabled, fail2ban running. Skip the setup and go straight to deploying your app.