Hetzner MVP Deployment Plan
This plan covers the original single-server rsync deployment. The platform now uses GitHub Actions CI/CD with two environments (dev + prod), Caddy for TLS, and GHCR for Docker images. See the canonical deployment guide: sv0-platform/docs/deploy/deployment.md
Date: 2026-02-10 Goal: Deploy sv0-platform (API + UI + MongoDB) on a single Hetzner Cloud instance, deploying from local machine. Related: Deployment and Cloud Strategy Research
Live Deployment
| Endpoint | URL |
|---|---|
| UI | http://178.156.217.150 |
| API (via nginx proxy) | http://178.156.217.150/api/* |
| Health check | http://178.156.217.150/health |
Nginx in the UI container proxies /api/*, /health, and /ready to the API internally. No need to expose port 3000 externally.
Instance Sizing
Recommended: CPX21 (2 vCPU, 4 GB RAM, 80 GB disk) — EUR 9.49/month
Sufficient for dev demos and 1–2 pilot clients.
| Component | Estimated RAM |
|---|---|
| Ubuntu OS + Docker daemon | ~400 MB |
| MongoDB 7.0 (small dataset) | ~800 MB – 1.5 GB |
| Node.js API + workers | ~150–300 MB |
| Nginx + static UI bundle | ~30 MB |
| Total | ~1.4 – 2.2 GB |
Leaves ~1.5–2.5 GB free headroom. Disk usage for 1–2 tenants: ~10–15 GB.
Upgrade trigger → CPX31 (4 vCPU, 8 GB RAM, 160 GB) — EUR 16.49/month
Upgrade when any of these happen:
- MongoDB working set grows past ~2 GB (many tenants or large ServiceNow instances)
- Concurrent connector syncs for multiple tenants
- Observability stack (Loki/Prometheus/Grafana) added on same box
Resize is available in the Hetzner console with a few minutes of downtime.
Phase 1: Create the Hetzner Instance
1.1 Sign up and create project
- Go to console.hetzner.cloud
- Create a project (e.g.
securityv0)
1.2 Add SSH key
- In Hetzner console: Security → SSH Keys → Add SSH Key
- Paste local public key (
~/.ssh/id_ed25519.pubor~/.ssh/id_rsa.pub)
1.3 Create server
| Setting | Value | Notes |
|---|---|---|
| Location | Falkenstein or Helsinki | Cheapest EU locations |
| Image | Docker CE (Ubuntu 24.04) | Docker + Compose pre-installed, skip manual install |
| Type | CPX21 (2 vCPU, 4 GB RAM, 80 GB disk) | Sufficient for demos and 1–2 pilot clients |
| SSH Key | Select the key added above | |
| Networking | Leave defaults (public IPv4 + IPv6) | |
| Volumes | Skip | 80 GB local disk is enough (~10–15 GB actual usage) |
| Backups | Enable | +20% (~EUR 1.90/month). Daily automatic snapshots with one-click restore |
| Placement groups | Skip | Only needed for multi-server HA |
| Labels | Optional | e.g. project: securityv0, env: pilot |
| Cloud config | Skip | We configure manually via SSH |
| Name | sv0-pilot |
Estimated cost: ~EUR 11.39/month (CPX21 EUR 9.49 + backups EUR 1.90)
Server IP: 178.156.217.150 (referred to as $SERVER_IP below)
Phase 2: Bootstrap the Server
The Docker CE image comes with Docker and Docker Compose pre-installed. Skip to 2.2 if using that image.
2.1 Install Docker and Docker Compose (only if using plain Ubuntu image)
# From local machine
ssh root@$SERVER_IP
# Update OS
apt update && apt upgrade -y
# Install Docker CE, Compose plugin, and firewall
apt install -y docker.io docker-compose-v2 ufw
# Enable Docker to start on boot
systemctl enable --now docker
# Verify installation
docker --version # Docker version 27.x+
docker compose version # Docker Compose v2.32+
2.2 Configure firewall
# Install ufw if not present
apt install -y ufw
# Allow only SSH + HTTP + HTTPS
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Verify
ufw status
2.3 Create deploy user
# Non-root user for running containers
adduser --disabled-password --gecos '' deploy
usermod -aG docker deploy
# Copy SSH access from root
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
exit
2.4 Verify SSH access with deploy user
# From local machine — confirm you can log in as deploy
ssh deploy@$SERVER_IP
docker ps # Should work without sudo
exit
Phase 3: Deploy from Local
Two options — choose one.
Option A: Build on server (recommended — simpler, no large transfers)
# 1. Sync source to server
cd ~/dev/securityv0
rsync -avz --exclude node_modules --exclude .venv --exclude dist \
--exclude data --exclude .git \
sv0-platform/ deploy@$SERVER_IP:~/sv0-platform/
# 2. SSH in and build + run
ssh deploy@$SERVER_IP
cd ~/sv0-platform
docker compose up -d --build
# 3. Verify
docker compose ps
curl http://localhost/health # API health (via nginx proxy)
curl http://localhost # UI
Option B: Build locally, transfer images (useful on slow server CPU)
# 1. Build images locally
cd ~/dev/securityv0/sv0-platform
docker compose build
# 2. Save images to tarballs
docker save sv0-platform-api:latest | gzip > /tmp/sv0-api.tar.gz
docker save sv0-platform-ui:latest | gzip > /tmp/sv0-ui.tar.gz
# 3. Copy to server
scp /tmp/sv0-api.tar.gz /tmp/sv0-ui.tar.gz deploy@$SERVER_IP:/tmp/
scp docker-compose.yml deploy@$SERVER_IP:~/sv0-platform/
scp ui/nginx.conf deploy@$SERVER_IP:~/sv0-platform/ui/nginx.conf
# 4. Load and run on server
ssh deploy@$SERVER_IP
docker load < /tmp/sv0-api.tar.gz
docker load < /tmp/sv0-ui.tar.gz
rm /tmp/sv0-*.tar.gz
cd ~/sv0-platform
docker compose up -d
# 5. Verify
docker compose ps
curl http://localhost/health
curl http://localhost
Port Architecture
Internet → :80 (nginx in UI container)
├── / → serves static React UI
├── /api/* → proxy to api:3000 (internal Docker network)
├── /health → proxy to api:3000/health
└── /ready → proxy to api:3000/ready
Internal only (not exposed to internet):
- api:3000 → Express API server
- mongo:27017 → MongoDB
The docker-compose.yml maps UI to port 80. MongoDB (27017) and API (3000) ports are exposed to the host but the firewall only allows 22/80/443 inbound.
Phase 4: Production-Minimal Hardening
4.1 Environment file
Create ~/sv0-platform/.env on the server:
MONGODB_URI=mongodb://mongo:27017/sv0_platform
REQUIRE_AUTH=true
ALLOWED_API_KEYS=<generate-a-strong-key>
NODE_ENV=production
LOG_LEVEL=info
CORS_ALLOWED_ORIGINS=http://178.156.217.150
4.2 MongoDB backups
mkdir -p /home/deploy/backups
crontab -e
# Add daily backup at 03:00:
0 3 * * * docker exec sv0-platform-mongo mongodump --archive=/data/db/backup.gz --gzip 2>&1 | logger -t mongodump
4.3 Schema setup
ssh deploy@$SERVER_IP
cd ~/sv0-platform
docker exec sv0-platform-api node dist/scripts/setup-schema.js
Phase 5: Redeploy Script
Save as deploy.sh at workspace root for repeat deployments:
#!/bin/bash
set -euo pipefail
SERVER=${1:-"deploy@178.156.217.150"}
echo "==> Syncing source..."
rsync -avz --exclude node_modules --exclude .venv --exclude dist \
--exclude data --exclude .git --exclude 'data/mongodb' \
sv0-platform/ "$SERVER:~/sv0-platform/"
echo "==> Building and restarting..."
ssh "$SERVER" "cd ~/sv0-platform && docker compose up -d --build"
echo "==> Checking health..."
sleep 5
ssh "$SERVER" "curl -sf http://localhost/health && echo ' API OK' || echo ' API FAIL'"
ssh "$SERVER" "curl -sf http://localhost > /dev/null && echo 'UI OK' || echo 'UI FAIL'"
Usage: bash deploy.sh deploy@178.156.217.150
Not Covered (Add Later)
| Item | When to add |
|---|---|
| TLS/HTTPS | Before sharing URL with external users (Caddy or Let's Encrypt + nginx) |
| CI/CD auto-deploy | When deploy frequency warrants it (see Cloud Strategy §7) |
| Separate MongoDB node | When reliability requirements increase |
| Observability stack | When pilot begins (Loki/Prometheus/Grafana or Grafana Cloud) |
| Domain name | Before pilot — point A record to $SERVER_IP |