Skip to main content

Documentation

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:

SectionWhat it declares
spec.tenancyData-layer isolation — shared rows vs per-instance DB, and per-component upgrade options
spec.deploymentNetwork placement — subdomain style, path prefix, visibility, marketplace flags
spec.computeECS / Fargate task sizing and container healthcheck
spec.infrastructureTerraform pointer for the backend, a paired static frontend bundle, and AWS resources your service owns (see Resources and deployment)
spec.required_envEnvironment 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:

storageMeaning
per_instance_dbEach tenant instance gets its own physical database (logical DB, dedicated cluster, or dedicated RDS — see below).
per_org_dbOne database per organization per region for your service; the organization's app instances are separated by an app_instance_guid column.
shared_with_filterAll 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 tableScope to pass
has an app_instance_guid columnTenantScope(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)
backend/app/routes/inspections.pypython
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:

app.manifest.yamlyaml
spec:
  tenancy:
    storage: per_instance_db

Beyond 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 keyTypePurpose
storage (required)enumper_instance_db, per_org_db or shared_with_filter.
optionslistPer-component isolation grid — each component independently shared or dedicated.
isolation_defaultobjectThe baseline isolation tier for standard plans.
isolation_premium_optionslistNamed 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.

FieldPurpose
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).
defaulttrue marks the option cp-fleet picks when nothing overrides it. One default per component group.
app.manifest.yamlyaml
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_topics
component covers 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), and observability (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).

FieldPurpose
code (required)Stable code for the tier.
name (required)Display name shown when a plan offers the upgrade.
descriptionWhat 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.
componentThe isolation axis: db (default), compute, redis, kafka, issuer, storage, observability.
default_size_codeT-shirt size code for the provisioned resource (default S).
specFree-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_methodProvisions
shared_pool_refA reference to the shared platform pool (no new infra).
pulumi_provision_logical_dbA logical database on a shared regional cluster.
pulumi_provision_dedicated_db_clusterA per-tenant Aurora PostgreSQL cluster.
pulumi_new_rds_instanceA physical RDS instance per customer.
pulumi_new_shared_clusterA new shared cluster.
pulumi_provision_ecs_serviceA dedicated ECS service.
ecs_service_per_customerOne ECS service per customer.
pulumi_provision_org_bucketA per-org object-storage bucket.
pulumi_provision_frontend_siteAn S3 + CloudFront + Route53 frontend site.
pg_dump_and_restore_or_createA database created or migrated by dump/restore.
customer_kms_key_grantA 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.

app.manifest.yamlyaml
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: S
Data 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 restUnder the customer's key
Dedicated database storage (RDS instance or Aurora cluster)yes
The database credential secretyes
The per-org object-storage bucketyes
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.

FieldValuesPurpose
kindproduct, system, platformproduct = 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_typebackend, frontend, infrastructureWhat the app is technologically — a FastAPI service, a React bundle, or ops infrastructure. Distinguishes the backend/bundle pair (e.g. egav vs egav-frontend).
languageslistpython, typescript, go, … — usually one entry.
subdomain_styletenant_root, prefixed, root, fixedHow 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_visibilitypublic, gated, privateDefault access for tenant instances: public = no auth, gated = org users, private = invite-only. Default gated.
launchableboolWhether the customer can launch the app from the portal Apps list. Set false for backend-only services (executors, audit, comments). Default is derived.
multi_instanceboolWhether a customer can provision more than one instance per org. Set false for singletons (portal, control-plane). Default derived from subdomain_style.
sellableboolOpt-in marketplace visibility. Omit (or false) for system services and infrastructure; set true only on genuinely sellable products (egav, automation).
modelplatform-singleton-trusted, per-org-dedicatedADR-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:

app.manifest.yamlyaml
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 instance

A frontend bundle is the same section with component_type: frontend and a path_prefix where the SPA mounts under the tenant origin:

app.manifest.yamlyaml
spec:
  deployment:
    kind: product
    component_type: frontend
    languages:
    - typescript
    subdomain_style: tenant_root
    path_prefix: /egav
    default_visibility: gated

spec.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.

FieldPurpose
launch_typeFARGATE, EC2, or EXTERNAL. cp-fleet defaults to FARGATE when omitted.
network_modeawsvpc, bridge, host, or none.
cpu / memoryTask size in Fargate units (256 = 0.25 vCPU; memory in MiB).
min_cpu / min_memoryLower bound for the task's CPU / memory.
container_nameName of the primary container in the task.
container_portPort the container listens on (1–65535).
healthcheck_pathALB target-group health path (e.g. /health).
healthcheck_interval_sSeconds between health checks.
healthcheck_timeout_sSeconds before a single check times out.
healthcheck_healthy_thresholdConsecutive passes before the target is marked healthy.
healthcheck_unhealthy_thresholdConsecutive failures before the target is marked unhealthy.
app.manifest.yamlyaml
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: 3

spec.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.

app.manifest.yamlyaml
spec:
  infrastructure:
    terraform:
      repository: https://github.com/djokodonev/egav-terraform
      path: modules/egav
      ref: main

infrastructure.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.

BlockFieldPurpose
source (required)repository (required)Git repo of the frontend.
sourcerefBranch or tag to build (default main).
sourcebuild_cmdBuild command (default npm ci && npm run build).
sourceout_dirDirectory the build emits (default dist).
sitesubdomain_stylefixed, tenant_subdomain, or tenant_path (default tenant_subdomain).
sitespa_fallbackMap CloudFront 403/404 to /index.html so deep links and refresh survive (default true).
app.manifest.yamlyaml
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: true
site.subdomain_style has its own enum (fixed / tenant_subdomain / tenant_path) — it is not the same field as deployment.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.

FieldPurpose
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_fieldSub-field within that slice (e.g. uri for db, bootstrap_servers for kafka).
defaultFallback when the resolver returns nothing. For source: static this is the value.
optionalWhen true, a missing value does not fail provisioning. Default false.
secretWhen true, the value is written to Secrets Manager and read via the task def secrets[]. Default false.

The source enum picks which resolver runs:

sourceResolves from
dbThe tenant database slice (uri, host, port).
redisThe Redis slice (host, port, db).
kafkaThe Kafka slice (bootstrap_servers).
cpThe Control Plane slice (base_url, api_key, verify_ssl).
authnThe AuthN slice (base_url).
authzThe AuthZ slice (base_url).
storageObject storage (bucket, region).
observabilityOTEL config (enabled, otel_endpoint).
secretA platform-managed secret (e.g. inbound_api_key, jwt_signing_key).
staticA literal — the default field carries the value.
app.manifest.yamlyaml
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_env is 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:

FieldPurpose
livenessPath that reports the process is up (e.g. /health).
readinessPath that reports the service can serve traffic (dependencies reachable).
metricsPath 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.

FieldPurpose
exposetrue makes the component eligible for the public status page.
groupStatus-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.

FieldPurpose
platform_servicesPlatform services from the enum: authn, authz, audit, notifications, comments, status, identity, fleet, fleet-authn, fleet-authz, control-plane, data-exchange, support.
appsOther app codes this app depends on (e.g. egav).
externalExternal systems — free-form (postgres, redis, kafka, aws-s3).
app.manifest.yamlyaml
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
    - kafka

How 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.