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:
- An app stack (api / ui / mongo) is deployed to the Azure VM.
deploy-dev.ymlis rewired to target the Azure VM (DEPLOY_HOSTsecret flip).- DNS for
dev.securityv0.comand*.dev.securityv0.comis 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:
| Variable | Value |
|---|---|
GHCR_REPO | securityv0/sv0-platform |
IMAGE_TAG | sha-<commit> or pr-<number> |
INSTANCE | Instance name |
UI_PORT | Allocated UI port |
MONGO_PORT | Allocated MongoDB port |
REQUIRE_AUTH | false (dev) |
CORS_ALLOWED_ORIGINS | https://<instance>.dev.securityv0.com |
LOG_LEVEL | debug |
Protected Instances
| Instance | DNS | Protected |
|---|---|---|
main | dev.securityv0.com | Yes — teardown blocked |
demo | demo.dev.securityv0.com | Yes — 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:
docker compose down -v(stops containers, removes volumes)- MongoDB data directory removed via Docker (avoids permission issues with MongoDB-owned files)
- Instance directory removed
- Entry removed from
instances.conf - Caddy site file removed, Caddy reloaded
Scripts Reference
| Script | Trigger | Purpose |
|---|---|---|
deploy/scripts/deploy-instance.sh | GitHub Actions, or manually | Create or update an instance |
deploy/scripts/teardown-instance.sh | GitHub Actions on PR close | Remove an instance and all its resources |
deploy/scripts/cleanup-instances.sh | Daily cron on dev server | Reap 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.shprevents unbounded growth
DNS
| Record | Type | Value | Purpose |
|---|---|---|---|
dev.securityv0.com | A | 178.156.217.150 | Main dev instance |
*.dev.securityv0.com | A | 178.156.217.150 | All 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.comemail OTP or GitHubSecurityV0org 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.
Related
- Deployment Guide — SSH access, server setup, Caddy configuration, GitHub Actions workflows
- Infrastructure Index — overview of all infrastructure documentation