VPS Security Hardening Manual
SSH Hardening, Root Access Lockdown & API Key Management
For OpenClaw / Skills-as-SaaS Deployments
February 2026
1. Introduction & Threat Model
2. SSH Hardening
2.1 Generate SSH Key Pair
2.2 Copy Public Key to Server
2.3 Configure SSHD
2.4 Set Up a Firewall (UFW)
2.5 Install Fail2Ban
3. Disable Password-Based Root Access
3.1 Create a Non-Root Admin User
3.2 Lock Down Root
3.3 Verify the Lockdown
4. Treat API Keys as Production Credentials
4.1 Never Hardcode Keys
4.2 Environment Variables & .env Files
4.3 Secrets Manager Options
4.4 Key Rotation Strategy
4.5 Monitoring & Auditing
5. Quick-Reference Checklists
6. Emergency Response Playbook
When you deploy a VPS to host an OpenClaw backend service, a trading bot, or any skill-as-SaaS application, you are placing a publicly reachable computer on the internet. Automated bots will begin probing your server within minutes of it going live. This manual covers the three most critical layers of defense you need from day one.
⚠ WARNING: A misconfigured VPS can expose your API keys, customer data, and the server itself to attackers. Do not skip any section.
| Threat | How It Happens | What You Lose |
|---|---|---|
| Brute-force SSH | Bots try thousands of password combos | Full server control |
| Leaked API keys | Keys committed to Git or left in code | Billing fraud, data theft |
| Root compromise | Attacker gains root via weak password | Total system takeover |
| Privilege escalation | Non-root exploit chains to root | Lateral movement, data exfil |
SSH is the front door to your server. By default, most VPS providers ship with password authentication enabled and root login allowed. This section locks that down.
SSH keys use public-key cryptography. The private key stays on your machine; the public key goes on the server. No password ever crosses the network.
On macOS / Linux:
ssh-keygen -t ed25519 -C "your_email@example.com"
# When prompted:
# File: press Enter for default (~/.ssh/id_ed25519)
# Passphrase: enter a STRONG passphrase (adds 2nd factor)
On Windows (PowerShell):
ssh-keygen -t ed25519 -C "your_email@example.com"
# Keys saved to C:\Users\YourName\.ssh\id_ed25519
✅ TIP: Always use ed25519 over RSA. It is faster, more secure, and produces shorter keys.
What this creates:
| File | Purpose | Share It? |
|---|---|---|
| id_ed25519 | Private key (YOUR SECRET) | NEVER |
| id_ed25519.pub | Public key | Yes – goes on server |
Method A – ssh-copy-id (easiest):
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@YOUR_SERVER_IP
Method B – Manual (if ssh-copy-id unavailable):
# On your local machine, display the public key:
cat ~/.ssh/id_ed25519.pub
# SSH into the server and paste it:
ssh root@YOUR_SERVER_IP
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo 'PASTE_YOUR_PUBLIC_KEY_HERE' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
Test the key before proceeding:
ssh -i ~/.ssh/id_ed25519 root@YOUR_SERVER_IP
# Should log in WITHOUT asking for a password
Now that key-based login works, disable everything else.
Edit the config file:
sudo nano /etc/ssh/sshd_config
Find and change these lines (or add them if missing):
# --- CRITICAL SETTINGS ---
# Disable password authentication entirely
PasswordAuthentication no
# Disable root login (we will create a separate admin user)
PermitRootLogin no
# Disable challenge-response (another password vector)
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
# Only allow SSH protocol 2
Protocol 2
# Limit login attempts per connection
MaxAuthTries 3
# Disconnect idle sessions after 5 minutes
ClientAliveInterval 300
ClientAliveCountMax 0
# Disable X11 forwarding (not needed for servers)
X11Forwarding no
# Disable empty passwords
PermitEmptyPasswords no
# --- OPTIONAL: Change default port ---
# Port 2222 # Reduces noise from automated bots
⚠ IMPORTANT: Keep your current SSH session open until you verify the new config works in a SECOND terminal window.
Restart SSH and test:
sudo systemctl restart sshd
# In a NEW terminal (keep old one open!):
ssh -i ~/.ssh/id_ed25519 your_admin_user@YOUR_SERVER_IP
UFW (Uncomplicated Firewall) blocks all ports except the ones you explicitly allow.
# Allow SSH (change 22 to your custom port if applicable)
sudo ufw allow 22/tcp
# Allow your app port (e.g., OpenClaw backend on 3000)
sudo ufw allow 3000/tcp
# Allow HTTPS if you run a landing page
sudo ufw allow 443/tcp
# Enable the firewall
sudo ufw enable
# Verify
sudo ufw status verbose
Fail2Ban monitors log files and automatically bans IPs that show malicious signs (like repeated failed SSH logins).
sudo apt update && sudo apt install fail2ban -y
# Create local config (never edit the main file)
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
Key settings to adjust in jail.local:
[sshd]
enabled = true
port = 22 # Match your SSH port
maxretry = 3 # Ban after 3 failed attempts
bantime = 3600 # Ban for 1 hour (seconds)
findtime = 600 # Within 10-minute window
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# Check status
sudo fail2ban-client status sshd
The root user has unrestricted access to everything on the server. If an attacker gets root, they own the machine. The strategy here is simple: never log in as root. Instead, create a dedicated admin user with sudo privileges.
# While still logged in as root:
adduser deployer
# Set a strong password when prompted
# Grant sudo privileges
usermod -aG sudo deployer
# Copy your SSH key to the new user
mkdir -p /home/deployer/.ssh
cp ~/.ssh/authorized_keys /home/deployer/.ssh/
chown -R deployer:deployer /home/deployer/.ssh
chmod 700 /home/deployer/.ssh
chmod 600 /home/deployer/.ssh/authorized_keys
Test login as the new user (in a new terminal):
ssh -i ~/.ssh/id_ed25519 deployer@YOUR_SERVER_IP
# Verify sudo works:
sudo whoami
# Should output: root
Step A: Disable root SSH login
You already set this in sshd_config above, but verify it:
sudo grep PermitRootLogin /etc/ssh/sshd_config
# Must show: PermitRootLogin no
Step B: Lock the root password
# This prevents ANY password-based root login, even on console
sudo passwd -l root
Step C: Restrict su to admin group only
sudo dpkg-statoverride --update --add root sudo 4750 /bin/su
Run these checks to confirm everything is properly locked down:
| Test | Expected Result |
|---|---|
| ssh root@SERVER_IP | Permission denied (publickey) |
| ssh -o PubkeyAuth=no deployer@SERVER_IP | Permission denied (publickey) |
| sudo passwd -S root | root L ... (L means locked) |
| sudo fail2ban-client status sshd | Shows active jail with ban count |
API keys for OpenAI, Anthropic, OANDA, Stripe, and any other service are as sensitive as passwords. A leaked key can drain your account balance, expose customer data, or allow attackers to impersonate your service.
⚠ CRITICAL: If you accidentally push an API key to a public GitHub repo, consider it compromised IMMEDIATELY. Rotate the key, then clean the Git history.
The single most common mistake. Never put API keys directly in your source code.
| ❌ WRONG (Hardcoded) | ✅ RIGHT (Environment Var) |
|---|---|
| api_key = "sk-abc123..." | api_key = os.environ["OPENAI_KEY"] |
Option A: System environment variables (simplest for VPS)
# Add to /home/deployer/.bashrc or .profile:
export OPENAI_API_KEY="sk-your-key-here"
export OANDA_API_KEY="your-oanda-key"
export STRIPE_SECRET_KEY="sk_live_your-stripe-key"
# Reload:
source ~/.bashrc
# In your app (Python):
import os
api_key = os.environ["OPENAI_API_KEY"]
# In your app (Node.js):
const apiKey = process.env.OPENAI_API_KEY;
Option B: .env file with dotenv library
# Create .env in your project root:
OPENAI_API_KEY=sk-your-key-here
OANDA_API_KEY=your-oanda-key
STRIPE_SECRET_KEY=sk_live_your-stripe-key
DATABASE_URL=postgresql://user:pass@localhost/mydb
⚠ CRITICAL: Add .env to your .gitignore IMMEDIATELY. Create .env.example with placeholder values for teammates.
# .gitignore - add these lines:
.env
.env.local
.env.production
*.pem
*.key
Set proper file permissions on .env:
chmod 600 .env # Only owner can read/write
chown deployer:deployer .env
When you move beyond a single VPS, consider a dedicated secrets manager:
| Tool | Best For | Cost | Complexity |
|---|---|---|---|
| System env vars | Single VPS | Free | Low |
| .env + dotenv | Small projects | Free | Low |
| Docker secrets | Containerized apps | Free | Medium |
| AWS Secrets Manager | AWS deployments | ~$0.40/secret/mo | Medium |
| HashiCorp Vault | Multi-env / team | Free (self-host) | High |
Rotate keys regularly, and always rotate immediately if a compromise is suspected.
# Create a simple key-rotation-log.md:
| Key | Last Rotated | Next Due | Owner |
|--------------|-------------|------------|----------|
| OPENAI_KEY | 2026-02-01 | 2026-05-01 | deployer |
| STRIPE_KEY | 2026-02-15 | 2026-03-15 | deployer |
| OANDA_KEY | 2026-01-10 | 2026-04-10 | deployer |
Monitor API usage for anomalies:
Audit key access:
# Check who has read access to your .env file:
ls -la .env
# Search for any hardcoded keys in your codebase:
grep -rn "sk-" --include="*.py" --include="*.js" .
grep -rn "sk_live" --include="*.py" --include="*.js" .
# Use git-secrets to prevent committing keys:
# https://github.com/awslabs/git-secrets
git secrets --install
git secrets --register-aws
git secrets --add 'sk-[a-zA-Z0-9]{20,}' # OpenAI pattern
If you suspect a security breach, act fast. Here is your step-by-step response plan.
✅ REMEMBER: Leaked keys must be treated as compromised even if you have no evidence of misuse. Always revoke and rotate.
End of Manual