Skip to main content

Hetzner MVP Deployment Plan

Superseded

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

EndpointURL
UIhttp://178.156.217.150
API (via nginx proxy)http://178.156.217.150/api/*
Health checkhttp://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

Sufficient for dev demos and 1–2 pilot clients.

ComponentEstimated 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

1.2 Add SSH key

  • In Hetzner console: Security → SSH Keys → Add SSH Key
  • Paste local public key (~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub)

1.3 Create server

SettingValueNotes
LocationFalkenstein or HelsinkiCheapest EU locations
ImageDocker CE (Ubuntu 24.04)Docker + Compose pre-installed, skip manual install
TypeCPX21 (2 vCPU, 4 GB RAM, 80 GB disk)Sufficient for demos and 1–2 pilot clients
SSH KeySelect the key added above
NetworkingLeave defaults (public IPv4 + IPv6)
VolumesSkip80 GB local disk is enough (~10–15 GB actual usage)
BackupsEnable+20% (~EUR 1.90/month). Daily automatic snapshots with one-click restore
Placement groupsSkipOnly needed for multi-server HA
LabelsOptionale.g. project: securityv0, env: pilot
Cloud configSkipWe configure manually via SSH
Namesv0-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.

# 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)

ItemWhen to add
TLS/HTTPSBefore sharing URL with external users (Caddy or Let's Encrypt + nginx)
CI/CD auto-deployWhen deploy frequency warrants it (see Cloud Strategy §7)
Separate MongoDB nodeWhen reliability requirements increase
Observability stackWhen pilot begins (Loki/Prometheus/Grafana or Grafana Cloud)
Domain nameBefore pilot — point A record to $SERVER_IP