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_hetznerrecommended) - [ ] 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.ymlfiles drafted locally before deploy - [ ] Forgejo
app.iniconfig 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
Hetzner Server Spec (Recommended for Phase 1)
| 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
- Log into Hetzner Cloud console
- Create new project:
atlantis-prod - Create server with above specs
- Add your SSH public key during provisioning
- Note the server IP — you'll use it throughout this runbook
- 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
atlantisto the docker group unless required. Usesudo dockerinstead 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.1only — 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
.envto 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.jsonapplied 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-memoryrepo created, V18 memory pushed - [ ] n8n live at
n8n.atlantisits.cowith auth enabled - [ ] NTFY live at
ntfy.atlantisits.cowith 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.