skills/deployment/hetzner-setup/SKILL.md
Use this skill when the user says 'Hetzner', 'VPS setup', 'server provisioning', 'deploy to VPS', 'hetzner-setup', 'cloud server', or needs to provision, harden, and deploy applications to a Hetzner Cloud server with Docker, Nginx/Caddy, SSL, and monitoring. Do NOT use for managed platform deployments like Railway or Netlify.
npx skillsauth add cwinvestments/memstack memstack-deployment-hetzner-setupInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Provision a Hetzner Cloud server with security hardening, reverse proxy, SSL, database setup, monitoring, and automated backups.
When this skill activates, output:
🖥️ Hetzner Setup — Provisioning your server...
| Context | Status | |---------|--------| | User says "Hetzner", "VPS setup", "server provisioning" | ACTIVE | | User wants to deploy to a cloud server (Hetzner specifically) | ACTIVE | | User mentions SSH hardening, fail2ban, or server security | ACTIVE | | User wants Docker containerization guidance | DORMANT — see docker-setup | | User wants CI/CD pipeline (not server setup) | DORMANT — see ci-cd-pipeline | | User wants managed hosting (Railway, Netlify, Vercel) | DORMANT — see railway-deploy or netlify-deploy |
Ask the user for:
Match workload to Hetzner Cloud server:
| Instance | vCPU | RAM | SSD | Traffic | Price | Best For | |----------|------|-----|-----|---------|-------|----------| | CX22 | 2 | 4 GB | 40 GB | 20 TB | ~$4.5/mo | Small API, static site, hobby project | | CX32 | 4 | 8 GB | 80 GB | 20 TB | ~$8/mo | Medium web app, small DB | | CPX31 | 4 | 8 GB | 160 GB | 20 TB | ~$13/mo | High-perf apps (AMD EPYC), scrapers | | CPX41 | 8 | 16 GB | 240 GB | 20 TB | ~$24/mo | Large apps, multiple services | | CPX51 | 16 | 32 GB | 360 GB | 20 TB | ~$47/mo | Heavy workloads, large databases | | CCX13 | 2 | 8 GB | 80 GB | 20 TB | ~$13/mo | Dedicated vCPU — consistent performance |
Recommendation logic:
Include: location recommendation (Nuremberg for EU, Ashburn for US).
Generate a complete setup script:
#!/bin/bash
# Hetzner Server Initial Setup
# Run as root on fresh Ubuntu 22.04/24.04 LTS
set -euo pipefail
echo "=== SYSTEM UPDATE ==="
apt update && apt upgrade -y
apt install -y curl wget git htop ufw fail2ban unattended-upgrades \
apt-listchanges software-properties-common
echo "=== CREATE DEPLOY USER ==="
useradd -m -s /bin/bash deploy
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
usermod -aG sudo deploy
# Set password for sudo (optional, SSH key preferred)
echo "deploy ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/deploy
echo "=== SSH HARDENING ==="
cat > /etc/ssh/sshd_config.d/hardened.conf << 'SSHEOF'
Port 2222
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers deploy
SSHEOF
systemctl restart sshd
echo "=== FIREWALL (UFW) ==="
ufw default deny incoming
ufw default allow outgoing
ufw allow 2222/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw --force enable
echo "=== FAIL2BAN ==="
cat > /etc/fail2ban/jail.local << 'F2BEOF'
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
backend = systemd
[sshd]
enabled = true
port = 2222
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 86400
F2BEOF
systemctl enable fail2ban
systemctl restart fail2ban
echo "=== UNATTENDED UPGRADES ==="
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'UUEOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
UUEOF
echo "=== SWAP (for low-RAM instances) ==="
if [ $(free -m | awk '/^Mem:/{print $2}') -lt 8192 ]; then
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
sysctl vm.swappiness=10
echo 'vm.swappiness=10' >> /etc/sysctl.conf
fi
echo "=== KERNEL TUNING ==="
cat >> /etc/sysctl.conf << 'KERNEOF'
# Network performance
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
# Security
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
KERNEOF
sysctl -p
echo "=== SETUP COMPLETE ==="
echo "SSH port changed to 2222"
echo "Root login disabled"
echo "Deploy user created with SSH key"
echo "Firewall enabled (ports: 2222, 80, 443)"
echo "Fail2ban active"
echo "Unattended upgrades enabled"
IMPORTANT: After running this script, reconnect using:
ssh -p 2222 deploy@[server-ip]
Option A: Docker deployment (recommended):
# Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker deploy
# Install Docker Compose
apt install -y docker-compose-plugin
# Create app directory
mkdir -p /opt/app
chown deploy:deploy /opt/app
See docker-setup skill for Dockerfile and docker-compose.yml generation.
Option B: Direct deployment (Node.js example):
# Install Node.js via nvm
su - deploy
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install --lts
# Clone and setup
cd /opt/app
git clone [repo-url] .
npm ci --production
# PM2 process manager
npm install -g pm2
pm2 start ecosystem.config.js
pm2 save
pm2 startup
PM2 ecosystem config:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'app',
script: 'dist/index.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
max_memory_restart: '500M',
error_file: '/var/log/app/error.log',
out_file: '/var/log/app/out.log',
merge_logs: true,
}],
};
Option A: Caddy (auto-SSL, simplest):
apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy
# /etc/caddy/Caddyfile
example.com {
reverse_proxy localhost:3000
encode gzip zstd
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/access.log
format json
}
}
Option B: Nginx + Certbot:
apt install -y nginx certbot python3-certbot-nginx
# /etc/nginx/sites-available/app
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
# Get SSL certificate
certbot --nginx -d example.com
# Auto-renewal is configured automatically by certbot
PostgreSQL:
apt install -y postgresql postgresql-contrib
sudo -u postgres createuser --interactive appuser
sudo -u postgres createdb appdb -O appuser
sudo -u postgres psql -c "ALTER USER appuser PASSWORD 'CHANGE_ME_STRONG_PASSWORD';"
# Listen only on localhost (default — safe)
# For remote access, use SSH tunnel, never expose port 5432
Redis:
apt install -y redis-server
# Secure Redis
sed -i 's/^# requirepass.*/requirepass CHANGE_ME_STRONG_PASSWORD/' /etc/redis/redis.conf
sed -i 's/^bind .*/bind 127.0.0.1 ::1/' /etc/redis/redis.conf
systemctl restart redis-server
Connection security:
127.0.0.1Resource monitoring script:
#!/bin/bash
# /opt/scripts/health-check.sh
# Disk usage alert (>85%)
DISK_USAGE=$(df / | awk 'NR==2{print $5}' | sed 's/%//')
if [ "$DISK_USAGE" -gt 85 ]; then
echo "ALERT: Disk usage at ${DISK_USAGE}%"
# Send notification (webhook, email, etc.)
curl -X POST "https://hooks.slack.com/services/XXX" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"🔴 Disk usage at ${DISK_USAGE}% on $(hostname)\"}"
fi
# Memory usage alert (>90%)
MEM_USAGE=$(free | awk '/Mem:/{printf "%.0f", $3/$2 * 100}')
if [ "$MEM_USAGE" -gt 90 ]; then
echo "ALERT: Memory usage at ${MEM_USAGE}%"
curl -X POST "https://hooks.slack.com/services/XXX" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"🟡 Memory usage at ${MEM_USAGE}% on $(hostname)\"}"
fi
# Check if app is responding
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health)
if [ "$HTTP_CODE" != "200" ]; then
echo "ALERT: App health check failed (HTTP $HTTP_CODE)"
curl -X POST "https://hooks.slack.com/services/XXX" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"🔴 App health check failed on $(hostname) — HTTP ${HTTP_CODE}\"}"
fi
# Add to crontab: every 5 minutes
crontab -e
*/5 * * * * /opt/scripts/health-check.sh >> /var/log/health-check.log 2>&1
External uptime monitoring:
Log management:
# Logrotate for application logs
cat > /etc/logrotate.d/app << 'LREOF'
/var/log/app/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
copytruncate
}
LREOF
Hetzner snapshots (server-level):
# Create snapshot via Hetzner CLI
hcloud server create-image --type snapshot --description "Weekly backup $(date +%F)" [server-id]
# Automate weekly snapshots
# Add to crontab (Sunday 3 AM):
0 3 * * 0 hcloud server create-image --type snapshot --description "auto-$(date +\%F)" [server-id]
# Retention: Keep last 4 snapshots, delete older
# Snapshot cost: ~$0.012/GB/month
Database backups (daily):
#!/bin/bash
# /opt/scripts/backup-db.sh
BACKUP_DIR="/opt/backups/db"
RETENTION_DAYS=14
DATE=$(date +%F_%H%M)
mkdir -p "$BACKUP_DIR"
# PostgreSQL
pg_dump -U appuser -h localhost appdb | gzip > "$BACKUP_DIR/appdb_${DATE}.sql.gz"
# Optional: Upload to off-site storage (Hetzner Storage Box or S3)
# rsync -avz "$BACKUP_DIR/" [email protected]:backups/
# Cleanup old backups
find "$BACKUP_DIR" -type f -mtime +${RETENTION_DAYS} -delete
echo "Backup complete: appdb_${DATE}.sql.gz"
# Daily at 2 AM
0 2 * * * /opt/scripts/backup-db.sh >> /var/log/backup.log 2>&1
Off-site backup options:
Final security hardening verification:
── SERVER HARDENING CHECKLIST ─────────────
SSH:
[x] Root login disabled
[x] Password authentication disabled
[x] SSH port changed from 22
[x] Key-based auth only
[x] MaxAuthTries set to 3
[x] Idle timeout configured
Firewall:
[x] UFW enabled with deny-by-default
[x] Only required ports open (SSH, HTTP, HTTPS)
[x] No database ports exposed
Services:
[x] Fail2ban active on SSH
[x] Unattended security upgrades enabled
[x] Unused services disabled
[x] No unnecessary packages installed
Application:
[x] App runs as non-root user (deploy)
[x] Environment variables for secrets (not in code)
[x] Database listens on localhost only
[x] Redis password set, localhost only
Monitoring:
[x] Disk/memory/CPU alerts configured
[x] App health check endpoint monitored
[x] External uptime monitoring active
[x] Log rotation configured
Backups:
[x] Database backed up daily
[x] Server snapshots weekly
[x] Off-site backup configured
[x] Backup restoration tested
Present the complete server setup:
━━━ HETZNER SERVER SETUP ━━━━━━━━━━━━━━━━━
Instance: [type] — [vCPU] vCPU, [RAM] GB RAM
Location: [datacenter]
OS: Ubuntu [version] LTS
Cost: ~$[X]/mo
── PROVISIONING SCRIPT ────────────────────
[complete setup script]
── APPLICATION DEPLOYMENT ─────────────────
Method: [Docker / Direct]
Process manager: [Docker / PM2]
Config: [ecosystem.config.js or docker-compose.yml]
── REVERSE PROXY ──────────────────────────
Server: [Caddy / Nginx]
SSL: Let's Encrypt (auto-renewal)
Config: [Caddyfile or nginx.conf]
── DATABASE ───────────────────────────────
[PostgreSQL/Redis setup]
── MONITORING ─────────────────────────────
Internal: [health check script + cron]
External: [uptime monitoring service]
Alerts: [notification channel]
── BACKUPS ────────────────────────────────
Database: Daily, [retention] days
Snapshots: Weekly, last [N] kept
Off-site: [storage solution]
── HARDENING ──────────────────────────────
[checklist with status]
── DEPLOYMENT COMMANDS ────────────────────
[quick reference for common operations]
tools
Use when the user says 'save diary', 'log session', 'wrapping up', or at end of a productive session.
tools
Use when the user says 'submit to marketplace', 'publish my skill', 'share this skill', 'list on marketplace', 'submit plugin', 'publish to community', or needs to submit a skill or plugin to a community marketplace via PR. Do NOT use for building skills or writing plugin code.
development
Use when the user says 'write browser tests', 'test this page', 'playwright test', 'e2e test', 'end to end test', 'browser test', 'test the UI', or needs Playwright-based browser testing for a web application. Do NOT use for unit tests, API tests, or non-browser testing.
development
Use when the user says 'teach me', 'explain as you go', 'mentor mode', 'walk me through', 'help me learn', 'explain why', 'learning mode', or wants real-time plain language narration of decisions and tradeoffs while building. Do NOT use for code review or debugging.