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
- Purchase your desired machine
- Set up SSH key authentication (avoid password login)
- Use a strong passphrase. Ideally non-memorable
- Store the key securely and privately
- 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.
- Configure the provider-supplied SSH key
- Place in
~/.sshwith configured name - Test login:
ssh root@<vps ip> - Use
-i ~/.ssh/key-locationif passphrase prompt doesn’t appear
- Place in
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.
SSH configuration
sudo nano /etc/ssh/sshd_config # Set: # - PermitRootLogin no # - PasswordAuthentication no # - Port <custom port 1024-65535> sudo systemctl restart sshAutomatic 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-upgradesFail2ban 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 fail2banUFW 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.
Set global DNS challenges in Caddyfile
tls { dns cloudflare {.env.CF_TOKEN} }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/caddyUpdate systemd service
sudo systemctl edit caddy.service # Add: [Service] User=caddy Group=caddy EnvironmentFile=/etc/caddy/caddy.envSet 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...