Simple VPS hardening

home

· Tacoma

It’s becoming increasingly popular to reduce abstractions in your stack. This includes a lot of people ditching all in one cloud providers for a single VPS for cost benefits and some others starting large full stack projects with vanilla PHP and self-hosting everything down to the db.

I personally enjoy this push, but think it could be risky since few places show the full process of what configuring a new VPS really looks like. I wanted to write up on hardening to help ensure the new machine is at least a bit safer.

Note: This post will be updated. Some things are not fully explained yet and the themes below should be included:

 

  • Alerting and logging
  • Intrusion detection
  • Disk encryption
  • Service disabling
  • Detailed web server hardening configurations

 

I also want to compile most of this into an easy to use script eventually.

Initial setup

Before touching the server itself, we need to handle some basics through your VPS provider’s control panel and prepare your local machine. This ensures you have secure access from the start and aren’t exposing unnecessary ports to the internet.

VPS provider console

  1. Purchase your desired machine
  2. Set up SSH key authentication (avoid password login)
    • Use a strong passphrase. Ideally non-memorable
    • Store the key securely and privately
  3. Configure firewall rules
    • Allow 443/tcp and 80/tcp from any IPv4/IPv6
    • Allow 22/tcp from your IP only

Local machine setup

The provider-supplied SSH key needs to be properly configured on your local machine. This is a one-time setup that ensures you can securely access your new server.

  1. Configure the provider-supplied SSH key
    • Place in ~/.ssh with configured name
    • Test login: ssh root@<vps ip>
    • Use -i ~/.ssh/key-location if passphrase prompt doesn’t appear

Server configuration

Now we’ll set up the server itself. The first priority is creating a non-root user for daily operations and configuring their SSH access. This significantly reduces the risk of accidental system-wide changes and provides better security than using the root account.

First login as root

apt update && apt upgrade
adduser <your username>
usermod -aG sudo <your username>
su - <your username>
sudo apt update
exit 
exit # close ssh connection

Local machine: new user SSH setup

With our new user created, we need to set up proper SSH authentication for them. We’ll generate a new key pair specifically for this user rather than reusing the root key.

# generate new key pair
ssh-keygen -t ed25519 -C "[email protected]"

# copy to clipboard
pbcopy < ~/.ssh/id_ed25519.pub

# reconnect to server
ssh <your username>@<vps ip>

Server: configure SSH

The new user needs their SSH directory and authorized keys configured with proper permissions to ensure secure access.

mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys  # paste clipboard contents
chmod 600 ~/.ssh/authorized_keys
exit

Security hardening

With basic access configured, we can now implement several important security measures. These steps help protect against common attack vectors and ensure the system stays updated against vulnerabilities.

  1. SSH configuration

    sudo nano /etc/ssh/sshd_config
    
    # Set:
    # - PermitRootLogin no
    # - PasswordAuthentication no
    # - Port <custom port 1024-65535>
    
    sudo systemctl restart ssh
    
  2. Automatic security updates

    # Install and configure automatic security updates
    sudo apt install unattended-upgrades apt-listchanges
    sudo dpkg-reconfigure -plow unattended-upgrades
    sudo unattended-upgrades --dry-run --debug
    sudo systemctl enable unattended-upgrades
    sudo systemctl start unattended-upgrades
    sudo systemctl status unattended-upgrades
    
  3. Fail2ban setup

    sudo apt install fail2ban
    sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
    sudo nano /etc/fail2ban/jail.local
    
    # Configure [sshd] section:
    # - enabled = true
    # - port = <custom ssh port>
    # - logpath = systemd-journal
    # - backend = systemd
    
    sudo usermod -aG systemd-journal fail2ban
    sudo systemctl restart fail2ban
    
  4. UFW firewall

    # install and configure firewall
    sudo apt install ufw -y
    
    sudo ufw --force reset
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow <custom ssh port>/tcp
    sudo ufw allow 443/tcp
    sudo ufw allow 80/tcp
    
    # Add rate limiting for SSH
    sudo ufw limit <custom ssh port>/tcp comment 'rate limit'
    

Web server setup: Caddy + Cloudflare

For serving web content, we’ll use Caddy with Cloudflare integration. This combination provides excellent security out of the box, automatic HTTPS, and protection against DDoS attacks. We’ll start by installing Go, which is required for building Caddy with custom modules.

Go installation

wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc

Caddy installation

We’ll build Caddy with the Cloudflare DNS module to enable automatic SSL certificate generation through DNS challenges. This lets us to obtain certificates without ports 80/443 being fully open during the certificate issuance process, though we’ll still need them open later to serve traffic.

# install xcaddy
wget https://github.com/caddyserver/xcaddy/releases/download/v0.4.2/xcaddy_0.4.2_linux_amd64.deb
sudo dpkg -i xcaddy_0.4.2_linux_amd64.deb
sudo apt-get install -f

# build Caddy with Cloudflare module
xcaddy build --with github.com/caddy-dns/cloudflare

# install to system
sudo mv caddy /usr/bin/caddy
sudo chown root:root /usr/bin/caddy 
sudo chmod 755 /usr/bin/caddy
sudo caddy reload

Caddy configuration

Proper configuration of Caddy involves setting up DNS challenges, creating a dedicated service user, and configuring appropriate permissions. This ensures the web server runs with minimal necessary privileges.

  1. Set global DNS challenges in Caddyfile

    tls {
        dns cloudflare {.env.CF_TOKEN}
    }
    
  2. Configure a caddy user

    sudo useradd -r -d /var/lib/caddy -s /usr/sbin/nologin caddy
    sudo chown -R caddy:caddy /etc/caddy
    sudo chown -R caddy:caddy /var/lib/caddy
    
  3. Update systemd service

    sudo systemctl edit caddy.service
    
    # Add:
    
    [Service] 
    User=caddy
    Group=caddy
    EnvironmentFile=/etc/caddy/caddy.env
    
  4. Set permissions

    sudo setcap cap_net_bind_service=+ep $(which caddy)
    sudo systemctl daemon-reload
    sudo systemctl restart caddy
    
    # Configure web content access
    sudo groupadd webcontent
    sudo usermod -a -G webcontent <your username>
    sudo usermod -a -G webcontent caddy
    sudo chgrp webcontent /home/<your username>
    sudo chmod g+x /home/<your username>
    sudo systemctl restart caddy
    

Cloudflare IP restriction script

We can also help ensure web traffic only comes through Cloudflare. This script configures the firewall to allow only Cloudflare’s IP ranges to access ports 80 and 443.

#!/bin/bash

log() {
  echo "[INFO] $1"
}

deny_other_ips() {
  log "denying 80 and 443 access..."
  ufw deny to any port 80 comment "deny all access to port 80"
  ufw deny to any port 443 comment "deny all access to port 443"
}

allow_cloudflare_ips() {
  local ipv4_ips=$(curl -s https://www.cloudflare.com/ips-v4)
  local ipv6_ips=$(curl -s https://www.cloudflare.com/ips-v6)
  log "configuring ufw rules for cloudflare only on 80 and 443..."
  
  for ip in $ipv4_ips; do
    ufw allow proto tcp from "$ip" to any port 80,443 comment "cloudflare ip $ip"
  done
  
  for ip in $ipv6_ips; do
    ufw allow proto tcp from "$ip" to any port 80,443 comment "cloudflare ip $ip"
  done
}

enable_ufw() {
  log "enabling ufw..."
  if ! ufw --force enable; then
    log "failed to enable ufw."
    exit 1
  fi
  log "ufw enabled."
}

main() {
  deny_other_ips
  allow_cloudflare_ips
  enable_ufw
}

main

Kernel hardening

Finally, we’ll apply some kernel-level security settings that help protect against various network-based attacks and optimize system performance.

#!/bin/bash

sudo tee /etc/sysctl.d/99-security.conf > /dev/null <<EOT
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
fs.file-max = 65535
net.ipv4.ip_local_port_range = 2000 65000
net.core.rmem_max = 67108864
net.core.wmem_max = 67108864
net.core.netdev_max_backlog = 65536
EOT

sudo sysctl -p /etc/sysctl.d/99-security.conf

Comments

Loading comments...