Secure VPS Setup
A production-ready VPS security configuration with Tailscale-only SSH access and CrowdSec intrusion prevention.
Overview
This guide configures three security layers:
| Layer | Protection |
|---|---|
| SSH | Tailscale only (invisible to internet) |
| Firewall | firewalld with DROP policy |
| IPS | CrowdSec with community blocklists |
Prerequisites
- Fresh Ubuntu 24.04 VPS
- Tailscale account
- Initial SSH access via public IP (temporary)
- Cloudflare account for HTTPS termination
Step 1: Initial System Hardening
Before anything else, enable automatic security updates and time sync:
# Automatic security updates
apt update && apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
# Verify time sync (enabled by default on Ubuntu 24.04)
timedatectl set-ntp true
timedatectl status
# Basic kernel hardening
cat > /etc/sysctl.d/99-security.conf << 'EOF'
# SYN flood protection
net.ipv4.tcp_syncookies = 1
# Reverse path filtering (anti-spoofing)
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Disable ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
# Ignore ICMP broadcasts
net.ipv4.icmp_echo_ignore_broadcasts = 1
EOF
sysctl --system
Step 2: Install Tailscale
Important: Use the official repository, NOT the Snap package (has AppArmor issues with Tailscale SSH on Ubuntu 24.04).
# Add official Tailscale repository
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.noarmor.gpg | \
tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/noble.tailscale-keyring.list | \
tee /etc/apt/sources.list.d/tailscale.list
# Install
apt update
apt install -y tailscale
Step 3: Connect Tailscale with SSH
tailscale up --ssh
This outputs an authentication URL. Open it and authenticate with your Tailscale account.
Important: After connecting, go to https://login.tailscale.com/admin/machines and disable key expiry for this server to prevent lockouts.
Step 4: Configure firewalld
# Install firewalld
apt install -y firewalld
# Enable and start
systemctl enable --now firewalld
# Trust Tailscale interface (allows all traffic from your tailnet)
firewall-cmd --permanent --zone=trusted --add-interface=tailscale0
# Allow Tailscale UDP port (WireGuard)
firewall-cmd --permanent --add-port=41641/udp
# Allow web traffic (for Gordon reverse proxy)
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
# Apply changes
firewall-cmd --reload
Note: Gordon's registry is proxied through port 80/443 via
gordon_domain. Port 5000 does NOT need to be exposed publicly.
Step 5: Verify Tailscale SSH Works
From another machine on your tailnet:
# Get the server's Tailscale hostname
tailscale status
# Connect from your local machine
ssh root@<tailscale-hostname>
# or
tailscale ssh root@<tailscale-hostname>
Step 6: Block Public SSH
Only proceed once Tailscale SSH is confirmed working.
# Remove SSH from public zone
firewall-cmd --permanent --remove-service=ssh
# Set default policy to DROP all unmatched traffic
firewall-cmd --permanent --zone=public --set-target=DROP
# Apply changes
firewall-cmd --reload
Verify public SSH is blocked:
# From another machine (not on tailnet)
ssh -o ConnectTimeout=5 root@<public-ip> # Should timeout
Step 6.5: Restrict Registry and Auth to Tailnet CIDR
If you want /auth/* and registry (/v2/*) reachable only from your tailnet, set Gordon's registry allowlist to the Tailscale CGNAT range.
Edit ~/.config/gordon/gordon.toml:
[server]
registry_allowed_ips = ["100.64.0.0/10"]
Apply the change:
systemctl --user restart gordon
Behavior:
- Requests from tailnet IPs (
100.64.0.0/10) are allowed. - Requests outside that CIDR get
403 Forbiddenon registry/auth endpoints. - App routes still use normal host-based routing through your proxy ports.
For private operations (auth, push/pull, admin API), keep using the HTTPS registry domain, but resolve that domain to the VPS tailnet IP (for example, via Cloudflare DNS to the tailnet address). With self-signed certs, configure the CLI remote with insecure TLS enabled.
# Example: save remote once
gordon remotes add tailnet-reg https://gordon.example.com --token-env GORDON_TOKEN --insecure
gordon remotes use tailnet-reg
# Then use commands normally (auth + admin API)
gordon auth login
gordon routes list
gordon backup status
~/.config/gordon/remotes.toml should contain an entry like:
active = "tailnet-reg"
[remotes.tailnet-reg]
url = "https://gordon.example.com"
token_env = "GORDON_TOKEN"
insecure_tls = true
This keeps transport on HTTPS while allowing self-signed certificates in tailnet-only deployments.
Step 7: Install CrowdSec
# Add CrowdSec repo (NOT Ubuntu's outdated version)
curl -s https://install.crowdsec.net | bash
# Install CrowdSec
apt update
apt install -y crowdsec
Step 8: Install nftables Bouncer
Ubuntu 24.04 uses nftables by default:
apt install -y crowdsec-firewall-bouncer-nftables
systemctl enable --now crowdsec-firewall-bouncer
Step 9: Install Security Collections
# HTTP protection
cscli collections install crowdsecurity/http-cve
cscli collections install crowdsecurity/base-http-scenarios
# Reload to apply
systemctl reload crowdsec
Step 10: Whitelist Tailscale IPs
Prevent CrowdSec from blocking Tailscale traffic:
cat > /etc/crowdsec/parsers/s02-enrich/tailscale-whitelist.yaml << 'EOF'
name: tailscale-whitelist
description: "Whitelist Tailscale CGNAT range"
whitelist:
reason: "Tailscale internal traffic"
cidr:
- "100.64.0.0/10"
EOF
systemctl reload crowdsec
Step 11: Enroll in CrowdSec Console
This enables community blocklists - IPs blocked across all CrowdSec users globally (15,000+ malicious IPs).
- Create account at https://app.crowdsec.net
- Go to Security Engines → Add Security Engine
- Copy enrollment key and run:
cscli console enroll <your-enrollment-key>
- Accept the enrollment in the CrowdSec console
- Enable console management:
systemctl restart crowdsec
cscli console enable console_management
systemctl reload crowdsec
Verification
# Firewall status
firewall-cmd --state
firewall-cmd --list-all
firewall-cmd --list-all --zone=trusted
# CrowdSec status
cscli metrics
cscli bouncers list
cscli console status
# Verify nftables has CrowdSec rules
nft list ruleset | grep -A5 'crowdsec'
# View blocked IPs count
cscli decisions list | wc -l
Port Summary
| Port | Protocol | Status | Purpose |
|---|---|---|---|
| 22 | TCP | Closed | SSH (use Tailscale instead) |
| 80 | TCP | Open | HTTP (Gordon proxy) |
| 443 | TCP | Open | HTTPS (Gordon proxy via Cloudflare) |
| 5000 | TCP | Closed | Registry (proxied through 80/443, containers bind to 127.0.0.1) |
| 41641 | UDP | Open | Tailscale WireGuard |
Security Note: All managed containers bind to
127.0.0.1by default, preventing direct access to their ports. The only entry point to your applications is Gordon's reverse proxy on ports 80/443, where authentication and rate limiting are enforced.
Troubleshooting
Can't SSH via Tailscale
Check for ACL issues:
journalctl -u tailscaled --since "10 minutes ago" | grep -i "rejected\|acl"
If you see rejected due to acl, check your Tailscale ACLs at https://login.tailscale.com/admin/acls
Locked out completely
Use your VPS provider's console access (e.g., Hetzner Cloud Console):
firewall-cmd --add-service=ssh # Temporarily re-enable public SSH
CrowdSec not blocking
Verify bouncer is running:
systemctl status crowdsec-firewall-bouncer
cscli bouncers list # Should show "Valid"
Check what's being blocked
cscli decisions list
cscli alerts list
Next Steps
- Install Gordon
- Podman Rootless Setup - Enhanced container isolation
- Cloudflare DNS & Proxy Setup - HTTPS termination (coming soon)