Resources and deployment
How a platform stack is deployed in four ordered stages, how your manifest declares the resources your service owns, and how to define a dedicated resource a plan can sell.
The platform provisions what your manifest declares
Your app never creates its own cloud infrastructure. It declares what it needs in app.manifest.yaml, the platform provisions it, and the resulting references reach your service as environment variables. This page covers three things:
- How a platform stack is deployed — the four ordered stages every stack goes through.
- Declaring a resource your service owns — tables and keys the platform creates for you.
- Defining a dedicated resource you can sell — shared-by-default components that a plan upgrades to dedicated infrastructure.
How a platform stack is deployed
Every platform stack is built as code in four ordered stages. Each stage uses only the recorded outputs of the stage before it, so a stage cannot be applied until the one below it exists. Operators and the platform console refer to them by layer number.
| Stage | Layer | What it creates |
|---|---|---|
| 1. Network | L0 | The private network: isolated subnets, outbound gateway, base firewall rules |
| 2. Shared infrastructure | L1 | The PostgreSQL cluster, Redis, Kafka and object storage every service uses |
| 3. Service data and secrets | L2 | A database per service, the DynamoDB tables and KMS keys manifests declare, service credentials |
| 4. Services and edge | L3 | The running services and their release pipelines, the web frontends, public DNS |
There is no fifth stage. Deployments run from inside the stack's private network; an operator previews each change, approves the exact resources it will touch, and the platform reads the result back after it is applied. A change that touches a data store requires a fresh snapshot first.
Why this matters to you: a resource your manifest declares is created at stage 3, and your service is deployed at stage 4. The first time you add a new resource, the stack needs a stage-3 apply before your service can be deployed with it. Until then, deploying the service is refused rather than started without the resource.
Declaring a resource your service owns
Declare the resource under spec.infrastructure.aws, then ask for its reference in required_env with source: resource. Two kinds are supported today.
spec:
infrastructure:
aws:
dynamodb:
tables:
- code: users
partition_key: {name: user_guid, type: S}
sort_key: {name: created_at, type: N} # optional
access_profile: read_write # or read_only
global_secondary_indexes: # optional, up to 20
- code: by_email
partition_key: {name: email, type: S}
projection: ALL # ALL | KEYS_ONLY | INCLUDE
kms:
signing_keys:
- code: run_signing
key_spec: ECC_NIST_P256 # the only value supported today
required_env:
- name: DYNAMODB_USERS_TABLE
source: resource
source_field: dynamodb.users.table_name
- name: RUN_SIGNING_KMS_KEY_ARN
source: resource
source_field: kms.run_signing.key_arn
optional: trueWhat the platform does with it:
- Creates the resource once per platform stack, at stage 3, named from the stack and your service — you never choose or hard-code a physical name.
- Grants your service exactly what it needs. A table gets the IAM actions for its
access_profile; a signing key getskms:DescribeKey,kms:GetPublicKeyandkms:Sign, limited to ECDSA SHA-256 over a digest. Nothing else in the account is reachable through that grant. - Hands you a reference, never a secret.
source: resourceresolves only todynamodb.<code>.table_nameorkms.<code>.key_arn; any othersource_fieldis rejected when the manifest is validated, as aredefaultandsecret: trueon a resource reference. - Replicates tables to the stack's regions. Replica regions come from where the stack is deployed; a manifest never names an AWS region.
Rules the validator enforces: codes are lowercase snake_case, unique within their list; a key attribute keeps one type everywhere it appears; a partition key and sort key must differ; INCLUDE projections need non_key_attributes and other projections must not have them.
Mark a reference optional: true only when your service genuinely starts without it — for example a signing key for a feature not yet switched on.
Defining a dedicated resource you can sell
Most customers share infrastructure. A plan can move one component of your app — its database, cache, event topics, compute and more — onto infrastructure dedicated to that customer. Selling that takes three steps: your manifest declares the options, an operator builds a plan that selects one, and the platform provisions it when a customer subscribes.
1. Declare the options in spec.tenancy
isolation_default is what every plan gets. Each entry in isolation_premium_options is an upgrade a plan can sell. Each option names its component axis and the provisioning_method that builds it.
spec:
tenancy:
storage: per_instance_db
isolation_default:
code: logical_db
name: Logical database (shared cluster)
provisioning_method: pulumi_provision_logical_db
component_code: dedicated_db
component: db
default_size_code: M
isolation_premium_options:
- code: dedicated_db_cluster
name: Dedicated Aurora cluster
description: A PostgreSQL cluster used by this customer only.
provisioning_method: pulumi_provision_dedicated_db_cluster
component_code: dedicated_db_cluster
component: db
default_size_code: L
- code: dedicated_redis
name: Dedicated Redis
provisioning_method: pulumi_provision_dedicated_redis
component_code: dedicated_redis
component: redis
default_size_code: MComponents are db, compute, redis, kafka, issuer, storage and observability. The full field reference, every provisioning method and the customer-managed key option are on Tenancy.
2. Build a plan that selects it
In the Control Plane, open Plans, pick the plan, and go to its Tenancy overrides tab. It shows one selector per component, read live from your published manifest: Standard (the default) or Dedicated — (option name). Choosing a dedicated option writes its code into the plan's tenancy overrides; you can also set a size tier (S / M / L / XL) per option. The same thing through the API:
PUT /v1/control-plane/cp/plans/{plan_id}/tenancy-overrides
{
"default_tier": "M",
"options_overrides": {
"dedicated_db_cluster": { "tier": "L" }
}
}The plan's price is what makes the upgrade sellable: price the dedicated plan above the shared one, and put it on your pricing page like any other plan (see Plans). A component with no premium option selected stays on its default.
3. The customer subscribes; the platform provisions
When a customer subscribes, the Control Plane combines your manifest's options with the plan's selections (and any per-customer operator override) into the list of components to provision, and the platform builds each dedicated one. Its connection details land in the instance's configuration, so your service reads them the same way whether the component is shared or dedicated:
spec:
required_env:
- name: DATABASE_URL
source: db # resolves to the logical DB or the dedicated cluster
source_field: uri
- name: REDIS_HOST
source: redis
source_field: hostYour code does not branch on whether a customer bought the dedicated tier. The only thing that changes is where the connection string points.
Where this fits
- Manifest — the full
app.manifest.yamlreference. - Tenancy — every isolation field and provisioning method.
- Provisioning — the lifecycle call your app receives once the components exist.
- Plans — pricing and publishing a plan.