How to Lock Down and Harden a Fresh Linux VPS (Ubuntu / Debian)

5 min read

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 -t before 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

Tags:

← Previous:
Next: →