Alpha Version: You are viewing the ALPHA documentation. This is an experimental version and may contain breaking changes.
Skip to main content

Per-Plugin AWS Deployment Guide

This guide covers deploying a Reventless application to AWS using independent per-plugin Pulumi stacks, automated by GitHub Actions.

1. Overview​

A Reventless application is deployed as one Pulumi stack per plugin plus a platform stack:

  • Platform stack (platform-aws) -- deploys the shared AppSync API (single unified endpoint), admin components (Plugin aggregate, read model, extension point), and scheduler. Exports the API ID so plugins can create DataSources/Resolvers against it.
  • Plugin stacks (catalog-aws, ordering-aws) -- each plugin deploys its own infrastructure (DynamoDB, SQS, Lambda, S3) and creates AppSync DataSources/Resolvers against the shared API. At runtime, plugins register their GraphQL schema fragment with the platform via the PluginExtensionPoint.

Package split: agnostic + AWS​

Each plugin and the platform are split into two packages:

PackagePurposeDepends on
catalog/Plugin code, tests, local devreventless-infra, reventless-local
catalog-aws/AWS deployment entry pointreventless-aws, catalog
platform/Local dev server (all plugins)reventless-local, all plugins
platform-aws/AWS platform deploymentreventless-aws

Plugin packages never depend on reventless-aws. The -aws packages are private ("private": true in package.json) — they are entry points, not published libraries.

How schema management works​

Schema stitching is a runtime concern, not deploy-time:

  1. When a plugin connects, it sends a connect event including its schema fragment to the PluginExtensionPoint. The platform's admin handler adds the fragment to the combined schema and pushes it to AppSync.
  2. When a plugin disconnects, the platform removes that fragment and pushes the updated schema.

No platform redeployment is needed when plugins change.

Benefits​

  • Independent deployments -- changing a single plugin only redeploys that plugin's -aws stack.
  • Clean dependency graph -- plugin code never touches AWS. AWS coupling is isolated in -aws packages.
  • Dynamic schema -- plugins register/deregister their schema fragments at runtime via the PluginExtensionPoint.
  • Reduced blast radius -- a failed deployment affects only one plugin's stack.
  • Parallel deploys -- independent plugins deploy concurrently.
  • Multiple providers -- adding catalog-supabase/ later is just another package.

2. Prerequisites​

RequirementDetails
AWS accountWith IAM credentials that have permissions to create DynamoDB, Lambda, SQS, SNS, S3, AppSync, and IAM resources
Pulumi CLIInstalled locally (brew install pulumi or curl -fsSL https://get.pulumi.com | sh)
Pulumi state backendPulumi Cloud account (free tier) or self-managed S3 backend — see A self-managed state backend
Node.js v22+See .node-version in the project root
npm access to @reventlessdev/*None needed — the packages are public on npmjs
Lambda layerPublished once in your account and region — see The Lambda layer
ReScript compilerInstalled via npm (included in @reventlessdev/reventless-aws dependencies)

3. Architecture​

Stack structure​

                      +---------------------------+
| platform-aws |
| (admin, scheduler, API) |
| exports: API ID, |
| admin EP, role ARN |
+---------------------------+
| |
StackReference | | StackReference
(API ID, EPs) | | (API ID, EPs)
v v
+----------------+ +----------------+
| catalog-aws | | ordering-aws |
| (plugin infra, | | (plugin infra, |
| DataSources, | | DataSources, |
| Resolvers) | | Resolvers) |
+----------------+ +----------------+
| |
| runtime connect | runtime connect
| + schema fragment | + schema fragment
v v
PluginExtensionPoint (platform runtime)
--> pushes combined schema to AppSync

Data flow​

Deploy-time:

  • Platform creates the AppSync API and exports its ID + role ARN as stack outputs.
  • Each plugin reads the API ID via StackReference and creates its own DataSources/Resolvers pointing to its Lambda functions.
  • Plugins also read admin extension points from the platform for the admin connection path.

Runtime:

  • Each plugin sends a "connect" event to the PluginExtensionPoint, including its schema fragment.
  • The platform's admin handler stitches all connected plugin fragments into a combined schema and pushes it to AppSync.
  • When a plugin disconnects, its fragment is removed and the schema is updated.

Cross-plugin:

  • A plugin that consumes another plugin's extension point needs no stack setting for it. The two are connected at runtime, through the platform, once both have registered.

Deployment order​

Single pass: platform first, then plugins.

  1. Platform deploys -- creates API, admin components, PluginExtensionPoint. Exports API ID.
  2. Plugins deploy in parallel -- create infrastructure, create DataSources/Resolvers using the platform's API ID. At runtime, connect and register schema.

No second platform deploy is needed when plugins change.

4. Step-by-step Setup​

4a. Create the package structure​

Each plugin gets an -aws package alongside its agnostic package. The platform gets platform/ (local) and platform-aws/ (AWS):

my-app/
├── catalog-spec/
├── catalog/
│ ├── src/Plugin.res
│ ├── tests/
│ ├── package.json
│ └── rescript.json
├── catalog-aws/
│ ├── src/Main.res
│ ├── package.json
│ ├── rescript.json
│ ├── Pulumi.yaml
│ ├── Pulumi.alpha.yaml
│ └── Pulumi.main.yaml
├── ordering-spec/
├── ordering/
├── ordering-aws/
│ ├── src/Main.res
│ ├── ...
├── platform/
│ ├── src/Main.res # Local dev server
│ ├── package.json
│ └── rescript.json
├── platform-aws/
│ ├── src/Main.res
│ ├── package.json
│ ├── rescript.json
│ ├── Pulumi.yaml
│ ├── Pulumi.alpha.yaml
│ └── Pulumi.main.yaml
├── deploy-manifest.yaml
├── package.json
└── .github/workflows/deploy-aws.yml

Register the -aws packages as Lerna workspaces in the root package.json and lerna.json.

4b. Add Main.res entry points​

Templates are available in docs/templates/deploy-aws/.

platform-aws/src/Main.res -- deploys admin components, scheduler, and the shared API:

module Platform = ReventlessAws.Platform.Make()

let default = Platform.deployPlatform(~version=Reventless.PackageVersion.fromCaller())

catalog-aws/src/Main.res -- deploys a single plugin's infrastructure:

Note: This file is auto-generated by generate-plugin --aws. Do not edit it manually — run pnpm run generate to regenerate.

// AUTO-GENERATED — do not edit. Run `pnpm run generate` to update.
// Catalog plugin — AWS deployment.

module Platform = ReventlessAws.Platform.Make()
module Catalog = Plugin.Make(Platform)

let default = Platform.deployPlugin(
~version=Reventless.PackageVersion.fromCaller(),
~plugin=module(Catalog),
)

Key points:

  • Platform.Make() configures the infrastructure builders. In per-plugin mode, it reads the platform's API ID from the platform:stack StackReference.
  • Plugins create their own DataSources/Resolvers against the shared API. The schema fragment is registered at runtime via the PluginExtensionPoint.

platform/src/Main.res -- local dev server (unchanged):

module Platform = ReventlessLocal.Platform.Make()

module Catalog = CatalogPlugin.Plugin.Make(Platform)
module Ordering = OrderingPlugin.Plugin.Make(Platform)

Platform.makePlatform(
~version=Reventless.PackageVersion.fromCwd(),
~plugins=[module(Catalog), module(Ordering)],
)

4c. Add package.json for -aws packages​

Each -aws package is a private Lerna workspace member:

{
"name": "@myorg/catalog-aws",
"version": "0.0.0",
"private": true,
"dependencies": {
"@reventlessdev/reventless-aws": "^3.0.0",
"@myorg/catalog": "*"
}
}

4d. Add rescript.json for -aws packages​

{
"name": "catalog-aws",
"sources": [{ "dir": "src", "subdirs": false }],
"dependencies": [
"sury",
"@reventlessdev/reventless-aws",
"@reventlessdev/reventless-infra",
"@reventlessdev/reventless-spec",
"@reventlessdev/rescript-pulumi-pulumi",
"@myorg/catalog"
],
"ppx-flags": ["sury-ppx/bin"],
"package-specs": { "module": "esmodule", "in-source": true },
"suffix": ".res.mjs"
}

4e. Add Pulumi.yaml per stack​

Each -aws package root gets a Pulumi.yaml project definition:

name: my-app-catalog
runtime: nodejs
main: src/Main.res.mjs
description: My App — Catalog plugin stack
  • name must be globally unique within your Pulumi organization.
  • main points to the compiled ReScript output.

4f. Add Pulumi.<env>.yaml per environment​

Create one file per environment the stack should deploy to.

Platform (platform-aws/Pulumi.alpha.yaml):

config:
aws:region: eu-west-1

Independent plugin (catalog-aws/Pulumi.alpha.yaml):

config:
aws:region: eu-west-1
platform:stack: org/my-app-platform/alpha

Plugin with cross-plugin dependency (ordering-aws/Pulumi.alpha.yaml):

config:
aws:region: eu-west-1
platform:stack: org/my-app-platform/alpha

The branch name determines the environment: push to alpha uses Pulumi.alpha.yaml, push to main uses Pulumi.main.yaml. If no matching file exists, no deployment occurs.

Per-instance overrides (env var, Pulumi.local.yaml)​

Some values vary per deploy target (existing AWS resource IDs to reuse, personal Cognito UserPools, etc.) and should not live in the checked-in Pulumi.<stack>.yaml. Util_LocalConfig (reventless/aws/src/util/Util_LocalConfig.res) reads two layered sources at deploy time; the first match wins, otherwise lookup falls through to Pulumi stack config (then auto-provision).

PrecedenceSourceTypical use
1 (highest)Env var REVENTLESS_<KEY_IN_SCREAMING_SNAKE>CI deploys (repo / environment secrets)
2Pulumi.local.yaml sidecar (gitignored)Dev-local override
3platform:<key> in Pulumi.<stack>.yaml (checked in)Shared default

Currently consumed by:

Config keyEnv varUsed byBehavior when unset
identityProviderIdREVENTLESS_IDENTITY_PROVIDER_IDPlatform_Stack.resolveCognitoUserPoolAuto-provisions a fresh UserPool and its active-role store
hostUiBaseDomainREVENTLESS_HOST_UI_BASE_DOMAINPlatform.deployPlatform (host UI custom domain)Keeps *.cloudfront.net default URL
hostUiHostedZoneIdREVENTLESS_HOST_UI_HOSTED_ZONE_IDsameKeeps *.cloudfront.net default URL
hostUiBaseNameREVENTLESS_HOST_UI_BASE_NAMEsameDefaults to Pulumi.getProjectName()
hostUiProdStacksREVENTLESS_HOST_UI_PROD_STACKS (CSV)sameDefaults to ["prod", "main"]
messagingEmailProviderREVENTLESS_MESSAGING_EMAIL_PROVIDERCapability_Messaging.make (which email transport)ses — a real SES identity
messagingEmailSenderREVENTLESS_MESSAGING_EMAIL_SENDERsame (the From: address)No email channel — and the deploy is refused if a plugin declares the Messaging capability
messagingEmailSenderNameREVENTLESS_MESSAGING_EMAIL_SENDER_NAMEsame (From: display name)From: carries the bare address
messagingSmsSenderREVENTLESS_MESSAGING_SMS_SENDERsame (origination number / sender id)No SMS channel — and none today regardless, see below

messagingEmailProvider picks the transport, per channel rather than per capability — SMS and push are separate channels and will each bring their own key, so choosing a mail sink must not also decide how texts go out. Two values:

ValueWhat it does
ses (default)Provisions a verified SES identity and really sends.
logProvisions nothing; every message is written to the Lambda's log in full and none is sent.

Default ses, so a deployment that says nothing still mails — a stack that means not to send says so, rather than silence meaning it. An unrecognised value fails the deploy naming the key: defaulting a typo to ses would provision a real identity for a stack that asked only to log, which is the mistake the key exists to prevent. log is what the shop's alpha and beta stacks use.

The messaging senders are the keys above with no default, and deliberately so. An address is one verified identity per AWS account, so a repository cannot name one that is true for every stack built from it — and a placeholder is worse than nothing: it provisions an identity nobody can verify, after which every send is refused at the sender while reading like a delivery problem.

Unset is therefore a real answer rather than a fallback, and it is checked. A platform whose plugins declare the Messaging capability and whose stack names no sender is refused at deploy time, naming the key — so a mail-less deployment cannot reach production as silence. A platform no plugin of which needs messaging deploys fine with all three unset.

Under ses, setting the address is half the job: it has to be verified (AWS mails it a link, and until somebody follows it SES refuses every send), and an account in the SES sandbox can send only to verified recipients regardless. Under log the address is only a header — nothing verifies it, and it defaults if unset.

These keys configure one of the deployment's three capabilities; for the roster, how a plugin declares a need, and the deploy-time refusals that follow, see Platform Capabilities.

messagingSmsSender is carried but no transport reads it yet. It exists so a stack can state the number where the other senders live; the SMS channel appears when a backend does, not when this is set. Claiming a channel ahead of its transport would collect preferences that silently deliver nothing, which is the failure the derived channel set exists to prevent.

Any future deploy-time helper reading via Util_LocalConfig.get("…") automatically participates in the same precedence ladder.

Bringing your own identity provider​

Setting identityProviderId means the pool is yours, not the framework's — and so is the active-role store, the table the pool's pre-token-generation trigger reads to narrow a caller's groups to the role they chose. The two are provisioned together, outside every stack, because a Cognito pool has exactly one trigger slot: two platform stacks on one pool each deploying their own store would leave the winning trigger reading rows the other platform's resolver never writes, and every role switch would report success and do nothing.

The store's name is derived, not configured — ReventlessActiveRoleStore-<identityProviderId> — so every stack on the provider resolves the same table and no two can disagree. Provision both with:

Run it from your platform-aws package — anywhere that depends on @reventlessdev/reventless-aws — where it is on the path as provision-identity:

cd platform-aws

# create a pool and its store
pnpm exec provision-identity --name MyIdentity

# or add the store to a pool you already have
pnpm exec provision-identity --provider-id eu-west-1_AbCdEfGhI
Without --provider-id, an unmatched --name creates a pool

The default name is ReventlessIdentity. If no pool carries the name it is given, the script makes one — so pointing it at an existing pool means naming that pool exactly, or passing its id. Two pools named the same is refused rather than guessed at.

A pool created this way is owned by no stack: pulumi destroy will not remove it, which is the point of bringing your own, and also means a stray one lingers.

Re-running is safe — existing resources are adopted, never recreated. The script deliberately does not attach the trigger; the deploy does that, and refuses if the pool's slot is held by a trigger that is not one of ours.

A stack given an identityProviderId whose pool or store does not exist fails the deploy. Both exist or neither does.

Each platform on a shared provider keeps its own active role: rows are keyed by (sub, clientId), and each stack declares its own app client. Narrowing to a role in one platform leaves the others as they were.

Host UI custom domain. Both hostUiBaseDomain and hostUiHostedZoneId must be set together — if either is missing the framework keeps the default *.cloudfront.net URL. When both are set, the framework provisions an ACM cert (us-east-1) + Route53 alias and serves the shell at ${baseName}-${stack}.${baseDomain} (or ${baseName}.${baseDomain} when stack ∈ hostUiProdStacks). See ui-fragments-deployment.md → "Conditional: custom domain" for the full provisioning detail and docs/analysis/host-ui-custom-domain.md for the design rationale and multi-tenancy notes.

Do not commit hostUiBaseDomain

Set it per deployment — an environment variable or an untracked local config — never in a checked-in Pulumi.<stack>.yaml. A committed value points every fork of that config at a Route53 zone its owner does not control: the certificate request simply fails, having tried to provision in somebody else's zone.

Env var (CI deploys):

camelCase config key → REVENTLESS_<SCREAMING_SNAKE>. Example for identityProviderId:

# .github/workflows/deploy-online-shop-hybrid.yml
jobs:
deploy:
uses: ./.github/workflows/deploy-reventless-aws.yml
secrets:
…
# In deploy-reventless-aws.yml, surface the env var to the pulumi step:
# env:
# REVENTLESS_IDENTITY_PROVIDER_ID: ${{ secrets.IDENTITY_PROVIDER_ID }}

An empty value is treated as "not set" so a stray empty export does not mask the sidecar.

Sidecar (dev-local):

# platform-aws/Pulumi.local.yaml — gitignored, read from process.cwd()
identityProviderId: eu-west-1_AbCdEfGhI

Notes:

  • Bare keys, no namespace prefix. The same value in Pulumi.<stack>.yaml would be written platform:identityProviderId: …; the sidecar drops the platform: prefix because lookup happens after the namespace is bound.
  • Format: minimal key: value lines. Quotes optional, # introduces comments, blank lines ignored — not a full YAML parser.
  • Gitignored at the repo root via **/Pulumi.local.yaml — verify with git check-ignore -v platform-aws/Pulumi.local.yaml.

Plugin UI bundle URL (optional)​

Plugins with aggregates or read models accept an optional UI bundle URL — the location of the Module-Federation remote that exposes the plugin's React components for the platform shell. The generated AWS Plugin.res reads <PLUGIN>_UI_BUNDLE_URL from process.env (PascalCase plugin name → SCREAMING_SNAKE_CASE: Catalog → CATALOG_UI_BUNDLE_URL). Unset → no UI fragments are registered for that plugin.

Set it on the deployed Lambda by adding the env var to the Pulumi stack config and threading it through to the plugin's deploy step. The same env-var name is consumed by the local dev platform, so a single setting works for both deploy paths. See platform-and-plugin-guide.md → AutoUI for the runtime mechanics.

4g. Create deploy-manifest.yaml at the project root​

This manifest tells the GitHub Actions workflow which stacks exist and their deployment order:

platform:
path: platform-aws
name: my-app-platform

plugins:
- name: catalog
path: catalog-aws
depends-on: []

- name: ordering
path: ordering-aws
depends-on:
- catalog
  • path points to the -aws package root (relative to repo root).
  • name matches the Pulumi project name (without the org prefix).
  • depends-on declares deployment ordering.
  • region (top level) is where the stacks deploy. On a plugin, region overrides it for that stack only, and the Lambda layer is looked up there; where none is published, the stack deploys without one. deploy-app deploys every stack to one region, so it refuses a manifest that names another.
  • review: false on a plugin keeps it out of a pull request's review environment: the review uses the base environment's stack, and settings naming it keep pointing there. Set it on a stack that holds something only one stack can hold, such as an S3 bucket's notification, which a review copy would take over and then remove on close.

The workflow runs only what it brings with it, plus commands from @reventlessdev/reventless-aws, which the -aws packages already depend on. The recovery before each pulumi up is clear-pending-creates from that package, so the version your packages install decides which recovery runs.

4h. Add the GitHub Actions workflow​

Create .github/workflows/deploy-aws.yml:

name: Deploy to AWS

on:
push:
branches: [main, alpha, beta]
pull_request:
branches: [main]

jobs:
deploy:
uses: ReventlessDev/reventless-core/.github/workflows/deploy-reventless-aws.yml@main
with:
manifest: deploy-manifest.yaml
node-version: "22"
secrets:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

The reusable workflow handles change detection, environment selection, deployment ordering, and secret resolution automatically.

4i. A self-managed state backend​

In plain words: Pulumi keeps a record of every stack (its state) somewhere. By default that is Pulumi Cloud, reached with an access token. It can also be a bucket you own, a self-managed backend. The workflow deploys to either. For a bucket, name it, and name the key that encrypts the stacks' secrets:

jobs:
deploy:
uses: ReventlessDev/reventless-core/.github/workflows/deploy-reventless-aws.yml@main
with:
manifest: deploy-manifest.yaml
pulumi-backend-url: s3://my-app-pulumi-state?region=eu-west-1
pulumi-secrets-provider: awskms://alias/my-app-pulumi-{stack}?region=eu-west-1
pulumi-concurrent-updates: true
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
InputWhat it does
pulumi-backend-urlExported as PULUMI_BACKEND_URL in every job that runs Pulumi. No PULUMI_ACCESS_TOKEN is needed. A job given neither the URL nor the token fails before it runs Pulumi, saying which is missing
pulumi-secrets-providerGiven to every stack the workflow creates: a review stack, or an environment deployed for the first time. {stack} is replaced by the stack's name, so one key per environment works. A review stack uses its base environment's stack name, so it shares the base's key
pulumi-concurrent-updatestrue: a bucket locks each stack on its own, so plugins deploy in parallel and runs on different branches do not wait for each other

Why the secrets provider matters. On a self-managed backend a new stack is encrypted with a passphrase by default. The workflow has no passphrase, so a stack created that way cannot be deployed, and a review stack cannot copy its base's configuration. Name a provider (awskms://…, or another Pulumi supports) and every stack the workflow creates uses it.

What the deploy identity needs. Besides what the deploy itself creates:

  • on the state bucket: s3:ListBucket on the bucket, and s3:GetObject, s3:PutObject and s3:DeleteObject on its objects (Pulumi writes a lock object per update and deletes it after);
  • on each KMS key the provider names: kms:Encrypt and kms:Decrypt.

Existing stacks keep their provider. The input applies only to stacks the workflow creates. A stack created earlier with a passphrase keeps it until you change it:

pulumi stack change-secrets-provider "awskms://alias/my-app-pulumi-alpha?region=eu-west-1" --stack alpha

A team whose keys are not named after the stack sets secretsprovider in each Pulumi.<stack>.yaml itself and leaves the input empty.

Moving stacks from Pulumi Cloud to a bucket. Once per stack, in each -aws package, platform and plugins alike. Change the provider before leaving Pulumi Cloud: a bucket cannot decrypt secrets that Pulumi Cloud encrypted.

pulumi stack select alpha
pulumi stack change-secrets-provider "awskms://alias/my-app-pulumi-alpha?region=eu-west-1"
pulumi stack export --file alpha.json
pulumi login s3://my-app-pulumi-state?region=eu-west-1
pulumi stack init alpha --secrets-provider "awskms://alias/my-app-pulumi-alpha?region=eu-west-1"
pulumi stack import --file alpha.json
pulumi preview # should show no changes

References between stacks change their first part. A bucket has no Pulumi Cloud organization, so its stacks are all organization/<project>/<stack>. Every setting that names another stack (platform:stack: myorg/my-app-platform/alpha, and any of your own) becomes organization/my-app-platform/alpha. Rewrite them all before the first pulumi preview.

Commit the rewritten Pulumi.alpha.yaml files, which now name the key and the new references, and then switch the workflow inputs. Keep the stack on Pulumi Cloud, untouched, until the bucket has deployed: it is the way back. alpha.json holds the stack's state, so keep it out of the repository.

5. Multi-Repository Setup​

The same architecture works when plugins live in separate repositories. Each plugin repo contains its agnostic package and its -aws package. The platform lives in its own repo.

Repository structure​

repo: my-org/platform
├── platform/ # Local dev server (depends on all plugins)
├── platform-aws/ # AWS platform deployment
├── deploy-manifest.yaml # platform only
└── .github/workflows/deploy-aws.yml

repo: my-org/catalog
├── catalog-spec/
├── catalog/
├── catalog-aws/
├── deploy-manifest.yaml # catalog plugin only
└── .github/workflows/deploy-aws.yml

repo: my-org/ordering
├── ordering-spec/
├── ordering/
├── ordering-aws/
├── deploy-manifest.yaml # ordering plugin only
└── .github/workflows/deploy-aws.yml

What changes from monorepo​

One manifest per repo. Each repo has its own deploy-manifest.yaml listing only the stacks in that repo:

# repo: my-org/catalog — deploy-manifest.yaml
plugins:
- name: catalog
path: catalog-aws
depends-on: []
# repo: my-org/platform — deploy-manifest.yaml
platform:
path: platform-aws
name: my-app-platform

Published package dependencies. The -aws package depends on the published plugin package, not a workspace link:

{
"name": "@myorg/catalog-aws",
"private": true,
"dependencies": {
"@reventlessdev/reventless-aws": "^3.0.0",
"@myorg/catalog": "^1.2.0"
}
}

Each repo calls the reusable workflow independently. Push to the catalog repo triggers catalog's workflow. Push to the platform repo triggers the platform's workflow. They are independent CI pipelines.

The platform reference works across repos. platform:stack is a Pulumi stack name — it references a stack by name, not by repo. Ordering in repo B finds the platform deployed from repo A:

# repo: my-org/ordering — ordering-aws/Pulumi.alpha.yaml
config:
aws:region: eu-west-1
platform:stack: myorg/my-app-platform/alpha

Platform repo and local development​

The platform repo contains platform/ for the local dev server. It depends on all plugin packages (published versions). For local development with unpublished plugin changes, use npm link or workspace overrides to point to local checkouts.

Deployment coordination​

ScenarioWhat to do
Plugin code changePush to plugin repo. CI deploys the -aws stack. Schema updates at runtime.
New pluginCreate new repo with plugin + -aws packages. Deploy. Platform is untouched.
Remove pluginDestroy the plugin stack. Remove the repo. Schema updates at runtime.
Cross-plugin spec changePublish the spec package. Update dependent plugin repos. Deploy each.
Framework upgradeUpdate @reventlessdev/* versions in each repo. Deploy platform first, then plugins.

What stays identical​

  • Pulumi stack structure (platform + per-plugin stacks)
  • platform:stack config
  • Runtime schema registration via PluginExtensionPoint
  • The reusable workflow (called from each repo)
  • Deployment order (platform first, then plugins)
  • Secret management (per-repo GitHub secrets with environment overrides)

6. Configuration​

platform:stack -- platform stack reference​

Every plugin stack must reference the platform stack so it can read the shared API ID and admin extension points:

config:
platform:stack: org/my-app-platform/alpha

The format is <pulumi-org>/<project-name>/<stack-name>. The stack name matches the branch/environment name.

Environment-specific configuration​

Each environment gets its own Pulumi.<env>.yaml. Common differences between environments:

SettingAlphaMain (Production)
aws:regioneu-west-1eu-west-1
platform:stackorg/my-app-platform/alphaorg/my-app-platform/main

Add custom config keys per environment for Lambda memory, DynamoDB capacity, or feature flags:

config:
aws:region: eu-west-1
platform:stack: org/my-app-platform/main
my-app-catalog:lambdaMemory: 1024
my-app-catalog:dynamoDbBillingMode: PROVISIONED

Branch name = Pulumi stack name​

The git branch name is used directly as the Pulumi stack name. Deployment only happens if a Pulumi.<branch>.yaml file exists in the -aws package root.

  • Push to alpha -- looks for Pulumi.alpha.yaml -- deploys if found.
  • Push to main -- looks for Pulumi.main.yaml -- deploys if found.
  • Push to feature-xyz -- no Pulumi.feature-xyz.yaml -- skipped.

Adding or removing environments​

ActionWhat to doWorkflow change needed?
Add test environmentCreate test branch + Pulumi.test.yaml in every -aws packageNo
Add temporary demo environmentCreate demo branch + Pulumi.demo.yaml in relevant -aws packagesNo
Remove an environmentDelete its Pulumi.<env>.yaml filesNo
Disable one plugin on one branchRename Pulumi.<branch>.yaml to Pulumi.<branch>.yaml.disabledNo

Stack naming​

Each plugin and the platform get their own Pulumi stack per environment:

org/my-app-platform/alpha      org/my-app-platform/main
org/my-app-catalog/alpha org/my-app-catalog/main
org/my-app-ordering/alpha org/my-app-ordering/main

7. Deployment Scenarios​

First deployment​

  1. Ensure all Pulumi.yaml and Pulumi.<env>.yaml files are committed.
  2. Deploy the platform stack first:
    cd platform-aws
    pulumi up --stack alpha
  3. Deploy plugins (can run in parallel if independent):
    cd catalog-aws
    pulumi up --stack alpha
  4. Deploy dependent plugins after their dependencies:
    cd ordering-aws
    pulumi up --stack alpha

Or push to the alpha branch and let GitHub Actions handle the ordering automatically.

Adding a new plugin​

  1. Create the plugin package (shipping/) and its AWS package (shipping-aws/).
  2. Add src/Main.res, package.json, rescript.json, Pulumi.yaml, and Pulumi.<env>.yaml to shipping-aws/.
  3. Register shipping-aws/ in the Lerna workspaces.
  4. Add the plugin entry to deploy-manifest.yaml:
    - name: shipping
    path: shipping-aws
    depends-on: []
  5. If the plugin consumes another plugin's extension point, add depends-on.
  6. Commit and push. The workflow deploys the plugin. At runtime, the plugin connects to the PluginExtensionPoint and its schema fragment is added to the unified API.

No platform redeployment is needed.

Updating a plugin (independent redeploy)​

Change the plugin's source code and push. Only that plugin's -aws stack redeploys. At runtime, the reconnect updates the schema fragment if it changed. No platform or other plugin stacks are touched.

Removing a plugin​

  1. Destroy the plugin stack: pulumi destroy --stack alpha from the -aws package.
  2. Remove the -aws package and the plugin entry from deploy-manifest.yaml.

At runtime, the disconnect event removes the plugin's schema fragment from the unified API. No platform redeployment needed.

Cross-plugin extension wiring​

When plugin B subscribes to plugin A's extension point:

  1. Plugin A exports its extension points as stack outputs (handled automatically by deployPlugin's _interopMeta export).
  2. Plugin B declares a dependency on plugin A in deploy-manifest.yaml (depends-on: [pluginA]), so A deploys first.
  3. At runtime, once both have registered with the platform, B's extension is connected to A's extension point. Plugin B's stack needs no setting naming plugin A.

8. API Functions​

Platform.deployPlatform(~version)​

Called in platform-aws/src/Main.res. Creates:

  • AppSync API -- the single unified GraphQL endpoint for the entire application.
  • Scheduler -- Pulumi component for scheduling recurring tasks.
  • Admin components -- Plugin aggregate, read model, extension point (via Platform_Admin.construct).
  • Stack outputs -- exports API ID, role ARN, and admin extension points for plugin stacks to consume.

Returns dict<Pulumi.Output.t<JSON.t>> -- the Pulumi stack outputs dict. Assign as the ESM default export: let default = Platform.deployPlatform(...).

The admin schema (plugin queries, cloner mutations) is pushed at deploy time. Plugin schema fragments are added at runtime when plugins connect.

Platform.deployPlugin(~version, ~plugin)​

Called in each plugin's -aws package (e.g., catalog-aws/src/Main.res). Creates:

  • Scheduler -- each plugin stack creates its own scheduler instance (closures cannot cross Pulumi stacks).
  • Plugin infrastructure -- all DynamoDB tables, SQS queues, Lambda functions, and S3 buckets for the plugin's aggregates, read models, tasks, and DCB slices.
  • AppSync DataSources/Resolvers -- created against the shared API using the API ID from the platform StackReference.
  • Stack outputs -- exports _interopMeta (extension points, event topics) for cross-stack consumption by dependent plugins.

Returns dict<Pulumi.Output.t<JSON.t>> -- the Pulumi stack outputs dict. Assign as the ESM default export: let default = Platform.deployPlugin(...). The plugin name is auto-registered by Plugin_Builder.make -- no manual registerDcbConfig call is needed.

At runtime, the plugin connects to the PluginExtensionPoint with its schema fragment. The platform updates the combined schema.

Platform.makePlatform(~version, ~plugins)​

Monolithic deployment mode (still supported). Deploys the platform and all plugins in a single Pulumi stack. Used by the platform/ package for local development:

Platform.makePlatform(
~version=Reventless.PackageVersion.fromCwd(),
~plugins=[
module(Catalog),
module(Ordering),
],
)

This creates one stack with all resources. To migrate to per-plugin deployment, create the -aws packages as described in this guide.

9. Secret Management​

Required GitHub secrets (platform-wide defaults)​

Go to your GitHub repo Settings, then Secrets and variables, then Actions, then Repository secrets:

NameValuePurposeRequired
PULUMI_ACCESS_TOKENYour Pulumi access tokenAuthenticates Pulumi CLI (default for all stacks)on Pulumi Cloud; not with a self-managed backend
AWS_ACCESS_KEY_IDIAM access keyAWS authentication for resource creationyes
AWS_SECRET_ACCESS_KEYIAM secret keyAWS authentication for resource creationyes
IDENTITY_PROVIDER_IDAn existing user pool id, e.g. eu-west-1_AbCdEfGhIDeploy against an identity provider you own instead of provisioning oneno — see below
PACKAGES_TOKENA token that can read your private package registryGiven to every install as GITHUB_TOKEN, the variable an .npmrc line such as //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} readsonly if you install from a private registry

These are the defaults used by the platform and all plugins unless overridden.

No npm token is needed for the framework. @reventlessdev/* are public on npmjs, so an install is anonymous unless your app also installs from a private registry (PACKAGES_TOKEN above). Publishing is the only thing that authenticates, and that belongs to the release workflow rather than to a deploy.

IDENTITY_PROVIDER_ID is configuration, not a credential​

It is carried as a secret only because that is how a value reaches a workflow — a user pool id authenticates nobody. The same id is exported as a stack output and written into the host shell's public config.json, so treating it as sensitive would be theatre. It is listed here because this is where someone sets up a deployment, not because it needs protecting.

An unset or misspelled IDENTITY_PROVIDER_ID does not fail the deploy

It means auto mode, and auto mode creates a new user pool. The deploy goes green, mints a fresh pool, and every account in the pool you meant to use is orphaned — with nothing in the log to say so.

So: set it, or deliberately leave it unset. There is no in-between state that does what you meant.

Bringing your own provider also means bringing its active-role store. See Bringing your own identity provider above: a stack given this id looks up both and fails if either is missing.

Per-plugin secret overrides (optional)​

To use a different Pulumi access token or AWS credentials for a specific plugin:

  1. Go to Settings, then Environments, then New environment.
  2. Name it deploy-<plugin> (e.g., deploy-catalog).
  3. Add a PULUMI_ACCESS_TOKEN secret (and/or AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) to that environment. These override the repository-level defaults for that plugin's deployment job.

The same mechanism works for the platform stack via the deploy-platform environment.

Resolution order: environment secret (per-plugin or per-platform) > repository secret (platform-wide default).

What goes where​

WhatWhereWhy
Identity provider idGitHub secret or Pulumi.<env>.yamlNot a credential — a secret only because CI has no other injection point. Committed is fine if the pool is not itself sensitive
AWS region, memory, capacityPulumi.<env>.yaml (committed)Non-secret, per-environment
Platform stack referencePulumi.<env>.yaml (committed)Points to correct env's platform
Feature flagsPulumi.<env>.yaml (committed)Per-environment behavior
Access tokens, keysGitHub secrets (never committed)Authentication credentials
Database passwordspulumi config --secret (encrypted in state)Encrypted per-stack

Never commit secrets to .env, Pulumi.<env>.yaml, or any file in the repo.

10. Troubleshooting​

Stack not found​

error: no stack named "org/my-app-catalog/alpha" found

Cause: The Pulumi stack has not been created yet. Fix: Run pulumi stack init alpha from the -aws package root, or let the GitHub Actions workflow create it on first deployment.

Missing platform:stack config​

error: Missing required configuration variable 'platform:stack'

Cause: The plugin's Pulumi.<env>.yaml does not include the platform:stack key. Fix: Add platform:stack: org/<platform-project>/<env> to the plugin's environment config file.

Cross-stack reference errors​

error: getting stack reference "org/my-app-catalog/alpha": no stack found

Cause: The referenced stack (e.g., catalog) has not been deployed yet, but the ordering plugin depends on it. Fix: Deploy the dependency stack first. Check depends-on in deploy-manifest.yaml to ensure correct ordering. For manual deployments, always deploy dependencies before dependents.

StackReference output is undefined​

Cause: The dependency stack deployed but did not export the expected output (e.g., _interopMeta or extensionPoints). Fix: Verify the dependency plugin calls Platform.deployPlugin(...) (not makePlatform). Redeploy the dependency stack.

Plugin schema not appearing in API​

Cause: The plugin deployed successfully but its schema fragment is not in the unified API. Fix: The schema is registered at runtime when the plugin connects. Check that the plugin's Lambda is running and can reach the PluginExtensionPoint (SQS queue). Verify the connect event was processed by checking the admin Plugin read model.

Pulumi.<branch>.yaml not found (deployment skipped)​

Cause: The branch name does not match any environment config file. Fix: This is expected behavior for feature branches. If you want the branch to deploy, create a Pulumi.<branch>.yaml file in the relevant -aws packages.

ReScript compilation error in -aws package​

Cause: Missing dependency in rescript.json or the plugin package has not been built. Fix: Ensure rescript.json in the -aws package lists all required dependencies including the plugin package. Run pnpm run build from the monorepo root before deploying.

Platform redeployment required after framework upgrade​

After upgrading @reventlessdev/reventless-aws or @reventlessdev/reventless-spec, redeploy the platform stack first, then all plugin stacks. The GitHub Actions workflow handles this automatically when it detects changes in framework packages.

Reference Implementation​

The examples/online-shop-hybrid/ directory in the reventless-core repo contains a working reference implementation with per-plugin deployment configured for both alpha and main environments. It follows all patterns described in this guide.