CoursesPulumiStack references & multi-project

Stack references & multi-project

Wire networking to app stacks.

Intermediate10 min · lesson 8 of 12

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
// networking/index.ts: this project owns the shared network
import * 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.

terminal
cd ~/infra/networking
# deploy the producer first: outputs exist only after a successful update
pulumi up --stack acme/networking/prod --yes
output
Updating (prod)
View Live: https://app.pulumi.com/acme/networking/prod/updates/9
Type 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 created
Duration: 1m37s
terminal
# the whole published surface
pulumi stack output --stack acme/networking/prod
# one value, unquoted, safe to capture into a shell variable
pulumi stack output vpcId --stack acme/networking/prod
# the key list is the contract other teams depend on
pulumi stack output --json --stack acme/networking/prod | jq -S 'keys'
output
Current stack outputs (3):
OUTPUT VALUE
privateSubnetIds ["subnet-0c1d2e3f4a5b6c7d8","subnet-09876fedcba543210"]
publicSubnetIds ["subnet-0aa11bb22cc33dd44","subnet-055e6f7a8b9c0d1e2"]
vpcId vpc-0a1b2c3d4e5f67890
vpc-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
// app/index.ts: reuse the network instead of recreating it
import * 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.

terminal
cd ~/infra/app
pulumi up --stack acme/app/prod --yes
output
Updating (prod)
View Live: https://app.pulumi.com/acme/app/prod/updates/4
Type 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 created
Duration: 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.

How a value travels from the networking stack to the app stack
1export const vpcId
top-level export in the networking program
2pulumi up (networking)
value written into that stack's state
3StackReference read
consumer asks the backend for the last-known outputs
4requireOutput("vpcId")
Output<any>, fails loudly if the name is gone
5aws.ec2.SecurityGroup
value is copied into the app stack's state too
Nothing on this path talks to AWS. The value moves from producer state into consumer state, which is why the producer has to deploy first.

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.

terminal
cd ~/infra/app
# one program, one wire per environment
pulumi config set networkStack acme/networking/dev --stack dev
pulumi config set networkStack acme/networking/prod --stack prod
pulumi config --stack prod
output
KEY VALUE
desiredCount 3
networkStack acme/networking/prod
aws:region eu-west-1
app/Pulumi.prod.yaml
# 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/prod
aws: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.

terminal
pulumi up --stack acme/app/prod --yes
output
Previewing update (prod)
Type Name Plan Info
pulumi:pulumi:Stack app-prod 1 error
Diagnostics:
pulumi:pulumi:Stack (app-prod):
error: Error: Required output 'natGatewayIds' does not exist on stack 'acme/networking/prod'.
A reference reads recorded state, not the live cloud
A stack reference resolves to the outputs of the producer's last successful 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.)

terminal
pulumi stack export --stack acme/app/prod \
| jq '.deployment.resources[] | select(.type == "pulumi:pulumi:StackReference")'
output
{
"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.

terminal
# 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 flag
pulumi stack output --show-secrets --stack acme/data/prod
output
NAME LAST UPDATE RESOURCE COUNT URL
acme/app/dev 4 hours ago 12 https://app.pulumi.com/acme/app/dev
acme/app/prod* 19 minutes ago 12 https://app.pulumi.com/acme/app/prod
acme/data/dev 6 days ago 31 https://app.pulumi.com/acme/data/dev
acme/data/prod 3 days ago 36 https://app.pulumi.com/acme/data/prod
acme/networking/dev 2 weeks ago 24 https://app.pulumi.com/acme/networking/dev
acme/networking/prod 5 days ago 24 https://app.pulumi.com/acme/networking/prod
Current stack outputs (3):
OUTPUT VALUE
dbEndpoint orders.cluster-c9k2v0abcdef.eu-west-1.rds.amazonaws.com
dbPassword [secret]
dbUser orders_app
Current stack outputs (3):
OUTPUT VALUE
dbEndpoint orders.cluster-c9k2v0abcdef.eu-west-1.rds.amazonaws.com
dbPassword hM7-quiet-otter-9142-PRIMARY
dbUser 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.

terminal
pulumi preview --expect-no-changes --stack acme/app/prod
echo "exit code: $?"
output
Previewing update (prod)
Type Name Plan
pulumi:pulumi:Stack app-prod
> └─ pulumi:pulumi:StackReference net read
Resources:
2 unchanged
exit code: 0
Quick check
01Your data stack exports dbPassword wrapped in pulumi.secret(), and the app stack reads it with requireOutput. The app pipeline's token has read access to the data stack. What has that secret marking actually bought you?
Incorrect — The value is encrypted by the stack's secrets provider before it is ever written to state or disk. The on-screen redaction is a separate, additional behavior.
Incorrect — requireOutput resolves it and keeps the secret marking, which is exactly how the app ends up holding a usable password.
Correct — On Pulumi Cloud the decryption keys are issued to anyone with read access, so read access is decrypt access and the only real control is not publishing the value at all.
Incorrect — The secret marking crosses the reference, so the copy in the consumer's state is encrypted too, using the consumer stack's own secrets provider.
02A producer stack exports dbPassword wrapped in pulumi.secret(). In the consumer you call net.requireOutputValue("dbPassword") to get a plain Promise. What happens?
Correct — getOutputValue and requireOutputValue throw on a secret output, and getOutputDetails is the honest door, returning either a value or a secretValue field.
Incorrect — These methods deliberately refuse secrets rather than silently handing back plaintext outside the Output graph.
Incorrect — They do not return a redacted placeholder; they throw, so you cannot accidentally treat a secret as an ordinary string.
Incorrect — A present secret output makes it throw, not return undefined; undefined would come from getOutput on a name that does not exist.
03The networking stack replaced its private subnets on Tuesday, but nobody has re-run pulumi up on the app stack that references privateSubnetIds. What is the app stack using right now, and how will the problem surface?
Incorrect — A stack reference never talks to the live cloud; it reads what the producer's state recorded at its last successful up.
Correct — the consumer keeps the stale IDs it copied into its own state until its next up re-reads the producer, and the mismatch surfaces as a cloud error against a deleted subnet.
Incorrect — Pulumi does not coordinate deploy order for you; the producer can change freely and consumers simply go stale until they redeploy.
Incorrect — The reference reads recorded producer state, not a fresh live value, and only on the consumer's own up, so it does not track the change on its own.

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.

Related