Skip to content

RUNBOOK: Hetzner VPS Phase 1 Deployment

Atlantis ITS — May 2026
Version: 1.0
Status: Ready — Awaiting Deployment Window
Owner: Shane (Atlantis ITS)
Squad Support: Ozzy, Cooper, Jimmy
Local Path: C:\AtlantisITS\knowledge
CIS Reference: CIS Docker Benchmark v1.8.0


Phase 1 Scope

Deploy the following services to Hetzner VPS:

Service Purpose Port
Forgejo Source of truth / Git server 3000
n8n Automation / workflow engine 5678
NTFY Push notification server 80 / 443

All services run in Docker containers behind a reverse proxy (Caddy or Nginx).
All services are hardened per CIS Docker Benchmark v1.8.0.


Pre-Flight Checklist

Before touching the server, confirm locally:

  • [ ] Hetzner account funded and active
  • [ ] SSH key generated and ready (~/.ssh/atlantis_hetzner recommended)
  • [ ] Domain DNS records planned (subdomains for Forgejo, n8n, NTFY)
  • [ ] Cloudflare DNS set to DNS Only (grey cloud) for all Atlantis subdomains on Hetzner — do NOT proxy through Cloudflare for Hetzner A records
  • [ ] docker-compose.yml files drafted locally before deploy
  • [ ] Forgejo app.ini config prepared
  • [ ] n8n environment variables prepared (no secrets in compose files)
  • [ ] NTFY config file prepared
  • [ ] Local backups confirmed: E:\Backups\Projects\ on Toshiba Canvio

Step 1 — Provision the VPS

Spec Minimum Recommended
CPU 2 vCPU 2 vCPU
RAM 4 GB 4 GB (8 GB if adding Nextcloud Phase 3)
Storage 40 GB 80 GB SSD
OS Ubuntu 22.04 LTS Ubuntu 22.04 LTS
Location Ashburn, VA (US East) Ashburn, VA (US East)

Provision Steps

  1. Log into Hetzner Cloud console
  2. Create new project: atlantis-prod
  3. Create server with above specs
  4. Add your SSH public key during provisioning
  5. Note the server IP — you'll use it throughout this runbook
  6. Set a meaningful hostname: atlantis-vps-01

Step 2 — Initial Server Hardening (Pre-Docker)

SSH in as root first:

ssh root@<SERVER_IP>

2.1 — Update the system

apt update && apt upgrade -y
apt install -y ufw fail2ban auditd curl git unzip htop

2.2 — Create a non-root deploy user

adduser atlantis
usermod -aG sudo atlantis
# Copy SSH key to new user
rsync --archive --chown=atlantis:atlantis ~/.ssh /home/atlantis

CIS 1.1.2: Only trusted users should control Docker. Never add atlantis to the docker group unless required. Use sudo docker instead or configure rootless mode.

2.3 — Harden SSH

nano /etc/ssh/sshd_config

Set the following:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
X11Forwarding no
AllowUsers atlantis
systemctl restart sshd

2.4 — Configure UFW Firewall

ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
ufw status verbose

Only open ports 80 and 443 publicly. All service ports (3000, 5678, etc.) stay internal — accessed via reverse proxy only.

2.5 — Configure Fail2ban

systemctl enable fail2ban
systemctl start fail2ban

Create /etc/fail2ban/jail.local:

[sshd]
enabled = true
port = ssh
maxretry = 5
bantime = 3600
systemctl restart fail2ban

2.6 — Configure auditd (CIS 1.1.3–1.1.18)

Add Docker audit rules per CIS Benchmark:

cat >> /etc/audit/rules.d/docker.rules << 'EOF'
-w /usr/bin/dockerd -k docker
-a exit,always -F path=/run/containerd -F perm=war -k docker
-a exit,always -F path=/var/lib/docker -F perm=war -k docker
-w /etc/docker -k docker
-w /lib/systemd/system/docker.service -k docker
-w /lib/systemd/system/docker.socket -k docker
-w /etc/docker/daemon.json -k docker
-w /etc/containerd/config.toml -k docker
-w /usr/bin/containerd -k docker
-w /usr/bin/containerd-shim -k docker
-w /usr/bin/runc -k docker
EOF

systemctl restart auditd
auditctl -l | grep docker

2.7 — Create a separate partition for Docker (CIS 1.1.1)

If provisioning fresh, allocate a separate logical volume for /var/lib/docker. On Hetzner with a single SSD, use LVM to create a dedicated volume at minimum 20 GB.

# Verify Docker root dir is on its own mount after Docker install
mountpoint -- "$(docker info -f '{{ .DockerRootDir }}')"

Step 3 — Install Docker (CIS Hardened)

# Install Docker via official script
curl -fsSL https://get.docker.com | sh

# Verify version (CIS 1.2.2 - keep Docker up to date)
docker --version

# Do NOT add atlantis user to docker group (CIS 1.1.2)
# Use sudo docker or configure rootless mode

3.1 — Configure daemon.json (CIS Section 2)

Create /etc/docker/daemon.json:

{
  "icc": false,
  "log-level": "info",
  "live-restore": true,
  "userland-proxy": false,
  "no-new-privileges": true,
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

CIS controls applied: - icc: false — restricts inter-container traffic on default bridge (2.2) - log-level: info — appropriate logging level (2.3) - live-restore: true — containers survive daemon restart (2.16) - userland-proxy: false — disables userland proxy (2.17) - no-new-privileges: true — prevents privilege escalation (2.15) - log-driver + log-opts — centralized logging with rotation (2.14)

systemctl restart docker
docker info | grep -E "Logging|Live Restore|UserProxy"

3.2 — Verify daemon config file permissions (CIS Section 3)

# daemon.json ownership and permissions
chown root:root /etc/docker/daemon.json
chmod 644 /etc/docker/daemon.json

# /etc/docker directory
chown root:root /etc/docker
chmod 755 /etc/docker

# Docker socket
chown root:docker /var/run/docker.sock
chmod 660 /var/run/docker.sock

Step 4 — Install Reverse Proxy (Caddy)

Caddy handles SSL automatically via Let's Encrypt — no manual cert management.

apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudflare.com/public/packages/repos/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudflare.com/public/packages/repos/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddy

Create /etc/caddy/Caddyfile:

forgejo.atlantisits.co {
    reverse_proxy localhost:3000
}

n8n.atlantisits.co {
    reverse_proxy localhost:5678
}

ntfy.atlantisits.co {
    reverse_proxy localhost:2586
}
systemctl enable caddy
systemctl start caddy

DNS: Point all three subdomains to the Hetzner server IP as A records in Cloudflare. Set to DNS Only (grey cloud).


Step 5 — Deploy Forgejo

5.1 — Create directory structure

mkdir -p /opt/atlantis/forgejo/data
mkdir -p /opt/atlantis/forgejo/config

5.2 — docker-compose.yml

Create /opt/atlantis/forgejo/docker-compose.yml:

version: "3.8"

services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:latest
    container_name: forgejo
    restart: unless-stopped
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__server__DOMAIN=forgejo.atlantisits.co
      - FORGEJO__server__ROOT_URL=https://forgejo.atlantisits.co
      - FORGEJO__server__HTTP_PORT=3000
    volumes:
      - /opt/atlantis/forgejo/data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
    security_opt:
      - no-new-privileges:true
    read_only: false
    user: "1000:1000"

CIS controls applied: - Port bound to 127.0.0.1 only — not publicly exposed (5.14) - no-new-privileges:true — prevents privilege escalation (5.4) - Non-root user specified (4.1)

cd /opt/atlantis/forgejo
docker compose up -d
docker compose logs -f

5.3 — First-run setup

Navigate to https://forgejo.atlantisits.co and complete the setup wizard: - Set admin username / password - Disable public registration - Create atlantis-memory repo immediately - Push ATLANTIS_AI_MEMORY.md V18 up


Step 6 — Deploy n8n

6.1 — Create directory

mkdir -p /opt/atlantis/n8n/data

6.2 — Create environment file (secrets out of compose)

Create /opt/atlantis/n8n/.env:

N8N_HOST=n8n.atlantisits.co
N8N_PORT=5678
N8N_PROTOCOL=https
WEBHOOK_URL=https://n8n.atlantisits.co/
N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=admin
N8N_BASIC_AUTH_PASSWORD=<STRONG_PASSWORD>
N8N_ENCRYPTION_KEY=<RANDOM_32_CHAR_KEY>

Never commit .env to Forgejo. Add to .gitignore.

6.3 — docker-compose.yml

Create /opt/atlantis/n8n/docker-compose.yml:

version: "3.8"

services:
  n8n:
    image: n8nio/n8n:latest
    container_name: n8n
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - /opt/atlantis/n8n/data:/home/node/.n8n
    ports:
      - "127.0.0.1:5678:5678"
    security_opt:
      - no-new-privileges:true
    user: "node"
cd /opt/atlantis/n8n
docker compose up -d
docker compose logs -f

Step 7 — Deploy NTFY

7.1 — Create directory and config

mkdir -p /opt/atlantis/ntfy/data

Create /opt/atlantis/ntfy/server.yml:

base-url: https://ntfy.atlantisits.co
listen-http: ":2586"
cache-file: /var/cache/ntfy/cache.db
auth-file: /var/lib/ntfy/user.db
auth-default-access: deny-all
behind-proxy: true

7.2 — docker-compose.yml

Create /opt/atlantis/ntfy/docker-compose.yml:

version: "3.8"

services:
  ntfy:
    image: binwiederhier/ntfy:latest
    container_name: ntfy
    restart: unless-stopped
    command: serve
    volumes:
      - /opt/atlantis/ntfy/server.yml:/etc/ntfy/server.yml:ro
      - /opt/atlantis/ntfy/data:/var/lib/ntfy
    ports:
      - "127.0.0.1:2586:2586"
    security_opt:
      - no-new-privileges:true
cd /opt/atlantis/ntfy
docker compose up -d

# Create admin user
docker exec -it ntfy ntfy user add --role=admin <USERNAME>

Step 8 — Post-Deploy CIS Validation

Run these checks after all services are live.

8.1 — Container runtime checks (CIS Section 5)

# No privileged containers (5.5)
docker ps -q | xargs docker inspect --format '{{ .Name }}: Privileged={{ .HostConfig.Privileged }}'

# Memory limits set (5.11)
docker ps -q | xargs docker inspect --format '{{ .Name }}: Memory={{ .HostConfig.Memory }}'

# No host network namespace sharing (5.10)
docker ps -q | xargs docker inspect --format '{{ .Name }}: NetworkMode={{ .HostConfig.NetworkMode }}'

# No sensitive host dirs mounted (5.6)
docker ps -q | xargs docker inspect --format '{{ .Name }}: Mounts={{ .Mounts }}'

# no-new-privileges active (5.4)
docker ps -q | xargs docker inspect --format '{{ .Name }}: NoNewPrivileges={{ .HostConfig.SecurityOpt }}'

# Restart policy on-failure max 5 (5.15)
docker ps -q | xargs docker inspect --format '{{ .Name }}: RestartPolicy={{ .HostConfig.RestartPolicy }}'

8.2 — Image checks (CIS Section 4)

# Verify no containers running as root (4.1)
docker ps -q | xargs docker inspect --format '{{ .Name }}: User={{ .Config.User }}'

# Verify content trust is enabled (4.5)
echo $DOCKER_CONTENT_TRUST
# Should return 1 — if not: export DOCKER_CONTENT_TRUST=1 in /etc/environment

8.3 — Daemon config verification (CIS Section 2)

docker info | grep -E "Logging Driver|Live Restore"
cat /etc/docker/daemon.json

8.4 — File permission verification (CIS Section 3)

stat -c '%U:%G %a %n' /etc/docker/daemon.json
stat -c '%U:%G %a %n' /var/run/docker.sock
stat -c '%U:%G %a %n' /etc/docker

Step 9 — DNS Verification

# Verify all three subdomains resolve to Hetzner IP
dig forgejo.atlantisits.co
dig n8n.atlantisits.co
dig ntfy.atlantisits.co

# Verify SSL certs issued by Caddy
curl -I https://forgejo.atlantisits.co
curl -I https://n8n.atlantisits.co
curl -I https://ntfy.atlantisits.co

Step 10 — Backup Configuration

10.1 — Automated daily backup script

Create /opt/atlantis/backup.sh:

#!/bin/bash
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/atlantis/backups"

mkdir -p $BACKUP_DIR

# Forgejo
tar -czf $BACKUP_DIR/forgejo_$TIMESTAMP.tar.gz /opt/atlantis/forgejo/data

# n8n
tar -czf $BACKUP_DIR/n8n_$TIMESTAMP.tar.gz /opt/atlantis/n8n/data

# NTFY
tar -czf $BACKUP_DIR/ntfy_$TIMESTAMP.tar.gz /opt/atlantis/ntfy/data

# Retain last 7 days only
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete

echo "Backup complete: $TIMESTAMP"
chmod +x /opt/atlantis/backup.sh

# Schedule daily at 2am
crontab -e
# Add: 0 2 * * * /opt/atlantis/backup.sh >> /var/log/atlantis-backup.log 2>&1

Go-Live Criteria

Phase 1 is complete when ALL of the following are true:

  • [ ] Server provisioned and SSH hardened (root login disabled)
  • [ ] UFW firewall active (only 22, 80, 443 open)
  • [ ] Fail2ban active
  • [ ] auditd configured with all Docker audit rules
  • [ ] Docker installed, daemon.json applied and verified
  • [ ] All file permissions match CIS Section 3 requirements
  • [ ] Caddy live with valid SSL certs for all three subdomains
  • [ ] Forgejo live at forgejo.atlantisits.co
  • [ ] atlantis-memory repo created, V18 memory pushed
  • [ ] n8n live at n8n.atlantisits.co with auth enabled
  • [ ] NTFY live at ntfy.atlantisits.co with auth enabled
  • [ ] All containers passing CIS Section 5 runtime checks
  • [ ] Daily backup cron active
  • [ ] Incident log entry created: INC-004 (VPS Phase 1 Deploy)
  • [ ] SOP or HT doc created for ongoing VPS operations
  • [ ] V19+ memory updated to reflect VPS live status

Known Risks & Mitigations

Risk Mitigation
Secrets in compose files Use .env files, add to .gitignore, never commit
Root SSH access Disabled in Step 2.3
Exposed service ports All ports bound to 127.0.0.1, proxied via Caddy
Container privilege escalation no-new-privileges:true on all containers
Log overflow daemon.json log rotation configured
Cloudflare proxy conflict DNS Only (grey cloud) for all Hetzner A records
Data loss Daily backup cron + retain 7 days

Useful Commands (Day-2 Ops)

# Check all running containers
docker ps -a

# View logs for a service
docker compose -f /opt/atlantis/forgejo/docker-compose.yml logs -f

# Restart a service
docker compose -f /opt/atlantis/n8n/docker-compose.yml restart

# Update a service image
docker compose -f /opt/atlantis/forgejo/docker-compose.yml pull
docker compose -f /opt/atlantis/forgejo/docker-compose.yml up -d

# Check Caddy status
systemctl status caddy
journalctl -u caddy -f

# Check firewall
ufw status verbose

# Check fail2ban
fail2ban-client status sshd

# Check audit logs
ausearch -k docker | tail -20

References

  • CIS Docker Benchmark v1.8.0 (July 2025)
  • Forgejo Documentation: https://forgejo.org/docs
  • n8n Documentation: https://docs.n8n.io
  • NTFY Documentation: https://docs.ntfy.sh
  • Caddy Documentation: https://caddyserver.com/docs
  • ATLANTIS_AI_MEMORY.md V18
  • SOP-005: Forgejo Deployment
  • INC-002: Cloudflare Tunnel (lessons learned on DNS config)

Runbook generated by Ozzy — May 19, 2026
Store at: C:\AtlantisITS\knowledge\RUNBOOK-Hetzner-VPS-Phase1.md
When VPS is live, create INC-004 and update V19+ memory.