Skip to main content
  1. Docs
  2. Secrets & Configuration
  3. Guides
  4. Use with Pulumi IaC
  5. Migrate from stack references to pulumi-stacks

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.

    This guide is for existing Pulumi IaC users who already read outputs across stacks. If you haven’t used ESC with Pulumi IaC before, read Use with Pulumi IaC first.

    Prerequisites

    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:

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

    2. Get one consumer working end to end. Move that stack’s StackReference reads into a pulumi-stacks block, verify a no-op preview, and deploy. Now you have a proven pattern to repeat.

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

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