When deploying a fresh cloud VPS on Ubuntu or Debian, securing the host before exposing services to the public internet is critical. Automated scanners probe default SSH ports within seconds of IP assignment.
This guide outlines a production-tested, idempotent baseline to lock down a fresh Debian or Ubuntu LTS server: creating a non-root sudo user, enforcing cryptographic SSH keys, configuring a strict UFW firewall, and installing fail2ban.
⚡ Quick Runbook: 60-Second Implementation
TL;DR: Copy and deploy the baseline production definition directly onto your host:
# Create the user (replace 'adminuser' with your desired username)
adduser --gecos "" adminuser
# Add to the sudo group
usermod -aG sudo adminuser
# Optional: Configure passwordless sudo for automation
echo "adminuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/99-adminuser
chmod 0440 /etc/sudoers.d/99-adminuser
Step 1: Create a Dedicated Sudo User
Never run daily administration or services directly as the root user. Create a dedicated administrative user and assign passwordless sudo privileges.
# Create the user (replace 'adminuser' with your desired username)
adduser --gecos "" adminuser
# Add to the sudo group
usermod -aG sudo adminuser
# Optional: Configure passwordless sudo for automation
echo "adminuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/99-adminuser
chmod 0440 /etc/sudoers.d/99-adminuser
Step 2: Install Modern Ed25519 SSH Keys
Before disabling password authentication, ensure your public SSH key is installed for the new user. On your local machine, generate an Ed25519 key if you do not already have one:
ssh-keygen -t ed25519 -a 100 -C "adminuser@vps"
Copy the public key to your server:
mkdir -p /home/adminuser/.ssh
chmod 0700 /home/adminuser/.ssh
cat << 'EOF' > /home/adminuser/.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... adminuser@vps
EOF
chmod 0600 /home/adminuser/.ssh/authorized_keys
chown -R adminuser:adminuser /home/adminuser/.ssh
Step 3: Harden OpenSSH Daemon
Modern SSH configurations should use drop-in configuration files under /etc/ssh/sshd_config.d/ rather than editing the main configuration directly.
Create /etc/ssh/sshd_config.d/10-hardening.conf:
# /etc/ssh/sshd_config.d/10-hardening.conf
Port 2222
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers adminuser
Security Tip: Test your SSH configuration syntax with
sshd -tbefore restarting the daemon to prevent accidental lockouts.
# Verify syntax
sshd -t
# Restart SSH service
systemctl restart ssh || systemctl restart sshd
Step 4: Configure UFW (Uncomplicated Firewall)
Set default ingress to drop, egress to allow, and explicitly open your custom SSH port:
apt update && apt install -y ufw
# Set defaults
ufw default deny incoming
ufw default allow outgoing
# Allow hardened SSH port
ufw allow 2222/tcp comment 'SSH'
# If hosting public web traffic (optional)
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
# Enable firewall
ufw --force enable
Step 5: Configure Fail2ban for Brute-Force Defense
Install fail2ban to automatically block IP addresses exhibiting repeated authentication failures:
apt install -y fail2ban
Create /etc/fail2ban/jail.d/10-sshd.local:
# /etc/fail2ban/jail.d/10-sshd.local
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 100.64.0.0/10
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
port = 2222
backend = systemd
Restart and verify the jail status:
systemctl restart fail2ban
fail2ban-client status sshd
Step 6: Automated Security Patches
Keep system packages patched automatically without manual intervention:
apt install -y unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
Automating Host Lockdown with Ansible
If you manage multiple VPS instances or want a fully repeatable Day-0 provisioning pipeline, you can execute this entire hardening baseline declaratively with Ansible:
# roles/security/tasks/main.yml
---
- name: Ensure non-root sudo user exists
ansible.builtin.user:
name: "{{ new_user }}"
uid: 1000
shell: /bin/zsh
groups: sudo
append: true
state: present
create_home: true
- name: Configure passwordless sudo for automation
ansible.builtin.copy:
content: "{{ new_user }} ALL=(ALL) NOPASSWD:ALL
"
dest: "/etc/sudoers.d/10-{{ new_user }}"
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
- name: Deploy authorized SSH public keys
ansible.posix.authorized_key:
user: "{{ new_user }}"
key: "{{ item }}"
state: present
loop: "{{ ssh_pubkeys }}"
- name: Configure hardened SSH drop-in
ansible.builtin.copy:
content: |
Port {{ ssh_port }}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers {{ new_user }}
MaxAuthTries 3
dest: /etc/ssh/sshd_config.d/10-bootstrap.conf
owner: root
group: root
mode: "0644"
validate: "/usr/sbin/sshd -t -f %s"
notify: Restart sshd
- name: Configure UFW default policies
community.general.ufw:
direction: "{{ item.dir }}"
default: "{{ item.policy }}"
loop:
- { dir: incoming, policy: deny }
- { dir: outgoing, policy: allow }
- name: Allow custom SSH port through UFW
community.general.ufw:
rule: allow
port: "{{ ssh_port | string }}"
proto: tcp
- name: Enable UFW firewall
community.general.ufw:
state: enabled
Related Runbooks in This Series
- Base Linux OS Hardening: SSH Ed25519, UFW/nftables & Sysctl Baseline
- Fleet-Wide Intrusion Prevention: Deploying CrowdSec with Docker, Caddy & Firewall Bouncers
- Deploying a Secure WireGuard & Tailscale Exit Node on a VPS for Private Mobile Egress
- Zero-Trust Edge Ingress: Deploying Cloudflare Tunnels for Private Services
- Modern Reverse Proxy Architecture: Caddy Server & Cloudflare Tunnels for Container Ingress