Deployment¶
Development (Docker Compose)¶
The docker-compose.yml in the project root starts the full stack in development mode:
All services use development configurations: hot-reload, debug logging, and development credentials.
Two things the dev stack assumes:
- Two repos, side by side. Extensions live in the sibling
enterprise-extensionsrepository —docker-compose.ymlbind-mounts../enterprise-extensionsread-only into the API container and setsVECTIS_DEV_EXTENSIONS=/enterprise-extensions, so the core discovers every graduated extension through the dev-link. Without the sibling checkout the bind mount is missing and no extensions load. - RustFS for file storage. Dev object storage is a bundled RustFS container (Apache-2.0, S3-compatible) — the API, worker, and event consumer run with
FILE_STORAGE_PROVIDER=rustfsandS3_ENDPOINT=http://rustfs:9000. A one-shotrustfs-setupjob (theminio/mcimage, kept purely as an S3 client) creates thevectis-uploadsbucket. See the RustFS extension.
Production Builds¶
Production images live alongside their dev counterparts in each app directory, orchestrated by docker-compose.prod.yml, which runs eleven services: api, admin, storefront, postgres, redis (Valkey), redpanda, temporal, temporal-worker, event-consumer, meilisearch, and typesense. There is no bundled object-storage service in production — the RustFS container is dev-only; point the S3_* variables at external S3-compatible storage and install a storage extension (production media must live in object storage, not on local disk — see the DR posture).
Build through make prod-build, not a bare docker compose -f docker-compose.prod.yml build:
The target chains three steps: it builds the backend image, runs the extension-UI sync against that image (extensions ship admin/storefront UI as package data, so the sync covers whichever extension wheels are installed in that image — see Extension UI Sync v2), then builds the admin + storefront images with an EXTENSIONS_SYNC_HASH build arg. A bare compose build of admin/storefront fails loudly when the sync is absent or stale, so a stale-UI image can't ship silently.
Extensions in Production¶
Extensions no longer live in the core repo: all of them graduated to the enterprise-extensions repository, and the core's in-tree backend/vectis/extensions/ holds only the namespace anchor. At runtime the core discovers extensions from three sources, in order: pip-installed wheels (via vectis.extensions entry points), the in-tree package scan (now empty), and the VECTIS_DEV_EXTENSIONS dev-link.
docker-compose.prod.yml sets no VECTIS_DEV_EXTENSIONS and mounts no extensions checkout, so in production extensions are installed as wheels into the backend image. Each ext_<name>/ in enterprise-extensions carries its own pyproject.toml that builds a vectis-ext-<name> wheel (that repo's CI builds every wheel); the wheel preserves the vectis.extensions.ext_<name> import path and registers its entry point, so pip installing it into the image is all the wiring an extension needs. Note that the stock backend/Dockerfile.prod builds the extension-agnostic core only and does not yet automate that install step — add a pip install layer for your licensed extension wheels when producing a production image.
The expected install set is pinned by backend/extensions.lock in the core repo (extension id + version + set hash) — adding or removing an extension requires make lock-extensions and a commit. Services compare the discovered set against the lockfile at boot (warn-only), so a missing or extra wheel is flagged in the logs rather than blocking startup.
Backend¶
backend/Dockerfile.prod is a multi-stage Python 3.12-slim build. Highlights:
- Stage 1 (builder) — installs build tooling, resolves
pyproject.tomldeps, and optionally downloads the MaxMind GeoLite2 database at build time (viaMAXMIND_ACCOUNT_ID/MAXMIND_LICENSE_KEYbuild args; GeoLite2 can also be fetched at runtime from admin → Settings → Geocoding). - Stage 2 (runtime) — slim image that copies in only
site-packages, app code, and the GeoLite2 DB. - Entrypoint — Gunicorn with Uvicorn workers (
--workerssized for the host).
Warning
Do not use --reload in production. Use --workers to match your CPU count.
Storefront / Admin¶
Both SvelteKit apps ship their own Dockerfile and build with @sveltejs/adapter-node into a build/ directory served by node build. Dev images mount source for hot reload; production builds bake the compiled output in — including the synced extension UI, which is why they must be built via make prod-build (the Dockerfiles verify EXTENSIONS_SYNC_HASH against the sync manifest).
Run npx houdini generate && npx svelte-check as part of the build (already wired into npm run check) so type-checking failures block deploys.
Environment Variables¶
Required variables for production:
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string (use asyncpg driver) |
REDIS_URL |
Redis connection string |
SECRET_KEY |
JWT signing secret (generate with python -c "import secrets; print(secrets.token_urlsafe(64))") |
REDPANDA_BROKER |
Redpanda/Kafka broker address |
TEMPORAL_HOST |
Temporal server address |
MEILISEARCH_URL |
Meilisearch endpoint (admin search) |
MEILISEARCH_API_KEY |
Meilisearch admin API key |
TYPESENSE_URL |
Typesense endpoint (storefront search) |
TYPESENSE_API_KEY |
Typesense admin API key |
TYPESENSE_SEARCH_ONLY_KEY |
Typesense search-only key (for scoped key generation) |
FILE_STORAGE_DIR |
Local filesystem directory for uploads (default: uploads). Used by the built-in local storage strategy. |
FILE_STORAGE_PROVIDER |
Pins the active file-storage backend by provider name (local, rustfs, s3, minio, ...). Empty keeps the legacy behavior — the last-registered storage extension wins. A name that isn't registered falls back to last-registered with a warning, so a stale value can't take uploads down. Dev compose sets rustfs. |
PUBLIC_API_BROWSER_ORIGIN |
Optional on API: browser-reachable origin (e.g. https://api.example.com). When set, the local file strategy returns absolute /uploads URLs. Set the same value on the admin service so the upload BFF can rewrite relative URLs if the API omits this setting. |
S3_ENDPOINT |
S3-compatible storage endpoint (used by ext_s3, ext_rustfs, ext_minio, ext_digitalocean_spaces). Dev points it at the bundled RustFS container; production points it at your external S3-compatible store. |
S3_PUBLIC_URL |
Public URL for S3 objects (browser-accessible). Required when S3_ENDPOINT uses a Docker-internal hostname. Falls back to S3_ENDPOINT if unset. |
S3_ACCESS_KEY |
S3 access key (required when using a storage extension) |
S3_SECRET_KEY |
S3 secret key (required when using a storage extension) |
S3_BUCKET |
S3 bucket name |
Tip
Use a secrets manager (AWS Secrets Manager, Vault, etc.) for SECRET_KEY, database credentials, and API keys. Never commit secrets to the repository.
Database Migrations¶
Apply core migrations before starting the application:
Extension migrations run themselves: each extension that owns tables ships its own Alembic chain, applied automatically by the api at boot under a private version table (alembic_version_ext_<name>). No manual step. The worker, schedule runner, and event consumer do not migrate — they boot with run_lifecycle=False and fail fast when an extension's database revision doesn't match its packaged migration head, so start the api first when rolling out a version that adds extension schema.
For first-time setup, also run the seed script:
Permission rows declared by extension manifests are upserted additively at every api boot — no re-seed needed when an extension adds a permission.
Health Check¶
The API exposes a { health } GraphQL query that returns "Vectis Commerce API is healthy". Use this for load balancer and container health checks:
curl -X POST http://localhost:8000/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ health }"}'
Scaling¶
| Service | Scaling Strategy |
|---|---|
| API | Horizontal — add more Uvicorn worker processes or container replicas |
| Storefront / Admin | Horizontal — stateless Node.js processes behind a load balancer |
| Temporal Worker | Horizontal — add worker replicas; Temporal distributes work automatically |
| Event Consumer | Single instance per consumer group (Redpanda handles partitioning) |
| PostgreSQL | Vertical or read replicas |
| Valkey | Single instance or Valkey Cluster for high-traffic sessions |
TLS / HTTPS¶
Place a reverse proxy (Nginx, Caddy, or cloud load balancer) in front of all services. Terminate TLS at the proxy. Internal service-to-service communication can use HTTP within a private network.
The API runs Uvicorn with --proxy-headers --forwarded-allow-ips "*" so it honours X-Forwarded-For from the BFF and the reverse proxy. Make sure the reverse proxy sets X-Forwarded-For correctly and nothing accepts the header from the public internet directly — only your trusted hop should be allowed to set it.
DigitalOcean Deployment Guide¶
A full DigitalOcean walkthrough lives at vectis/docs/DEPLOYMENT_DIGITALOCEAN.md in the application repo. It covers four customer-choice paths:
- Single droplet — single VM running the full Docker Compose stack. Good for pilots and demos.
- App Platform + Managed Databases — DO App Platform for stateless services, DO Managed Postgres + Managed Caching (Valkey). Less ops overhead, slightly higher cost.
- Kubernetes (DOKS) — for customers already running on K8s. Helm charts not yet provided; the runbook covers manifest generation.
- Self-hosted on a VPS provider — generic Compose deployment with a Caddy reverse proxy and Let's Encrypt.
Each path covers networking, secrets, backups, observability, and DNS. The runbook is the canonical onboarding doc — keep it in sync with this page when paths or env vars change.
Crypto Secrets¶
Two environment variables control payload encryption (webhook secrets, gateway credentials, anything stored Fernet-encrypted in settings):
| Variable | Purpose |
|---|---|
SECRETS_MASTER_KEY |
Current master key — used for new writes and decryption reads |
SECRETS_MASTER_KEY_ROTATING_FROM |
Optional, comma-separated list of older keys tried during decryption — lets you rotate without downtime |
To rotate: generate a new key with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())", move the current key to SECRETS_MASTER_KEY_ROTATING_FROM, then set the new key as SECRETS_MASTER_KEY and restart. Any encrypted row written with the previous key continues to read; new writes use the new key. After 14 days (or your chosen retention) remove the old key from the rotating list.
Customer Pilot Playbook¶
For white-glove customer onboarding, see vectis/docs/CUSTOMER_ONBOARDING_PILOT.md — store-credit seeding, demo accounts, OAuth providers, Houdini schema regeneration.