Tenancy & deployment
Declare your multi-tenant storage and isolation options, deployment topology, compute sizing, required env and infrastructure — what the fleet provisions per tenant.
The manifest also declares how your app is deployed and isolated
Most of the app manifest describes what your app does — its features, permissions, events. A handful of spec sections describe what your app is at runtime: how tenant data is isolated, where the service sits on the network, how big its container is, which IaC builds it, and which environment it needs to boot. The Control Plane Fleet (cp-fleet) reads these sections and provisions the concrete infrastructure per (instance, region) when a customer accepts a plan. Each customer runs in an isolated site — the tenancy model behind Jira Cloud and Slack workspaces.
Five sections carry the deployment shape, and this page covers each:
| Section | What it declares |
|---|---|
spec.tenancy | Data-layer isolation — shared rows vs per-instance DB, and per-component upgrade options |
spec.deployment | Network placement — subdomain style, path prefix, visibility, marketplace flags |
spec.compute | ECS / Fargate task sizing and container healthcheck |
spec.infrastructure | Terraform pointer for the backend, a paired static frontend bundle, and AWS resources your service owns (see Resources and deployment) |
spec.required_env | Environment variables CP resolves per-org and injects into the task definition |
Three smaller sections — spec.health, spec.status, and spec.depends_on — round out the runtime contract and are covered at the end.
Every section here is optional. A minimal manifest declares only spec.features; you add these as the app needs real infrastructure.spec.tenancy — the data isolation model
spec.tenancy is how your app tells the platform to keep one tenant's data away from another's. Its only required key is storage:
| storage | Meaning |
|---|---|
per_instance_db | Each tenant instance gets its own physical database (logical DB, dedicated cluster, or dedicated RDS — see below). |
per_org_db | One database per organization per region for your service; the organization's app instances are separated by an app_instance_guid column. |
shared_with_filter | All tenants share one database; rows are separated by row-level filtering on org_guid (and app_instance_guid). The schema still accepts it and several first-party services still declare it while their tables move, but it is not where new data should go. |
Which tables live in the catalog database and which in an organization's database, how your code gets a session on each, and how each is provisioned and migrated are covered in Data placement. Read that page before choosing storage.
Scoping a query to the tenant
Every list or read your app serves to a customer passes a TenantScope from egav_base to the service layer. The organization always comes from the signed-in user's token, never from the request. The instance position has to be filled one of two ways, and leaving it empty raises MissingInstanceScopeError:
| Your table | Scope to pass |
|---|---|
has an app_instance_guid column | TenantScope(org_guid=identity.org_guid, app_instance_guid=<the X-App-Instance-Guid header>) |
| has no instance column (the organization owns the row) | TenantScope.org_wide_for(identity.org_guid) |
from egav_base import TenantScope
scope = TenantScope.org_wide_for(identity.org_guid) # the model has no instance column
return await service.list_items(filters=filters, sort=sort, scope=scope)A model with no instance column refuses an instance-scoped request rather than silently dropping the instance filter, so pick org_wide_for only when the table genuinely has no instance.
The simplest possible tenancy block is one line:
spec:
tenancy:
storage: per_instance_dbBeyond storage, tenancy has three optional keys that describe upgrades cp-fleet can provision: options[] (the per-component grid), isolation_default (the baseline tier), and isolation_premium_options[] (the richer paid tiers). They are described in the next three subsections.
| Tenancy key | Type | Purpose |
|---|---|---|
storage (required) | enum | per_instance_db, per_org_db or shared_with_filter. |
options | list | Per-component isolation grid — each component independently shared or dedicated. |
isolation_default | object | The baseline isolation tier for standard plans. |
isolation_premium_options | list | Named upgrade tiers offered on higher plans. |
options[] — per-component isolation
options[] is the per-component isolation grid (the "PCI" model). Each entry names one component, a code for the choice, and the provisioning method, with an optional default: true. The composer groups the list by component and picks exactly one option per group, so each part of the stack can be shared or dedicated independently — a tenant might keep a shared database but move to a dedicated Redis.
| Field | Purpose |
|---|---|
component (required) | Which part of the stack: db, compute, redis, kafka, issuer, storage, observability. |
code (required) | Stable code for this choice within its component group (e.g. shared_db, dedicated_db). |
method (required) | The provisioning method identifier (e.g. shared_pool_ref, pulumi_provision_dedicated_db_cluster). |
default | true marks the option cp-fleet picks when nothing overrides it. One default per component group. |
spec:
tenancy:
storage: per_instance_db
options:
- component: db
code: shared_db
method: shared_pool_ref
default: true
- component: db
code: dedicated_db
method: pulumi_provision_dedicated_db_cluster
- component: redis
code: shared_redis
method: shared_pool_ref
default: true
- component: redis
code: dedicated_redis
method: pulumi_provision_dedicated_redis
- component: kafka
code: shared_kafka
method: shared_pool_ref
default: true
- component: kafka
code: dedicated_kafka_topics
method: pulumi_provision_dedicated_kafka_topicscomponentcovers the whole stack:db(database),compute(the app's own ECS service),redis(cache),kafka(event-bus topics),issuer(token issuer),storage(object storage / KMS), andobservability(metrics, logs, traces).
isolation_default and premium upgrades
Where options[] is a flat grid, isolation_default and isolation_premium_options[] are the named tiers a plan offers — each a richer object carrying a display name, a provisioning method, a default size, and a free-form spec block of provisioning parameters. isolation_default is what standard plans get; the premium list is what higher tiers unlock (a dedicated RDS instance, a per-tenant Aurora cluster, a customer-managed KMS key).
| Field | Purpose |
|---|---|
code (required) | Stable code for the tier. |
name (required) | Display name shown when a plan offers the upgrade. |
description | What the tier gives and who it is for. |
provisioning_method (required) | How cp-fleet builds it — one of the methods in the table below. |
component_code (required) | Identifier of the component this tier provisions. |
component | The isolation axis: db (default), compute, redis, kafka, issuer, storage, observability. |
default_size_code | T-shirt size code for the provisioned resource (default S). |
spec | Free-form provisioning parameters (instance class, engine version, storage, backups). |
The provisioning_method enum is the full set of things cp-fleet knows how to build:
| provisioning_method | Provisions |
|---|---|
shared_pool_ref | A reference to the shared platform pool (no new infra). |
pulumi_provision_logical_db | A logical database on a shared regional cluster. |
pulumi_provision_dedicated_db_cluster | A per-tenant Aurora PostgreSQL cluster. |
pulumi_new_rds_instance | A physical RDS instance per customer. |
pulumi_new_shared_cluster | A new shared cluster. |
pulumi_provision_ecs_service | A dedicated ECS service. |
ecs_service_per_customer | One ECS service per customer. |
pulumi_provision_org_bucket | A per-org object-storage bucket. |
pulumi_provision_frontend_site | An S3 + CloudFront + Route53 frontend site. |
pg_dump_and_restore_or_create | A database created or migrated by dump/restore. |
customer_kms_key_grant | A grant against a customer-owned KMS key. |
The example below is the real tenancy of the Data app's backend: a logical-DB default, with dedicated-RDS and dedicated-Aurora upgrades plus a customer-KMS option on premium tiers.
spec:
tenancy:
storage: per_instance_db
isolation_default:
code: logical_db
name: Logical database (shared cluster)
description: Each org gets its own logical DB on a shared regional Aurora cluster. Standard for Free/Pro tiers.
provisioning_method: pulumi_provision_logical_db
component_code: dedicated_db
component: db
default_size_code: M
isolation_premium_options:
- code: dedicated_rds
name: Dedicated RDS instance
description: Physical RDS instance per customer. For high-volume workloads or per-customer PITR windows.
provisioning_method: pulumi_new_rds_instance
component_code: dedicated_rds
component: db
default_size_code: L
spec:
instance_class: db.r6g.large
engine_version: '15.5'
storage_gb: 200
backup_retention_days: 30
- code: customer_kms
name: Customer-managed KMS key
description: Encrypt the connection-string secret with a customer-owned key. Required for regulated data.
provisioning_method: customer_kms_key_grant
component_code: customer_kms
component: storage
default_size_code: SData residency — which region a tenant's data may legally live in — is a separate axis from tenancy, and choosing a region for customer data is not yet available. See GDPR & compliance.
Customer-managed encryption keys (BYOK)
Planned — not yet available. customer_kms is declared in the manifest schema below and the enforcement code exists, but the platform does not currently issue the cross-account grant it requires. Selecting it is not refused up front — the plan is accepted and provisioning fails partway through. Do not configure it for a live tenant until this note is removed; ask us for the timeline.The customer_kms option is different from the others in one respect: it does not provision a resource of its own, it changes whose key encrypts the resources a tenant already has. The customer keeps the key in their own cloud account and authorizes the platform to use it. They can withdraw that authorization at any time, and when they do the data becomes unreadable — including to us. That is the point of the control, not a failure mode.
Selecting it turns on customer-key encryption for:
| Data at rest | Under the customer's key |
|---|---|
| Dedicated database storage (RDS instance or Aurora cluster) | yes |
| The database credential secret | yes |
| The per-org object-storage bucket | yes |
| Cache (Redis) and event streams (Kafka) | no — platform-managed keys |
Tell customers whose compliance scope covers cache or stream data about that last row explicitly. Everything else on the platform stays on platform-managed keys as well.
It requires a dedicated data store, and the platform enforces that. A shared cluster serves many tenants under one platform key, so it cannot be encrypted with a single tenant's key. Selecting customer_kms alongside a shared or pooled database is refused when the plan is composed rather than provisioned into a state where the tenant is told their data is customer-encrypted while it is not. Pair it with dedicated_rds or dedicated_db_cluster.
Two consequences worth planning for:
- Availability becomes partly the customer's responsibility. If they disable the key, delete it, or withdraw our authorization, their tenant loses data access until they restore it. Reflect that in the SLA — platform uptime cannot cover an outage caused by a key change in someone else's account.
- A scheduled key deletion is the one irreversible path. Once the deletion window elapses, data encrypted under that key cannot be recovered. Agree a change window with the customer before enabling this.
Operators can see which components are actually under the customer's key — and which are not — per organization in the Control Plane's Encryption tab.
spec.deployment — where the app lives on the network
spec.deployment tells the platform how to place the app: what kind of app it is, how its URL is built, whether tenants can launch it, and whether it appears in the marketplace. It drives provisioning, URL construction, and OIDC redirect registration.
| Field | Values | Purpose |
|---|---|---|
kind | product, system, platform | product = tenant-facing marketplace app; system = platform-owned tenant surface (portal, CP admin); platform = infrastructure (Grafana, Loki, audit), not FE-served to tenants. Default product. |
component_type | backend, frontend, infrastructure | What the app is technologically — a FastAPI service, a React bundle, or ops infrastructure. Distinguishes the backend/bundle pair (e.g. egav vs egav-frontend). |
languages | list | python, typescript, go, … — usually one entry. |
subdomain_style | tenant_root, prefixed, root, fixed | How the host is built: tenant_root = {org}.domain (path-mounted); prefixed = {org}-{app}.domain; root = {org}.domain (no hyphen); fixed = a non-tenant host like api.domain, cp.domain. Default tenant_root. |
path_prefix | ^(|/[a-z][a-z0-9-]*)$ | Where the FE mounts under the tenant origin (e.g. /portal, /egav). Empty = not FE-served under a tenant origin. |
default_visibility | public, gated, private | Default access for tenant instances: public = no auth, gated = org users, private = invite-only. Default gated. |
launchable | bool | Whether the customer can launch the app from the portal Apps list. Set false for backend-only services (executors, audit, comments). Default is derived. |
multi_instance | bool | Whether a customer can provision more than one instance per org. Set false for singletons (portal, control-plane). Default derived from subdomain_style. |
sellable | bool | Opt-in marketplace visibility. Omit (or false) for system services and infrastructure; set true only on genuinely sellable products (egav, automation). |
model | platform-singleton-trusted, per-org-dedicated | ADR-009 deployment model — see below. Default platform-singleton-trusted. |
model decides how the event bus routes to your app. platform-singleton-trusted is one shared deployment that filters on each event's org_guid. per-org-dedicated means every org runs its own instance, and the event-bus dispatcher resolves per-instance subscriber endpoints from tenant_application_instances at delivery time.
A tenant-facing backend product (the Data app's backend), opting into the marketplace and running a dedicated instance per org:
spec:
deployment:
kind: product
component_type: backend
languages:
- python
subdomain_style: fixed
path_prefix: ''
default_visibility: gated
launchable: true
multi_instance: true
sellable: true # opt-in marketplace visibility
model: per-org-dedicated # ADR-009: each tenant runs its own instanceA frontend bundle is the same section with component_type: frontend and a path_prefix where the SPA mounts under the tenant origin:
spec:
deployment:
kind: product
component_type: frontend
languages:
- typescript
subdomain_style: tenant_root
path_prefix: /egav
default_visibility: gatedspec.compute — ECS / Fargate sizing
spec.compute sizes the container. cp-fleet reads it when building the ECS task definition and ALB target group — either for the app's own dedicated task, or to size the platform-shared task. The shape is intentionally loose (additionalProperties is allowed), so cp-fleet picks the fields it understands and the schema can grow without a bump.
| Field | Purpose |
|---|---|
launch_type | FARGATE, EC2, or EXTERNAL. cp-fleet defaults to FARGATE when omitted. |
network_mode | awsvpc, bridge, host, or none. |
cpu / memory | Task size in Fargate units (256 = 0.25 vCPU; memory in MiB). |
min_cpu / min_memory | Lower bound for the task's CPU / memory. |
container_name | Name of the primary container in the task. |
container_port | Port the container listens on (1–65535). |
healthcheck_path | ALB target-group health path (e.g. /health). |
healthcheck_interval_s | Seconds between health checks. |
healthcheck_timeout_s | Seconds before a single check times out. |
healthcheck_healthy_threshold | Consecutive passes before the target is marked healthy. |
healthcheck_unhealthy_threshold | Consecutive failures before the target is marked unhealthy. |
spec:
compute:
launch_type: FARGATE
network_mode: awsvpc
min_cpu: 512
min_memory: 1024
cpu: 512
memory: 1024
container_name: main
container_port: 80
healthcheck_path: /health
healthcheck_interval_s: 10
healthcheck_timeout_s: 5
healthcheck_healthy_threshold: 2
healthcheck_unhealthy_threshold: 3spec.infrastructure — IaC pointer and paired frontend
spec.infrastructure declares the infrastructure that builds the app. It has two optional halves: terraform (the backend IaC module) and frontend (a paired static bundle). A frontend-only app omits terraform; a backend-only app omits frontend.
terraform is a pointer, not the module itself — repository, path, and ref are all required, and they tell cp-fleet which module at which revision to apply.
spec:
infrastructure:
terraform:
repository: https://github.com/djokodonev/egav-terraform
path: modules/egav
ref: maininfrastructure.frontend declares a config-free static bundle deployed as a pulumi_provision_frontend_site component — S3 + CloudFront + Route53. Its only required key is source, and per-deployment config resolves at runtime (the edge host maps to a CP URL, then /public/bootstrap) rather than being baked at build time.
| Block | Field | Purpose |
|---|---|---|
source (required) | repository (required) | Git repo of the frontend. |
source | ref | Branch or tag to build (default main). |
source | build_cmd | Build command (default npm ci && npm run build). |
source | out_dir | Directory the build emits (default dist). |
site | subdomain_style | fixed, tenant_subdomain, or tenant_path (default tenant_subdomain). |
site | spa_fallback | Map CloudFront 403/404 to /index.html so deep links and refresh survive (default true). |
spec:
infrastructure:
frontend:
provisioning_method: pulumi_provision_frontend_site
source:
repository: https://github.com/djokodonev/egav-frontend
ref: main
build_cmd: npm ci && npm run build
out_dir: dist
site:
subdomain_style: tenant_subdomain
spa_fallback: truesite.subdomain_stylehas its own enum (fixed/tenant_subdomain/tenant_path) — it is not the same field asdeployment.subdomain_style(tenant_root/prefixed/root/fixed). One shapes the frontend site's host; the other shapes the app's network placement.
spec.required_env — runtime environment CP injects
spec.required_env is the list of environment variables the deployed container needs. CP's RequiredEnvResolver maps each entry to a concrete value per org at provision time and injects it via the ECS task definition (as environment[] for plain values, secrets[] for secret ones). The image itself stays org-agnostic — nothing is baked at build time. A missing entry that is not optional fails provisioning loudly.
| Field | Purpose |
|---|---|
name (required) | The env var name the service reads (e.g. DATABASE_URI). |
source (required) | Which config slice CP resolves the value from (table below). |
source_field | Sub-field within that slice (e.g. uri for db, bootstrap_servers for kafka). |
default | Fallback when the resolver returns nothing. For source: static this is the value. |
optional | When true, a missing value does not fail provisioning. Default false. |
secret | When true, the value is written to Secrets Manager and read via the task def secrets[]. Default false. |
The source enum picks which resolver runs:
| source | Resolves from |
|---|---|
db | The tenant database slice (uri, host, port). |
redis | The Redis slice (host, port, db). |
kafka | The Kafka slice (bootstrap_servers). |
cp | The Control Plane slice (base_url, api_key, verify_ssl). |
authn | The AuthN slice (base_url). |
authz | The AuthZ slice (base_url). |
storage | Object storage (bucket, region). |
observability | OTEL config (enabled, otel_endpoint). |
secret | A platform-managed secret (e.g. inbound_api_key, jwt_signing_key). |
static | A literal — the default field carries the value. |
spec:
required_env:
- { name: APP_API_KEY, source: secret, source_field: inbound_api_key, secret: true }
- { name: SECRET_KEY, source: secret, source_field: jwt_signing_key, secret: true }
- { name: CONTROL_PLANE_BASE_URL, source: cp, source_field: base_url }
- { name: CONTROL_PLANE_API_KEY, source: cp, source_field: api_key, secret: true }
- { name: CONTROL_PLANE_VERIFY_SSL, source: cp, source_field: verify_ssl, default: "true" }
- { name: DATABASE_URI, source: db, source_field: uri, secret: true }
- { name: REDIS_HOST, source: redis, source_field: host }
- { name: REDIS_PORT, source: redis, source_field: port, default: "6379" }
- { name: KAFKA_BOOTSTRAP_SERVERS, source: kafka, source_field: bootstrap_servers }
- { name: AWS_BUCKET, source: storage, source_field: bucket, optional: true }
- { name: OTEL_ENABLED, source: observability, source_field: enabled, default: "false", optional: true }
- { name: OTEL_SERVICE_NAME, source: static, default: "egav" }required_envis for provision-time wiring only. Operator-tunable settings — feature flags, TTLs, retry counts — belong in Settings & config (spec.config), never as an undeclared env var.
spec.health, spec.status, spec.depends_on
Three smaller sections finish the runtime contract.
spec.health names the probe paths cp-fleet and the platform poll:
| Field | Purpose |
|---|---|
liveness | Path that reports the process is up (e.g. /health). |
readiness | Path that reports the service can serve traffic (dependencies reachable). |
metrics | Path exposing Prometheus metrics (e.g. /metrics). |
spec.status controls public status-page exposure. expose is eligibility only — the Status service holds the final per-component enable toggle — and group names the section the component appears under on the status page.
| Field | Purpose |
|---|---|
expose | true makes the component eligible for the public status page. |
group | Status-page group label (e.g. Core, Apps). |
spec.depends_on declares what the app needs to run — platform services (from a fixed enum), other apps, and external systems (free-form). CP uses it to order provisioning and surface dependency graphs.
| Field | Purpose |
|---|---|
platform_services | Platform services from the enum: authn, authz, audit, notifications, comments, status, identity, fleet, fleet-authn, fleet-authz, control-plane, data-exchange, support. |
apps | Other app codes this app depends on (e.g. egav). |
external | External systems — free-form (postgres, redis, kafka, aws-s3). |
spec:
health:
liveness: /health
readiness: /health
metrics: /metrics
status:
expose: true # eligibility only; Status service owns the final toggle
group: Core
depends_on:
platform_services:
- control-plane
- authn
- authz
- notifications
apps:
- egav
external:
- postgres
- kafkaHow it comes together at provision
When a customer accepts a plan, cp-fleet reads these sections together and builds the tenant instance per (instance, region): it applies the tenancy isolation choice (a shared-pool reference or a dedicated Pulumi resource), builds the ECS task from compute, runs the infrastructure module(s), resolves every required_env value for that org and injects it into the task definition, and registers the health, status, and dependency wiring. The step-by-step pipeline — including the lifecycle call your app receives and the handlers you must implement — is documented in Provisioning; the declarative hook block it interacts with is in Hooks.
- Isolation and per-component upgrades →
spec.tenancy - Network placement and marketplace flags →
spec.deployment - Container size and healthcheck →
spec.compute - IaC module and paired frontend →
spec.infrastructure - Per-org runtime environment →
spec.required_env
For the manifest as a whole and how publish fans these sections out, see The app manifest. For operator-tunable settings, see Settings & config; for data residency, see GDPR & compliance.