Skip to main content

Multi-Instance Dev Deployment

Shipped in: sv0-platform PR #38 (2026-03-08)

The dev server supports multiple isolated platform instances running simultaneously. Each pull request gets its own instance at <instance>.dev.securityv0.com. The main branch deploys to the protected main instance at dev.securityv0.com.


Instance Model

An instance is a self-contained set of three Docker containers (API, UI, MongoDB) plus a Caddy site file, running on the dev VPS at distinct localhost ports.

dev.securityv0.com          → main instance (auto-deploy on merge to main)
pr-42.dev.securityv0.com → PR #42 preview instance
pr-55.dev.securityv0.com → PR #55 preview instance
...

All *.dev.securityv0.com traffic routes to the same VPS IP (178.156.217.150) via a wildcard DNS record. Caddy matches on the hostname and reverse-proxies to the correct localhost:<port>.

:::note Active Hetzner→Azure migration (as of 2026-05-15) The Azure VM dev-azure-ssh.securityv0.com (subscription rg-sv0-dev) is bootstrap-only — its gha-sv0-platform-deploy SP, RBAC, and Serial Console role were applied via TFC, but no app stack runs there yet. It does NOT serve dev.securityv0.com traffic. The Hetzner VPS topology above is the operational reality.

The migration will complete when:

  1. An app stack (api / ui / mongo) is deployed to the Azure VM.
  2. deploy-dev.yml is rewired to target the Azure VM (DEPLOY_HOST secret flip).
  3. DNS for dev.securityv0.com and *.dev.securityv0.com is flipped to the Azure VM.

Until then, treat any "the dev server is Azure" claim in older session notes as a planning artifact, not current state. Update this callout when the DNS flips. :::


Instance Naming

Instance names are lowercase alphanumeric + hyphens, 1–20 characters, no leading/trailing hyphen. They must be valid DNS labels.

GitHub Actions uses the name pr-<number> for PR preview instances (e.g., pr-42). The main and demo instances are protected — they cannot be torn down by the cleanup or teardown scripts.


Lifecycle

PR opened / push to PR branch


deploy-dev.yml (GitHub Actions)

├─ Builds Docker images, pushes to GHCR with tag pr-<number>

└─ SSH → deploy-instance.sh pr-<number> sha-<commit> <ghcr-repo> <token>

├─ Allocates ports (file-locked)
├─ Writes docker-compose.deploy.yml + .env
├─ Pulls images from GHCR
├─ Starts containers (docker compose up -d)
└─ Creates Caddy site file, reloads Caddy

PR merged / closed


deploy-dev-cleanup.yml (GitHub Actions, auto on PR close)

└─ SSH → teardown-instance.sh pr-<number>

├─ Stops and removes containers + volumes
├─ Removes instance directory (incl. MongoDB data)
├─ Removes entry from instances.conf
└─ Removes Caddy site file, reloads Caddy

Daily cron (04:00 server time)

└─ cleanup-instances.sh

└─ Reaps instances idle > MAX_AGE_DAYS (default: 7)
based on Caddy access log mtime

Port Allocation

Ports are assigned sequentially and tracked in ~/instances.conf on the dev server. Each line has the format:

<instance>:<ui_port>:<mongo_port>

Example:

main:8080:27017
pr-42:8081:27018
pr-55:8082:27019

Port allocation is protected by a file lock (instances.conf.lock) to prevent race conditions when multiple PRs deploy simultaneously.

Starting values:

  • UI ports start at 8080 (incremented from the current maximum)
  • MongoDB ports start at 27017 (incremented from the current maximum)

Each instance's containers are started with the project name sv0-<instance> to avoid naming collisions.


Caddy Routing

Each instance gets a drop-in Caddy site file at /etc/caddy/sites/<instance>.caddy:

<instance>.dev.securityv0.com {
reverse_proxy localhost:<ui_port>
handle /deploy-health {
respond "ok" 200
}
log {
output file /var/log/caddy/instances/<instance>.log {
roll_size 1mb
roll_keep 1
}
}
}

The main Caddyfile imports all drop-in files (import /etc/caddy/sites/*.caddy). After creating or removing a site file, the script reloads Caddy live via the admin API (caddy adapt → POST /load) — no restart required.

The main instance uses dev.securityv0.com (no subdomain prefix). All other instances use <instance>.dev.securityv0.com.


Directory Layout (dev server)

~/instances/
├── instances.conf ← port registry
├── instances.conf.lock ← advisory lock file
├── main/
│ ├── .env
│ ├── docker-compose.deploy.yml
│ └── data/main/ ← MongoDB data volume
├── pr-42/
│ ├── .env
│ ├── docker-compose.deploy.yml
│ └── data/pr-42/
└── pr-55/
└── ...

/etc/caddy/sites/
├── main.caddy
├── pr-42.caddy
└── pr-55.caddy

/var/log/caddy/instances/
├── main.log
├── pr-42.log
└── pr-55.log

Environment Variables per Instance

Each instance's .env contains:

VariableValue
GHCR_REPOsecurityv0/sv0-platform
IMAGE_TAGsha-<commit> or pr-<number>
INSTANCEInstance name
UI_PORTAllocated UI port
MONGO_PORTAllocated MongoDB port
REQUIRE_AUTHfalse (dev)
CORS_ALLOWED_ORIGINShttps://<instance>.dev.securityv0.com
LOG_LEVELdebug

Protected Instances

InstanceDNSProtected
maindev.securityv0.comYes — teardown blocked
demodemo.dev.securityv0.comYes — teardown blocked

Protected instances can only be updated (redeployed), never torn down, by the automated scripts.


Idle Cleanup

cleanup-instances.sh runs daily at 04:00 via cron. It reaps non-protected instances that have had no HTTP traffic for more than MAX_AGE_DAYS days (default: 7).

Idle detection is based on the mtime of the Caddy access log for the instance. If no access log exists, the instance directory mtime is used as a fallback.

Cleanup sequence per reaped instance:

  1. docker compose down -v (stops containers, removes volumes)
  2. MongoDB data directory removed via Docker (avoids permission issues with MongoDB-owned files)
  3. Instance directory removed
  4. Entry removed from instances.conf
  5. Caddy site file removed, Caddy reloaded

Scripts Reference

ScriptTriggerPurpose
deploy/scripts/deploy-instance.shGitHub Actions, or manuallyCreate or update an instance
deploy/scripts/teardown-instance.shGitHub Actions on PR closeRemove an instance and all its resources
deploy/scripts/cleanup-instances.shDaily cron on dev serverReap idle instances older than MAX_AGE_DAYS

Resource Limits

No hard concurrent-instance limit is enforced in code. Practical limits are:

  • CPU/memory: CPX21 (2 vCPU, 4 GB RAM). Each instance uses ~200–400 MB for the API + UI containers; MongoDB is the largest consumer. Expect 4–6 simultaneous instances before memory pressure.
  • Port space: UI ports start at 8080; up to ~57,000 ports are theoretically available (well beyond any practical limit)
  • Disk: MongoDB data directories; cleanup-instances.sh prevents unbounded growth

DNS

RecordTypeValuePurpose
dev.securityv0.comA178.156.217.150Main dev instance
*.dev.securityv0.comA178.156.217.150All PR preview instances

DNS is managed by Sergey via the securityv0.com registrar. The wildcard record was added as part of PR #38 to enable the multi-instance model.


Access Control

All *.dev.securityv0.com subdomains (including PR preview instances like pr-42.dev.securityv0.com) are protected by Cloudflare Access (Zero Trust). The wildcard Access Application covers all instances automatically — no per-instance configuration needed.

  • Team access: @securityv0.com email OTP or GitHub SecurityV0 org membership
  • CI/CD access: Service tokens (ci-deploy-bot, visual-review-bot) bypass the login page
  • Session duration: 24 hours

See Access Protection for full configuration.