It’s becoming increasingly popular to reduce abstractions in your stack. A lot of people are ditching all-in-one cloud providers for a single VPS, and others are starting 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. Here’s a write up on hardening to help ensure the new machine is at least a bit safer.
Initial setup
VPS provider console
- Purchase your desired machine
- Set up SSH key authentication (avoid password login)
- Use a strong, non-memorable passphrase
- 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
- Place the provider-supplied SSH key in
~/.sshwith a configured name - Test login:
ssh root@<vps ip> - Use
-i ~/.ssh/key-locationif the passphrase prompt doesn’t appear
Server configuration
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
Generate a new key pair 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
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys # paste clipboard contents
chmod 600 ~/.ssh/authorized_keys
exit
SSH hardening
sudo nano /etc/ssh/sshd_config
# Set:
# - PermitRootLogin no
# - PasswordAuthentication no
# - Port <custom port 1024-65535>
sudo systemctl restart ssh
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
Fail2ban
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
UFW 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
sudo ufw limit <custom ssh port>/tcp comment 'rate limit'
Web server setup: Caddy + Cloudflare
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
Building Caddy with the Cloudflare DNS module lets you obtain SSL certificates through DNS challenges without needing ports 80/443 open during issuance.
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
Set global DNS challenges in Caddyfile:
tls {
dns cloudflare {.env.CF_TOKEN}
}
Create a dedicated 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
Update the systemd service:
sudo systemctl edit caddy.service
# Add:
[Service]
User=caddy
Group=caddy
EnvironmentFile=/etc/caddy/caddy.env
Set permissions:
sudo setcap cap_net_bind_service=+ep $(which caddy)
sudo systemctl daemon-reload
sudo systemctl restart caddy
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
This script restricts ports 80 and 443 to only Cloudflare’s IP ranges.
#!/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
#!/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...