Migrate from stack references to the pulumi-stacks provider
Stack references let one stack read another stack’s outputs through the StackReference resource in your program. The ESC pulumi-stacks provider does the same job from an environment instead: it reads another stack’s outputs and exposes them to your program as ordinary stack configuration. Your consuming program no longer instantiates a StackReference — it just reads config — and the cross-stack wiring lives in ESC, where it can be composed, pinned, and shared like any other configuration.
This guide shows how to migrate one consumer from StackReference to pulumi-stacks, then how to work through a chain of stacks safely.
Prerequisites
- Pulumi CLI installed
- A Pulumi account — ESC requires the Pulumi Cloud backend
- Two stacks where one reads the other’s outputs via
StackReference
The starting point
Suppose an application stack reads networking details from a vpc-infra stack using a StackReference:
import * as pulumi from "@pulumi/pulumi";
const infra = new pulumi.StackReference("acmecorp/vpc-infra/prod");
const vpcId = infra.requireOutput("vpcId");
const publicSubnetIds = infra.requireOutput("publicSubnetIds");
// ... use vpcId and publicSubnetIds to create resources
import pulumi
infra = pulumi.StackReference("acmecorp/vpc-infra/prod")
vpc_id = infra.require_output("vpcId")
public_subnet_ids = infra.require_output("publicSubnetIds")
# ... use vpc_id and public_subnet_ids to create resources
infra, err := pulumi.NewStackReference(ctx, "acmecorp/vpc-infra/prod", nil)
if err != nil {
return err
}
vpcId := infra.RequireOutput(pulumi.String("vpcId"))
publicSubnetIds := infra.RequireOutput(pulumi.String("publicSubnetIds"))
// ... use vpcId and publicSubnetIds to create resources
var infra = new StackReference("acmecorp/vpc-infra/prod");
var vpcId = infra.RequireOutput("vpcId");
var publicSubnetIds = infra.RequireOutput("publicSubnetIds");
// ... use vpcId and publicSubnetIds to create resources
var infra = new StackReference("acmecorp/vpc-infra/prod");
Output<Object> vpcId = infra.requireOutput("vpcId");
Output<Object> publicSubnetIds = infra.requireOutput("publicSubnetIds");
// ... use vpcId and publicSubnetIds to create resources
resources:
infra:
type: pulumi:pulumi:StackReference
properties:
name: acmecorp/vpc-infra/prod
variables:
vpcId: ${infra.outputs["vpcId"]}
publicSubnetIds: ${infra.outputs["publicSubnetIds"]}
The producer stack, vpc-infra, exports those values as stack outputs; nothing about the producer changes in this migration.
Read the outputs through pulumi-stacks
Create (or edit) the environment that your application stack imports and add a pulumi-stacks block that pulls in the producer’s outputs, then map them into pulumiConfig:
# acmecorp/app/prod
values:
stackRefs:
fn::open::pulumi-stacks:
stacks:
vpcInfra:
stack: vpc-infra/prod
pulumiConfig:
vpcId: ${stackRefs.vpcInfra.vpcId}
publicSubnetIds: ${stackRefs.vpcInfra.publicSubnetIds}
The stack property takes the project-qualified stack name (<project>/<stack>); the outputs are read with no additional tokens or credentials. Confirm they resolve:
pulumi env open acmecorp/app/prod
If the application stack doesn’t already import this environment, add it to Pulumi.prod.yaml:
environment:
- app/prod
Update the consuming program
With the outputs arriving as configuration, replace the StackReference in your program with ordinary config reads:
import * as pulumi from "@pulumi/pulumi";
const config = new pulumi.Config();
const vpcId = config.require("vpcId");
const publicSubnetIds = config.requireObject<string[]>("publicSubnetIds");
// ... use vpcId and publicSubnetIds to create resources
import pulumi
config = pulumi.Config()
vpc_id = config.require("vpcId")
public_subnet_ids = config.require_object("publicSubnetIds")
# ... use vpc_id and public_subnet_ids to create resources
conf := config.New(ctx, "")
vpcId := conf.Require("vpcId")
var publicSubnetIds []string
conf.RequireObject("publicSubnetIds", &publicSubnetIds)
// ... use vpcId and publicSubnetIds to create resources
var config = new Pulumi.Config();
var vpcId = config.Require("vpcId");
var publicSubnetIds = config.RequireObject<string[]>("publicSubnetIds");
// ... use vpcId and publicSubnetIds to create resources
var config = ctx.config();
var vpcId = config.require("vpcId");
var publicSubnetIds = config.requireObject("publicSubnetIds", List.class);
// ... use vpcId and publicSubnetIds to create resources
config:
vpcId:
type: string
publicSubnetIds:
type: array
items:
type: string
Use the accessor that matches each output’s type: a string accessor (require / Require) for vpcId, an object accessor (requireObject / RequireObject) for the publicSubnetIds list, and a secret accessor (requireSecret / RequireSecret) for any value that was secret in the producer stack.
Verify the migration is a no-op
The whole point is that the resources don’t change — only where their inputs come from. Preview before applying:
pulumi preview --stack prod
The values feeding your resources are the same, so the plan should show no changes. Once you see a clean preview, run pulumi up, then delete the now-unused StackReference code.
StackReference.requireOutput marks secret outputs as secret automatically. When you map an output that was secret through pulumiConfig, wrap it in fn::secret so it stays encrypted as it flows into your stack.Migrate a chain at scale
Real projects often have a chain of stacks — networking feeds a cluster, which feeds an application. Migrate it incrementally rather than all at once, and let each step prove itself with a no-op preview:
Start at the end of the chain. Begin with a stack that only consumes outputs and isn’t referenced by anything downstream. Migrating a leaf consumer can’t break anything upstream, so it’s the safest place to validate your environment structure.
Get one consumer working end to end. Move that stack’s
StackReferencereads into apulumi-stacksblock, verify a no-op preview, and deploy. Now you have a proven pattern to repeat.Work backward through the chain. Move to the next stack up — the one whose outputs the stack you just migrated consumes. A stack can be both a producer (still exporting outputs) and a consumer (now reading its own upstream inputs through
pulumi-stacks); migrating its consumer side doesn’t affect the stacks that read its outputs.Keep every step a no-op. For each stack, the switch should change only the source of its inputs, never its resources. If a preview shows a diff, reconcile it before deploying. Promoting to production as a series of no-ops means you can stop at any point with a working system.
Because the producer’s outputs are unchanged throughout, you can run StackReference and pulumi-stacks side by side during the transition and cut each consumer over independently.
Next steps
pulumi-stacksprovider reference - Full input and output schema- Adopt ESC for config and secrets - Move stack-file config and secrets into ESC with the same incremental approach
- Importing environments - Compose cross-stack outputs with other configuration
- Stack references - The
StackReferencemodel you’re migrating from