Deployment Guide
Environment Variables
Copy .env.example to .env and configure.
Required
| Variable | Description |
|---|---|
CORS_ALLOWED_ORIGINS |
Comma-separated allowed browser origins (production should include https://admin.api.xylolabs.com) |
DATABASE_URL |
PostgreSQL connection string |
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
Docker Compose PostgreSQL bootstrap credentials |
JWT_SECRET |
HMAC-SHA256 signing key (access tokens) |
JWT_REFRESH_SECRET |
HMAC-SHA256 signing key (refresh tokens) |
MINIO_ROOT_USER / MINIO_ROOT_PASSWORD |
MinIO root credentials used by the storage service and bucket bootstrap |
S3_ACCESS_KEY / S3_SECRET_KEY |
MinIO credentials |
INITIAL_ADMIN_EMAIL / INITIAL_ADMIN_PASSWORD |
First admin account (created on first run if no users exist) |
Optional
| Variable | Default | Description |
|---|---|---|
BIND_ADDR |
0.0.0.0:3000 |
Listen address inside the container |
DATABASE_MAX_CONNECTIONS |
20 |
Connection pool size |
JWT_ACCESS_TTL_SECS |
86400 (1d) |
Access token lifetime (LOCKED — see Session Length Policy) |
JWT_REFRESH_TTL_SECS |
31536000 (1y) |
Refresh token lifetime (LOCKED — never reduce; logs out every operator) |
S3_ENDPOINT |
http://minio:9000 |
Internal Docker MinIO endpoint |
S3_BUCKET |
-- | Storage bucket name |
S3_REGION |
-- | S3 region |
S3_PATH_STYLE |
-- | Use path-style addressing |
TRANSCODE_CONCURRENCY |
-- | Max concurrent transcode jobs |
TRANSCODE_DEFAULT_FORMAT |
-- | Default output format |
TRANSCODE_DEFAULT_BITRATE |
-- | Default output bitrate |
TRANSCODE_STALE_TIMEOUT_SECS |
7200 |
Reap orphaned transcode jobs after this many seconds |
GPU_HEALTH_CHECK_INTERVAL_SECS |
60 |
Interval in seconds between GPU server health checks |
INFERENCE_WORKER_CONCURRENCY |
4 |
Max concurrent inference jobs processed by the inference worker |
UPLOAD_MAX_SIZE |
104857600 (100MB) |
Max upload size in bytes |
STATIC_DIR |
./frontend/dist |
Legacy admin dashboard dist path |
STATIC_DIR_APP |
./static-app |
Operator dashboard (frontend-app) dist path |
APP_FRONTEND_HOSTS |
app.xylolabs.com |
Comma-separated allowed origins for the operator dashboard |
ALLOWED_WS_ORIGINS |
(empty — allow all) | Comma-separated WebSocket origin allowlist; empty permits all origins |
ANOMALY_BROADCAST_CAPACITY |
10000 |
In-memory broadcast ring-buffer capacity for anomaly SSE feed |
WEBHOOK_ALLOW_PLAINTEXT_HTTP |
false |
Allow http:// webhook URLs; enable only for closed-network deployments |
INFERENCE_JOB_STALE_TIMEOUT_SECS |
600 |
Reap inference jobs stuck in running after this many seconds |
DEVICE_EVENTS_RETENTION_DAYS |
30 |
Retention window for the SP5 device_events feed |
API_REQUEST_LOG_RETENTION_DAYS |
30 |
Retention window for api_request_logs (C18-AGG-8) |
AUDIT_LOG_RETENTION_DAYS |
365 |
Retention window for audit_log (S8-2). Default 365 because compliance regimes typically require a 1-year audit trail; lower only if your compliance scope permits. |
ANOMALY_REPORT_RETENTION_DAYS |
90 |
Retention window for anomaly_reports (S8-1). The table grows at ~26.5k rows/24h; 90 days covers most operational review cadences. |
INGEST_SESSIONS_RETENTION_DAYS |
90 |
Retention window for closed ingest_sessions (S8-10). Pruning cascades to metadata_chunks via ON DELETE CASCADE, so one worker bounds both tables. |
DEVICE_HEALTH_HISTORY_RETENTION_DAYS |
90 |
Retention window for device_health_history (S8-10). Every health POST writes a row; 90 days covers operational trend review. |
LIVE_STREAM_CONNECTIONS_RETENTION_DAYS |
90 |
Retention window for CLOSED live_stream_connections rows (S8-10). Open rows are untouched (partial index). |
UWB_SURVEYS_RETENTION_DAYS |
90 |
Retention window for uwb_surveys (C4-DEF-4). Pruning cascades to uwb_survey_edges + uwb_survey_solutions via ON DELETE CASCADE, so one worker bounds all three tables. |
ACOUSTIC_DETECTOR_ENABLED |
false |
Enable the Phase 3 acoustic predictive-maintenance detector worker |
FUSION_DETECTOR_ENABLED |
false |
Enable the Phase 2 multi-sensor fusion anomaly detector worker |
FINGERPRINT_WORKER_ENABLED |
false |
Enable the Phase 7 search-by-sound fingerprint worker |
GEMINI_MODEL |
gemini-3.5-flash |
Model used by the daily report + facility assistant |
GEMINI_API_KEY |
(empty) | Gemini API key; enables the daily report + facility assistant when set |
VOICE_ESCALATION_ENABLED |
false |
Enable the Phase 12 LLM voice-call escalation (Twilio) |
VOICE_ESCALATION_TO_NUMBER |
(empty) | Destination number for voice-call escalation |
XYLOLABS_ENV |
development |
Deployment environment (development / test / production); production refuses weak or default secrets |
JWT_PREVIOUS_SECRET |
(empty) | Previous access-token signing key kept valid during rotation; when set must be ≥32 bytes and differ from JWT_SECRET / JWT_REFRESH_SECRET |
TRUSTED_PROXY_IPS |
(empty) | Comma-separated IPs/CIDRs of trusted reverse proxies; enables X-Forwarded-For / X-Real-IP client-IP resolution for audit + auth rate limiting |
UPLOAD_MAX_SIZE_BYTES |
(alias) | Alias for UPLOAD_MAX_SIZE (max upload size in bytes); either name is accepted |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY |
(empty) | Web-push VAPID keypair; enables browser push notifications when both are set |
VAPID_SUBJECT |
(built-in default) | VAPID sub contact (mailto: or https:) sent with push requests |
SMS_PROVIDER |
(empty) | SMS backend: empty (disabled), twilio, or vonage |
SMS_FROM_NAME |
Xylolabs |
Sender name used on outbound SMS |
TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM_NUMBER |
(empty) | Twilio SMS + voice-escalation credentials; required when SMS_PROVIDER=twilio or VOICE_ESCALATION_ENABLED=true |
VONAGE_API_KEY / VONAGE_API_SECRET / VONAGE_FROM |
(empty) | Vonage SMS credentials; required when SMS_PROVIDER=vonage |
RBAC
| Role | Scope | Permissions |
|---|---|---|
super_admin |
Global | Full access to all facilities, users, and system config |
facility_admin |
Facility | Manages own facility's data and users |
user |
Facility | Read-only access to own facility's data |
Database Migrations
Migrations run automatically on startup via sqlx::migrate!() in xylolabs-db. If any migration fails, the server process exits and Docker restarts it — which means the container crash-loops until the migration is fixed and redeployed. Migration failures in production are a P0.
Migration files: crates/xylolabs-db/migrations/ (140 files; verify with ls crates/xylolabs-db/migrations/*.sql | wc -l).
Naming and ordering rules
- Filename format:
YYYYMMDDHHMMSS_<snake_case_description>.sql. - Version prefixes MUST be strictly monotonic. Never reuse a version number. sqlx's
MIGRATORorders by the numeric prefix; duplicate prefixes produce undefined ordering and will almost always break on the dependent side. - Dependency ordering. If migration
Nreferences a column, index, or table created by migrationM, thenN's version MUST be greater thanM's. - Before committing a new migration, verify monotonicity:
bash ls crates/xylolabs-db/migrations | awk -F_ '{print $1}' | sort -cSilent exit means OK; any output means you have a duplicate or out-of-order prefix.
Known incident: facility_id unique index (2026-04-24)
Two migrations were authored under the same prefix 20260418000002:
20260418000002_add_password_changed_at.sql20260418000002_add_firmware_active_unique_index.sql(referencedfirmware_releases.facility_id, which is not created until20260418000003_add_firmware_facility_scope.sql)
sqlx sorted the unique-index migration before the one that added facility_id, so it failed with column "facility_id" does not exist and the app container crash-looped on deploy. The unique-index migration was renumbered to 20260418000009 so it runs after the facility_scope migration. Use this as the canonical reminder: always check that a migration's dependencies are actually in place by prefix order, not by filename alphabet.
Writing safe migrations
- Prefer idempotent DDL:
CREATE TABLE IF NOT EXISTS,CREATE INDEX IF NOT EXISTS,ADD COLUMN IF NOT EXISTS. - Backfill non-null columns in three steps: add nullable → UPDATE →
SET NOT NULL. - Never edit a migration file that has already been applied in production — write a new, forward-only migration instead. The
_sqlx_migrationstable records applied versions; changing history triggers a checksum mismatch. scripts/deploy.shruns a preflight before building the remote image: it validates local and remote migration filenames/order/duplicates, then compares production_sqlx_migrationsversions and SQLx SHA-384 checksums againstcrates/xylolabs-db/migrations/. If any applied migration file is missing or edited, deploy stops before replacing the running container.
House rule: defensive pre-clean before a new UNIQUE index
A migration that adds a UNIQUE (including partial-unique) index to an EXISTING table must not assume the table already satisfies the constraint — rows written before the index existed can violate it, and CREATE UNIQUE INDEX fails outright on the first violation rather than skipping offending rows. Every such migration MUST include a defensive pre-clean step (an UPDATE/DELETE that resolves or removes the violating rows) immediately before the CREATE UNIQUE INDEX, even when production is believed to already satisfy the constraint.
20260710150000_deployments_active_per_device_unique.sql is the model to follow: it adds idx_deployments_active_per_device (at most one active firmware_deployments row per device) by first UPDATE-ing every non-newest active row per device to cancelled (with an explanatory error_message, keeping the row rather than deleting it), and only then building the index with CREATE UNIQUE INDEX IF NOT EXISTS. On production the index already existed out-of-band, so both the pre-clean and the IF NOT EXISTS no-op there — but a migrations-only database (dev, CI, integration tests) never had the constraint and could have accumulated duplicate active rows, so without the pre-clean the index build would fail on exactly those environments.
Constraint to revisit if hot-table sizes grow: CREATE INDEX CONCURRENTLY cannot run inside sqlx::migrate!() — Postgres refuses CONCURRENTLY inside a transaction block, and sqlx wraps every migration in one. For the tables this house rule applies to today (bounded by retention workers, low row counts), a plain synchronous CREATE UNIQUE INDEX is fast enough not to matter (see "Large-table migrations" below). If a table this rule applies to ever grows into the tens-of-millions-of-rows range, the pre-clean pattern above still holds, but the index build itself will need the same out-of-band CONCURRENTLY treatment (a one-off psql run against production, migration-only for dev/CI) rather than a synchronous in-migration build.
Large-table migrations: CONCURRENTLY / batching (C5-ARCH-14)
sqlx::migrate!() runs synchronously at boot, before the app admits traffic. A migration that builds an index on, or bulk-DELETEs from, a LARGE table (metadata_chunks-class: tens of millions of rows in production) can hold a long lock or simply run for minutes, either crash-looping the container on a deploy health-check timeout or stalling every future deploy. Future migrations touching a table of that class should build indexes CONCURRENTLY out-of-band (outside sqlx::migrate!(), e.g. a one-off psql run against production) or batch the DELETE the way the retention workers do (small batches with an inter-batch pause), rather than a single synchronous statement.
Two cycle-4 migrations built directly against production without batching: 20260708060000_timeline_rollups.sql (creates timeline_rollups + worker_watermarks, plus a non-CONCURRENT index on metadata_chunks) and 20260708061000_ingest_sessions_fk_actions.sql (FK constraint changes plus a single-statement DELETE of orphaned audio_fingerprints rows). Both applied cleanly — the metadata_chunks index build is a narrow exception the boot-before-traffic guarantee allows for a one-time build, and the audio_fingerprints DELETE targeted a small derived table — but neither should be treated as a template for a routinely large or fast-growing table. The cycle-5 rollup covering index (20260708120000_timeline_rollups_device_bucket_idx.sql) is a plain, non-CONCURRENT build for the same underlying reason: timeline_rollups is bounded by design (288 rows/day/stream-name, 90-day retention), so the build is seconds, not the minutes a metadata_chunks-class table would take — see the migration's own sizing note.
Docker
# Full stack (production)
docker compose up
# Development mode
docker compose -f docker-compose.dev.yml up
# Test infrastructure
docker compose -f docker-compose.test.yml up -d
Production Deployment
Server
- Host: api.xylolabs.com (AWS EC2)
- Instance type:
t4g.medium(2 vCPU, 4 GiB RAM, arm64) - Region:
ap-northeast-2(Seoul) - Public IP: Elastic IP, reassignable — resolve via
dig +short api.xylolabs.com(do not hardcode; a previously documented IP went stale on 2026-07-06) - OS: Ubuntu 24.04 LTS
- Architecture: arm64
- Swap: 4 GiB at
/swapfile(vm.swappiness=10) - User: ubuntu
- SSH Key:
~/.ssh/xylolabs-api.pem - SSH:
ssh -i ~/.ssh/xylolabs-api.pem ubuntu@api.xylolabs.com
Domains
| Domain | Purpose | Port |
|---|---|---|
| api.xylolabs.com | API endpoints | 3000 (proxied via nginx) |
| admin.api.xylolabs.com | Legacy admin dashboard | 3000 (proxied via nginx, serves static + API) |
| app.xylolabs.com | Operator dashboard (frontend-app) | 3000 (proxied via nginx, serves static-app + API) |
| docs.api.xylolabs.com | Static documentation bundle | 443 (nginx static site) |
Infrastructure
- Reverse proxy: nginx with Let's Encrypt (certbot, webroot flow)
- App: Docker Compose (app + postgres + minio)
- Docs: Static bundle generated from
docs/and served by nginx - SSL: Auto-renewed via certbot timer
First-time server bootstrap
ssh -i ~/.ssh/xylolabs-api.pem ubuntu@api.xylolabs.com 'bash -s' < scripts/setup-server.sh
The setup script installs Docker, Docker Compose, nginx, and certbot, then creates:
/opt/xylolabs-api/.env/opt/xylolabs-api/bootstrap-credentials.txt
Deploy or redeploy
./scripts/deploy.sh
Docker Commands on Server
cd /opt/xylolabs-api
docker compose up -d # Start services
docker compose logs -f app # View logs
docker compose restart app # Restart app
docker compose down # Stop all
nginx + certificate flow
scripts/deploy.sh now:
- Validates local migration filenames, ordering, duplicate versions, and SQLx checksums
- Builds the static docs bundle for
docs.api.xylolabs.com - Rsyncs the source tree to the EC2 host (
$REMOTE_DIR/src/) - Uploads the deployment config bundle (nginx configs,
docker-compose.yml) via scp - Validates the remote migration set and checks production-applied migration versions/checksums before the Docker build
- Builds the Docker image on the remote host (
docker build -f docker/Dockerfile) — detached (see below) - Starts Docker Compose on the server and waits for the local app health endpoint
- Applies temporary HTTP-only nginx configs
- Obtains Let's Encrypt certificates for
api.xylolabs.com,admin.api.xylolabs.com,docs.api.xylolabs.com, andapp.xylolabs.com - Switches nginx to the final TLS configs, reloads it, and verifies the public API, admin, docs, and operator-app hosts respond
Detached build + resume (P1023 WP3, C3-DOC-5)
The remote build+swap phase (steps 5-7) runs detached on the server
(setsid + nohup under $REMOTE_DIR/deployments/<REV_ID>/, with
deploy.log, deploy.pid, and a deploy.exit completion sentinel). The
local deploy.sh merely polls the sentinel over short reconnecting SSH
sessions (25 s interval, 1 h ceiling). Practical consequences:
- A dropped SSH session / closed laptop / killed local script does NOT
cancel the remote build. The ~2-vCPU fat-LTO build takes hours; it keeps
running unattended and completes the compose-up + health-check +
currentrepoint on its own. - Resume instead of re-launching: re-running
bash scripts/deploy.shwhile a build for a different rev is in flight aborts with a hint; to resume polling an interrupted deploy, re-invoke with the printed rev id:REV_ID='<rev>' bash scripts/deploy.sh(same-rev re-invocation resumes polling the in-flight build rather than starting a duplicate). - Watch the log directly when needed:
ssh ubuntu@api.xylolabs.com tail -f <REMOTE_DIR>/deployments/<REV_ID>/deploy.log. - Retention keeps the last N rev snapshots and always pins the rev
deployments/currentpoints at (plus its image), so stacked failed deploys cannot prune the live rev's rollback material (C3-CRITIC-5).