Stack references & multi-project
Wire networking to app stacks.
Two teams, one cloud account. The networking team lays the cable: they own the VPC (virtual private cloud, the private network your servers live inside), the subnets, the route tables, the link back to the office. The application team wants to plug something in and go home. You do not want app engineers rebuilding a network on every deploy, and you do not want one giant program that touches every resource in the company because somebody bumped a container image. A stack reference is the socket on the wall. The networking stack publishes what it built, each app stack reads the address off the socket, and nobody has to run their own cable.
Two habits make that work day to day. The producer stack publishes a small, stable set of values, and each consumer points at the right socket for its environment. Splitting has a real cost, so split where ownership or deploy cadence genuinely differs (network versus app, data versus app), not once per resource. Every boundary you create is a deploy-order dependency and a place where a rename breaks a stranger you have never met. There is a third thing people learn the hard way, and it is why this lesson spends so long on storage: a reference copies values into the consumer's state (the ledger where Pulumi writes down everything it built and everything it knows about it), and the word "secret" across that boundary means something narrower than most engineers assume.
Outputs Are the Socket on the Wall
Every top-level export const in a Pulumi program becomes a stack output. After a successful pulumi up, those values are written into that stack's state, and state is the only place a reference ever reads from. Treat outputs as the public API (application programming interface, the small labelled panel other teams are allowed to touch) of the stack. Name them for the people who will read them, not for yourself. Export identifiers and ARNs (Amazon Resource Names, the long unique strings AWS uses to point at one specific resource) rather than whole resource objects: a consumer wants a vpcId string and a list of subnet IDs, not your VPC construct. Keep the surface small and boring. Renaming an output is a breaking change for everyone downstream, and you will not find out until their next deploy.
// networking/index.ts: this project owns the shared networkimport * as awsx from "@pulumi/awsx";const vpc = new awsx.ec2.Vpc("platform", {numberOfAvailabilityZones: 2,});// Anything exported at the top level becomes a stack output.// This is the panel other teams may touch: IDs, not objects.export const vpcId = vpc.vpcId;export const privateSubnetIds = vpc.privateSubnetIds;export const publicSubnetIds = vpc.publicSubnetIds;
Deploy it, then look at what it actually published. pulumi up prints the outputs at the end of the update, and pulumi stack output reads them back out of state at any time. Pass the fully-qualified stack name and you can run that from outside the project directory, as long as your CLI is logged into the same backend.
cd ~/infra/networking# deploy the producer first: outputs exist only after a successful updatepulumi up --stack acme/networking/prod --yes
Updating (prod)View Live: https://app.pulumi.com/acme/networking/prod/updates/9Type Name Status+ pulumi:pulumi:Stack networking-prod created (94s)+ └─ awsx:ec2:Vpc platform created (0.9s)+ └─ aws:ec2:Vpc platform created (13s)+ ├─ aws:ec2:InternetGateway platform created (2s)+ ├─ aws:ec2:Subnet platform-private-1 created (12s)+ ├─ aws:ec2:Subnet platform-private-2 created (12s)+ ├─ aws:ec2:Subnet platform-public-1 created (12s)+ ├─ aws:ec2:Subnet platform-public-2 created (12s)+ ├─ aws:ec2:RouteTable platform-private-1 created (2s)+ ├─ aws:ec2:RouteTable platform-private-2 created (2s)+ ├─ aws:ec2:RouteTable platform-public-1 created (2s)+ ├─ aws:ec2:RouteTable platform-public-2 created (2s)+ ├─ aws:ec2:Eip platform-1 created (2s)+ ├─ aws:ec2:Eip platform-2 created (2s)+ ├─ aws:ec2:Route platform-public-1 created (1s)+ ├─ aws:ec2:Route platform-public-2 created (1s)+ ├─ aws:ec2:RouteTableAssociation platform-private-1 created (1s)+ ├─ aws:ec2:RouteTableAssociation platform-private-2 created (1s)+ ├─ aws:ec2:RouteTableAssociation platform-public-1 created (1s)+ ├─ aws:ec2:RouteTableAssociation platform-public-2 created (1s)+ ├─ aws:ec2:NatGateway platform-1 created (79s)+ ├─ aws:ec2:NatGateway platform-2 created (81s)+ ├─ aws:ec2:Route platform-private-1 created (1s)+ └─ aws:ec2:Route platform-private-2 created (1s)Outputs:privateSubnetIds: [[0]: "subnet-0c1d2e3f4a5b6c7d8"[1]: "subnet-09876fedcba543210"]publicSubnetIds : [[0]: "subnet-0aa11bb22cc33dd44"[1]: "subnet-055e6f7a8b9c0d1e2"]vpcId : "vpc-0a1b2c3d4e5f67890"Resources:+ 24 createdDuration: 1m37s
# the whole published surfacepulumi stack output --stack acme/networking/prod# one value, unquoted, safe to capture into a shell variablepulumi stack output vpcId --stack acme/networking/prod# the key list is the contract other teams depend onpulumi stack output --json --stack acme/networking/prod | jq -S 'keys'
Current stack outputs (3):OUTPUT VALUEprivateSubnetIds ["subnet-0c1d2e3f4a5b6c7d8","subnet-09876fedcba543210"]publicSubnetIds ["subnet-0aa11bb22cc33dd44","subnet-055e6f7a8b9c0d1e2"]vpcId vpc-0a1b2c3d4e5f67890vpc-0a1b2c3d4e5f67890["privateSubnetIds","publicSubnetIds","vpcId"]
Plugging In With StackReference
In the consuming program you build a pulumi.StackReference. It takes a fully-qualified stack name shaped <org>/<project>/<stack>, so acme/networking/prod means the prod stack of the networking project in the acme organization. Self-managed backends (state kept in an S3 bucket, Simple Storage Service, Amazon's file store, or an Azure blob, or a local folder) have no real organizations, so they use the literal word organization in that first slot. Pass a stable logical name as the first argument and put the target in args.name. The logical name is what ends up in the resource's URN (uniform resource name, Pulumi's internal address for one resource in one stack), so when the target string changes later you get a fresh read instead of a confusing delete-and-create in the plan.
// app/index.ts: reuse the network instead of recreating itimport * as pulumi from "@pulumi/pulumi";import * as aws from "@pulumi/aws";const cfg = new pulumi.Config();// "net" is the logical name (keep it stable forever).// args.name is the stack you point at: <org>/<project>/<stack>.const net = new pulumi.StackReference("net", {name: cfg.require("networkStack"),});// requireOutput fails the deployment when the output is missing.// getOutput would hand back undefined and let you build something broken.const vpcId = net.requireOutput("vpcId") as pulumi.Output<string>;const subnetIds = net.requireOutput("privateSubnetIds") as pulumi.Output<string[]>;const sg = new aws.ec2.SecurityGroup("app", {vpcId: vpcId,description: "app tier, internal callers only",ingress: [{ protocol: "tcp", fromPort: 443, toPort: 443, cidrBlocks: ["10.0.0.0/8"] }],egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],});export const securityGroupId = sg.id;export const appSubnetIds = subnetIds;
There are five ways in, and the differences matter. getOutput("vpcId") hands back an Output<any> and quietly yields undefined when that name does not exist. requireOutput("vpcId") also returns Output<any>, but it kills the deployment when the output is absent, which is what you want: a loud error beats a security group attached to nothing. Both are typed any, so cast them (as pulumi.Output<string>) if you want the compiler to keep helping you. getOutputValue and requireOutputValue return a plain Promise for the rare times you need a raw value outside the Output graph, and they refuse to handle secrets. Call either one on a secret output and it throws Cannot call 'getOutputValue' if the referenced stack output is a secret. When you genuinely need a secret's value in a promise, getOutputDetails is the honest door: it hands back an object carrying either a value field or a secretValue field, exactly one of the two, and makes you say out loud which one you are holding.
Secret marking survives the crossing. If the producer wrapped an output in pulumi.secret(), getOutput and requireOutput give it back still marked secret, so it renders as [secret] in the CLI and gets encrypted again inside your own state. Only the outputs that were secret come back secret. The reference carries a secretOutputNames list, so one secret value does not taint the entire bag. Deploy the consumer and watch the reference show up in the plan.
cd ~/infra/apppulumi up --stack acme/app/prod --yes
Updating (prod)View Live: https://app.pulumi.com/acme/app/prod/updates/4Type Name Status+ pulumi:pulumi:Stack app-prod created (11s)> ├─ pulumi:pulumi:StackReference net read+ └─ aws:ec2:SecurityGroup app created (4s)Outputs:appSubnetIds : [[0]: "subnet-0c1d2e3f4a5b6c7d8"[1]: "subnet-09876fedcba543210"]securityGroupId: "sg-0a9b8c7d6e5f43210"Resources:+ 2 createdDuration: 13s
Look at the > in front of the reference line. That is a read, not a create. Pulumi never called AWS to find your VPC. It asked the state backend what the networking stack said the last time it finished an update. Inside the SDK, the StackReference hands the stack name to the engine as the resource's id, which is exactly why the engine treats it as an existing thing to look up rather than something to build. Everything else in this lesson follows from that one fact.
One Program, One Socket Per Environment
Hardcoding acme/networking/prod in the program means your dev app stack wires itself into production networking, and you find out when a dev container starts resolving production database endpoints. Make the target a per-stack config value instead, so the same program follows dev to dev and prod to prod. Set it once per stack from the app project directory, because pulumi config set writes into the Pulumi.<stack>.yaml file sitting next to the program in your current directory.
cd ~/infra/app# one program, one wire per environmentpulumi config set networkStack acme/networking/dev --stack devpulumi config set networkStack acme/networking/prod --stack prodpulumi config --stack prod
KEY VALUEdesiredCount 3networkStack acme/networking/prodaws:region eu-west-1
# written by the commands above; safe to commit# keys are stored namespaced and sorted, so app:* sorts before aws:*config:app:desiredCount: "3"app:networkStack: acme/networking/prodaws:region: eu-west-1# a secret would land here as ciphertext, never as plaintext:# app:sessionKey:# secure: AAABAJ0X8pQ2v3nL0yYb1RkT9c7...==
If your naming is strict you can compute the string instead of storing it. pulumi.getOrganization() returns the org (the literal organization on self-managed backends) and pulumi.getStack() returns the current stack name, so ${org}/networking/${pulumi.getStack()} follows the environment on its own. Convention is cheaper than config right up until one app needs to point somewhere else, so keep the config override even when you lean on the pattern. Either way, run pulumi stack output against the producer before you write a line of consumer code. It is the cheapest check in this lesson, and the first thing to run when a deploy fails.
Deploy order is not a style preference. Say somebody adds natGatewayIds to the producer program (the NAT gateways, network address translation, are the boxes that let private subnets reach the internet without being reachable from it) and then forgets to re-run up on networking. The value never made it into state. Your app deploy dies during preview, before it creates anything.
pulumi up --stack acme/app/prod --yes
Previewing update (prod)Type Name Plan Infopulumi:pulumi:Stack app-prod 1 errorDiagnostics:pulumi:pulumi:Stack (app-prod):error: Error: Required output 'natGatewayIds' does not exist on stack 'acme/networking/prod'.
pulumi up. Never the live cloud, never your uncommitted local edits, never a branch nobody has deployed. Four consequences. Ordering: producers deploy before consumers, always, and two stacks that reference each other's outputs make a cycle where neither can go first. Renames: output names are an add-only contract, so when you must rename, export both names for a release and delete the old one only after every consumer has re-deployed. Staleness: if networking replaced its private subnets on Tuesday, the app stack keeps using the old IDs until somebody runs up on it, and the failure surfaces later as an AWS error about a subnet that no longer exists. Backends: the reference resolves against whichever backend your CLI is logged into, so a stack on Pulumi Cloud cannot read a stack that lives only in your own state bucket, and on a self-managed backend the consumer needs access to the producer's secrets provider or the read fails to decrypt instead of quietly handing you a usable value.What the Wire Copies Into Your State
The read does not stay in memory. Pulumi records the StackReference as a resource in the consumer's state, along with the outputs it read, so the app stack's state now holds a copy of every value the networking stack published. Worth seeing with your own eyes, because it changes where sensitive data physically lives. (jq is a command-line JSON filter; the expression below picks out the one resource whose type is pulumi:pulumi:StackReference.)
pulumi stack export --stack acme/app/prod \| jq '.deployment.resources[] | select(.type == "pulumi:pulumi:StackReference")'
{"urn": "urn:pulumi:prod::app::pulumi:pulumi:StackReference::net","custom": true,"id": "acme/networking/prod","type": "pulumi:pulumi:StackReference","inputs": {"name": "acme/networking/prod"},"outputs": {"name": "acme/networking/prod","outputs": {"privateSubnetIds": ["subnet-0c1d2e3f4a5b6c7d8","subnet-09876fedcba543210"],"publicSubnetIds": ["subnet-0aa11bb22cc33dd44","subnet-055e6f7a8b9c0d1e2"],"vpcId": "vpc-0a1b2c3d4e5f67890"},"secretOutputNames": []},"parent": "urn:pulumi:prod::app::pulumi:pulumi:Stack::app-prod","external": true,"created": "2026-07-14T09:12:41.884Z","modified": "2026-07-19T06:03:12.117Z"}
That "external": true is the state file agreeing with the plan: this row was read, not built. Ordinary outputs land in the clear, exactly as you see above. A secret output lands as a small tagged object instead. Pulumi writes a magic signature key with the secret marker value, plus the ciphertext produced by that stack's secrets provider, so it reads as {"4dabf18193072939515e22adb298388d": "1b47061264138c4ac30d75fd1eb44270", "ciphertext": "AAABAO8p..."} and only a holder of the right key can open it. On a self-managed backend, all of this means your state bucket now holds a copy of every ID, endpoint and account number your producers publish. Lock it like a credential store: private, versioned, encrypted, with read access limited to the pipelines that need it. pulumi stack export is also how somebody with read access maps your entire topology in one request, so treat that permission as sensitive rather than as a debugging convenience.
Who Else Can Read the Socket
The permission model here belongs to your state backend, not to AWS. On Pulumi Cloud with the default secrets provider, the decryption keys are handed out automatically to anyone with read access to the stack. Read access is decrypt access. One extra flag is the whole difference.
# step 1: what stacks can this token even see, across every project?pulumi stack ls --all# step 2: what does the data team publish?pulumi stack output --stack acme/data/prod# step 3: same command, one extra flagpulumi stack output --show-secrets --stack acme/data/prod
NAME LAST UPDATE RESOURCE COUNT URLacme/app/dev 4 hours ago 12 https://app.pulumi.com/acme/app/devacme/app/prod* 19 minutes ago 12 https://app.pulumi.com/acme/app/prodacme/data/dev 6 days ago 31 https://app.pulumi.com/acme/data/devacme/data/prod 3 days ago 36 https://app.pulumi.com/acme/data/prodacme/networking/dev 2 weeks ago 24 https://app.pulumi.com/acme/networking/devacme/networking/prod 5 days ago 24 https://app.pulumi.com/acme/networking/prodCurrent stack outputs (3):OUTPUT VALUEdbEndpoint orders.cluster-c9k2v0abcdef.eu-west-1.rds.amazonaws.comdbPassword [secret]dbUser orders_appCurrent stack outputs (3):OUTPUT VALUEdbEndpoint orders.cluster-c9k2v0abcdef.eu-west-1.rds.amazonaws.comdbPassword hM7-quiet-otter-9142-PRIMARYdbUser orders_app
[secret] means encrypted at rest and redacted by default. It does not mean hidden from anyone holding a valid read token. That is the entire play for an attacker who lands a leaked CI token (continuous integration, the robot that builds and deploys your code) or an over-scoped personal access token: no cloud credentials, no alarms in AWS, three commands, and they walk out with a production database password nobody thought of as exposed.
Defenders get four moves. Publish pointers, not payloads: export the Secrets Manager ARN or the parameter path and let the consumer's runtime role fetch the value, so the worst case of a stolen state read is that somebody learns the name of a secret. That swap buys you detection as well, because a runtime fetch leaves a CloudTrail record (AWS's own log of who called which API) you can alert on, while a value sitting in a stack output is read silently forever. Scope pipeline tokens to the stacks they need instead of handing pipelines personal tokens that inherit everything their human can see. Give the sensitive stacks their own key with pulumi stack change-secrets-provider "awskms://alias/pulumi?region=eu-west-1", using KMS (Key Management Service, Amazon's key custodian), which moves decryption out of the Pulumi backend and into an AWS permission you control and can log. And audit what each producer publishes on a schedule, because outputs get added by well-meaning people in a hurry.
Keep --show-secrets off your build machines. It prints plaintext to standard output, and build logs are usually kept for months and readable by far more people than the stack itself. The same goes for running pulumi stack export in a pipeline, since the export carries every secret in encrypted form and every ordinary output in the clear. Both belong to deliberate, interactive use on a workstation. If a value has ever hit a build log, treat it as burned and rotate it.
Catching Drift Between Producer and Consumer
Consumers pick up new values on their next up, which makes "the app stack is correct" a statement about the last time somebody deployed it. That gap is where multi-stack setups quietly rot. A nightly pulumi preview --expect-no-changes for every consumer closes it: the preview re-reads the producer's outputs, compares them against what the consumer recorded, and exits non-zero as soon as any resource would change. The read itself is not a change, so a quiet night stays quiet. Run it on a schedule. Pull-request checks are not enough here, because the change you care about lands in somebody else's repository.
pulumi preview --expect-no-changes --stack acme/app/prodecho "exit code: $?"
Previewing update (prod)Type Name Planpulumi:pulumi:Stack app-prod> └─ pulumi:pulumi:StackReference net readResources:2 unchangedexit code: 0
Adding an output is safe. Renaming or removing one breaks people you may never have met. If you want that to be a reviewable event instead of a 03:00 discovery, keep the producer's key list in the repository (pulumi stack output --json | jq -S 'keys' > outputs.contract.json) and fail the producer's build when that file changes without a commit message explaining why. One CI step, and the socket on the wall becomes a promise somebody signed.
Try this
Run pulumi up --stack acme/networking/prod --yes on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a reference reads recorded state, not the live cloud. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.