Memstack memstack-deployment-hetzner-setup

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.

install
source · Clone the upstream repo
git clone https://github.com/cwinvestments/memstack
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/cwinvestments/memstack "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/deployment/hetzner-setup" ~/.claude/skills/cwinvestments-memstack-memstack-deployment-hetzner-setup && rm -rf "$T"
manifest: skills/deployment/hetzner-setup/SKILL.md
source content

🖥️ Hetzner Setup — VPS Provisioning & Deployment

Provision a Hetzner Cloud server with security hardening, reverse proxy, SSL, database setup, monitoring, and automated backups.

Activation

When this skill activates, output:

🖥️ Hetzner Setup — Provisioning your server...

ContextStatus
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 securityACTIVE
User wants Docker containerization guidanceDORMANT — 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

Protocol

Step 1: Gather Inputs

Ask the user for:

  • Workload type: Web app, API, database, scraper, background worker?
  • Expected traffic: Low (< 1k/day), medium (1k-50k/day), high (50k+/day)?
  • Stack: Node.js, Python, Go, Rust? Docker or direct?
  • Database needs: PostgreSQL, MySQL, Redis, none?
  • Domain: Do you have a domain to point at this server?
  • Budget preference: Minimum viable or room to grow?

Step 2: Recommend Instance Type

Match workload to Hetzner Cloud server:

InstancevCPURAMSSDTrafficPriceBest For
CX2224 GB40 GB20 TB~$4.5/moSmall API, static site, hobby project
CX3248 GB80 GB20 TB~$8/moMedium web app, small DB
CPX3148 GB160 GB20 TB~$13/moHigh-perf apps (AMD EPYC), scrapers
CPX41816 GB240 GB20 TB~$24/moLarge apps, multiple services
CPX511632 GB360 GB20 TB~$47/moHeavy workloads, large databases
CCX1328 GB80 GB20 TB~$13/moDedicated vCPU — consistent performance

Recommendation logic:

  • Scraper/background worker → CPX31 (AMD EPYC, burst-friendly)
  • Web app with DB → CX32 or CPX31 (depends on traffic)
  • API-heavy service → CPX31 (fast single-thread)
  • Multiple services → CPX41 (headroom for containers)
  • Budget-constrained → CX22 (surprisingly capable)

Include: location recommendation (Nuremberg for EU, Ashburn for US).

Step 3: Initial Server Setup Script

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]

Step 4: Application Deployment

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,
  }],
};

Step 5: Reverse Proxy & SSL

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

Step 6: Database Setup

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:

  • Databases should only listen on
    127.0.0.1
  • Use SSH tunnels for remote database access
  • Never expose database ports to the internet
  • Use strong, unique passwords stored in environment variables

Step 7: Monitoring

Resource 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:

  • UptimeRobot (free tier: 50 monitors, 5-min checks)
  • Better Uptime / Hetrixtools (free alternatives)
  • Monitor: HTTPS endpoint, SSL expiry, response time

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

Step 8: Backup Strategy

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/" u123456@u123456.your-storagebox.de: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:

  • Hetzner Storage Box: Cheap, same datacenter, rsync/SFTP
  • Hetzner Object Storage: S3-compatible, good for large files
  • Backblaze B2: Ultra-cheap cloud storage, S3-compatible
  • BorgBackup: Deduplication, encryption, efficient for incremental backups

Step 9: Hardening Checklist

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

Step 10: Output

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]

Inputs

  • Workload type and expected traffic
  • Application stack (language, framework)
  • Database requirements
  • Domain name
  • Budget preference

Outputs

  • Instance type recommendation with justification
  • Complete server provisioning script (SSH hardening, firewall, fail2ban, unattended upgrades)
  • Application deployment (Docker or direct with PM2)
  • Reverse proxy config (Caddy or Nginx) with auto-SSL
  • Database setup (PostgreSQL, Redis) with security
  • Monitoring script with disk/memory/app health alerts
  • Backup strategy (snapshots + database dumps + off-site)
  • Server hardening checklist
  • Deployment quick-reference commands

Level History

  • Lv.1 — Base: Hetzner instance selection matrix, full provisioning script (SSH hardening, UFW, fail2ban, unattended upgrades, swap, kernel tuning), Docker and direct deployment, Caddy/Nginx reverse proxy with auto-SSL, PostgreSQL/Redis setup, health monitoring with alerts, backup strategy (snapshots + DB dumps + off-site), hardening checklist. Based on CPX31 production experience. (Origin: MemStack v3.2, Mar 2026)