Deployment
Docker + CI/CD + branching policy
Vezta deploys frontend and backend as Docker containers on self-hosted VMs with a defined branch promotion policy.
Branch Policy
Promotion path is dev → staging → main (each subproject — vezta-be, vezta-fe, vezta-docs — is its own git repository with its own workflows):
dev-- integration. PRs merge here.staging-- release candidate. PRs fromdev(orrelease/*,hotfix/*) merge here.main-- production. PRs fromstaging(orhotfix/*) only.
main requires 2 approvals + passing CI + Security Gates. staging requires 1 approval. Branch protections are enforced by the governance.yml workflow in each subproject repo.
Frontend (Docker on VMs)
The frontend deploys as a Docker container (ghcr.io/morcalabs/vezta-fe) on the same VMs as the backend.
| Environment | Domain | Host Port | Branch |
|---|---|---|---|
| Production | vezta.io / www.vezta.io | 3005 → container 3000 | main (manual promote) |
| Staging | dev.vezta.io | 3002 → container 3000 | staging |
Key env vars per environment:
# Production
NEXT_PUBLIC_API_URL=https://backend.vezta.io/api/v1
NEXT_PUBLIC_WS_URL=wss://backend.vezta.io
# Staging
NEXT_PUBLIC_API_URL=https://staging.vezta.io/api/v1
NEXT_PUBLIC_WS_URL=wss://staging.vezta.ioProduction deploys (frontend)
frontend-deploy-production.yml takes a pinned image_ref as input (a ghcr.io/...:sha-... tag) and deploys that promoted image — there is no rebuild. Staging auto-deploys on push to the staging branch (with an optional image_ref input to skip the build). Rollback = re-run the workflow with a previous known-good image_ref.
Backend (Docker on self-hosted VMs)
The backend runs in 9 application containers per VM (1 API + 8 specialized workers) with self-hosted GitHub Actions runners.
Production VM
| Component | Details |
|---|---|
| SSH | ssh agent@<production-vm> (key in team 1Password) |
| App path | ~/vezta/ |
| Domain | https://backend.vezta.io |
| Reverse proxy | Nginx → host 127.0.0.1:4001 (SSL via Certbot) |
| Port mapping | Host 4001 → container 3001 |
| Postgres | vezta-postgres container (separate from compose), data in Docker volume |
| Redis | vezta-production-redis container |
| Compose | docker-compose.yml |
Staging VM
| Component | Details |
|---|---|
| SSH | ssh root@<staging-vm> (key in team 1Password) |
| App path | /root/apps/vezta-be/ |
| Domain | https://staging.vezta.io |
| Reverse proxy | Nginx → host 127.0.0.1:4002 |
| Port mapping | Host 4002 → container 3001 |
| Compose | docker-compose.staging.yml |
| Containers | vezta-staging-api, vezta-staging-worker-traders, vezta-staging-worker-market, vezta-staging-worker-misc, vezta-staging-worker-ai, vezta-staging-worker-websocket, vezta-staging-worker-rtds, vezta-staging-worker-sports, vezta-staging-worker-combos, vezta-staging-redis |
Worker Topology
Each VM runs 9 application containers (1 API + 8 workers) sharing the same ghcr.io/<owner>/vezta-be-api image:
| Container | Role | Memory / CPU | Liveness |
|---|---|---|---|
vezta-production-api | HTTP + WebSocket | 1.5 GB / 1.5 CPU | port 3001 |
vezta-production-worker-traders | Order execution, confirmations, fee collection, copy-trade, sniper, insider-signals | 2.5 GB / 2.0 CPU | port 3002 |
vezta-production-worker-market | market-sync, trade-sync, short-duration, catalog-sync, holders sync | 2.0 GB / 1.5 CPU | port 3003 |
vezta-production-worker-misc | notifications, rewards, share-card, leaderboard, alerts, spread-scanner | 2.0 GB / 1.0 CPU | port 3004 |
vezta-production-worker-ai | Agent chat + research queues (gated by FF_AGENT_QUEUE_SPLIT) | 2.0 GB / 1.0 CPU | port 3006 |
vezta-production-worker-websocket | Polymarket CLOB WS, Kalshi WS, Sports WS, User WS | 1.0 GB / 1.5 CPU | port 3005 |
vezta-production-worker-rtds | Polymarket RTDS (Chainlink crypto prices) | 384 MB / 0.25 CPU | port 3007 |
vezta-production-worker-sports | ESPN sports-stats (scores, standings, resolve) | 384 MB / 0.25 CPU | port 3008 |
vezta-production-worker-combos | Combo-trade catalog + eligibility / settlement | 384 MB / 0.25 CPU | port 3009 |
Rollback: scale specialized workers to 0 and run a single fallback container with WORKER_DOMAIN=all. Same image works in both modes.
Promoted-image production deploy
Backend production deploys use a promoted-image model:
- Push to
stagingtriggersbuild.yml+deploy-staging.yml-- builds and tagsghcr.io/<owner>/vezta-be-api:sha-<sha12> - Production deploy workflow (
deploy.yml) takes thatimage_refas input -- no rebuild - Rollback via
scripts/rollback-to-image.sh <env> <image>
CI/CD Flow
| Workflow location | Workflow | Trigger | What it does |
|---|---|---|---|
Each subproject .github/workflows/ | security.yml | PR | CodeQL, dependency audit, secret scan |
Each subproject .github/workflows/ | governance.yml | PR | Branch promotion policy |
Each subproject .github/workflows/ | benchmark-cicd.yml | Weekly | CI performance tracking |
vezta-be/.github/workflows/ | test.yml | PR to main/staging | Unit tests + E2E |
vezta-be/.github/workflows/ | build.yml | Push to staging/main | Image build, tag, push |
vezta-be/.github/workflows/ | deploy-staging.yml | Push to staging | Deploy to staging VM |
vezta-be/.github/workflows/ | deploy.yml | Manual + image_ref input | Production deploy with promoted image |
vezta-fe/.github/workflows/ | frontend-ci.yml | PR | Vitest + Playwright |
vezta-fe/.github/workflows/ | frontend-deploy-staging.yml | Push to staging | Docker build + deploy to staging VM |
vezta-fe/.github/workflows/ | frontend-deploy-production.yml | Manual + image_ref | Docker deploy to prod VM (promoted image, no rebuild) |
Both production and staging CI auto-run prisma migrate deploy -- never run migrations manually on the VM.
Post-deploy health check
Verifies:
/api/v1/health(port 4001/4002 via Nginx)- In-container liveness probes for each worker (ports 3002 / 3003 / 3004 / 3005 / 3006 / 3007 / 3008 / 3009)
Server .env
Located at the app path on each VM (e.g. /root/apps/vezta-be/.env on staging, ~/vezta/.env on production). Always back up before edits:
ssh root@<staging-vm> 'cd /root/apps/vezta-be && cp .env .env.bak.$(date +%s)'After .env changes, recreate containers (a docker compose restart is not enough — env is loaded at create time):
ssh root@<staging-vm> 'cd /root/apps/vezta-be && docker compose up -d'Env changes must be applied to both the staging and production VM .env files. They are not synced automatically.
Telegram bot env divergence: TELEGRAM_BOT_TOKEN, the 3 TELEGRAM_*_CHANNEL_ID vars, VEZTA_BOT_TOKEN, VEZTA_BOT_WEBHOOK_SECRET, and BOT_PROD_ENABLED MUST hold different values on staging vs prod by design. Putting a prod bot token on staging causes staging signal-dispatch to pollute production user-facing channels.
Nginx-Layer Concerns
Nginx (not NestJS) owns several concerns in production:
- CORS --
map $http_origin $cors_originwhitelist forvezta.io,dev.vezta.io,admin.vezta.io,localhost:3000. NestJS CORS headers are stripped viaproxy_hide_header; Nginx adds its own withadd_header ... always. OPTIONS preflight returns 204 directly from Nginx. ModifyingenableCors()inmain.tshas no effect in production. - Rate limits --
api_generalzone (30r/s, burst 50),api_authzone (5r/s, burst 10) on/api/v1/auth/, 30 conn/IP, 10s body/header timeouts, 2MB body max - SSL -- Auto-renewing via Certbot/Let's Encrypt
- Backups --
*.cors-backupfiles preserve previous configs
The Polymarket CLOB proxy runs on the dedicated vezta-proxy VM (Tinyproxy on port 8888), routing CLOB API calls from the production and staging VMs past geo-restrictions. Its address is supplied to the backend via environment variable, never hardcoded.
CI Runner and Proxy VMs
CI and the CLOB geo-bypass proxy run on two separate VMs (previously a single combined host):
vezta-runner— hosts the self-hosted GitHub Actions runners. Jobs target these runner labels:
| Runner label | Used by |
|---|---|
vezta-be-ci-staging | Backend staging build/deploy + tests |
vezta-be-ci-prod | Backend production build + deploy |
vezta-fe-ci-staging | Frontend staging build/deploy + PR CI |
vezta-fe-ci-prod | Frontend production build + deploy |
vezta-docs-builder | Docs image build + production deploy |
vezta-proxy— runs Tinyproxy on port 8888, routing Polymarket CLOB API calls from the production and staging VMs past geo-restrictions.
Manual deploy (hotfix only)
scp src/some-file.ts agent@<production-vm>:~/vezta/src/some-file.ts
ssh agent@<production-vm> 'cd ~/vezta && docker compose down && docker compose build --no-cache api && docker compose up -d'This bypasses the promoted-image model — only use it when CI/CD is broken and the situation is urgent. Always reconcile by re-running CI as soon as possible.
Key Conventions
- Never
prisma migrate resolve --appliedwithout running the migration. If a migration conflicts with manual DB changes, make the SQL idempotent (IF NOT EXISTS). - Never run manual
ALTER TABLEon production via psql. Schema changes go through Prisma migrations. - Always dedup existing rows before adding unique constraints -- staging and production may have different data.