Inputs, Outputs & apply
The async value model that trips everyone.
You order a new lock for the server room and the locksmith tells you the key gets cut tomorrow. The number that will be stamped on the blade does not exist yet. You still get work done today, because you write the instructions down: when the key exists, stamp it with the room number, drop the spare in the on-call safe, add a line to the key register. Instructions now, real key later. Pulumi's Output<T> is that instruction slip, and most of the baffling Pulumi errors you will ever hit come from someone treating the slip as though it were the key.
Your program is not the deployment. It runs first, in well under a second, and its only job is to describe what should exist. Once it finishes, the Pulumi engine talks to the cloud and finds out what the cloud decided: the bucket's ARN (Amazon Resource Name, the unique identifier string that AWS, Amazon Web Services, stamps on everything it creates), the load balancer's DNS name (Domain Name System, the naming scheme that turns names into addresses), the random suffix glued onto your bucket name. None of that exists while your code is running. So Pulumi hands you an Output<T>, a wrapper meaning "a value of type T that will exist once this deploys".
The mirror image is Input<T>, and it is what every resource argument accepts. In TypeScript it is roughly the union T | Promise<T> | Output<T>. Hand a resource a plain string, a promise, or another resource's Output, and all three work. Inputs are forgiving. That lopsidedness is why the whole model stays invisible right up until the day it does not: the moment your own code wants to read the value, the forgiveness stops.
The Bug Everyone Writes First
import * as aws from "@pulumi/aws";// S3 is Simple Storage Service, the AWS object store.const bucket = new aws.s3.BucketV2("audit-logs", {});// Compiles. Deploys. Completely wrong.export const badMsg = "ARN is " + bucket.arn;
pulumi up --yes > /dev/null && pulumi stack output badMsg
ARN is Calling [toString] on an [Output<T>] is not supported.To get the value of an Output<T> as an Output<string> consider either:1: o.apply(v => `prefix${v}suffix`)2: pulumi.interpolate `prefix${v}suffix`See https://www.pulumi.com/docs/concepts/inputs-outputs/ for more details.This function may throw in a future version of Pulumi.
JavaScript wanted a string, so it asked the object for one, and the Pulumi Node SDK (software development kit, the library your program imports) answered with a paragraph of advice instead of a value. It is trying to be helpful. What it cannot do is refuse, because as far as AWS is concerned that paragraph is a perfectly legal tag, description, or log group name. It deploys into your stack (one live copy of your program, such as dev or prod) without a single complaint. Python is blunter: an f-string over an Output gives you <pulumi.output.Output object at 0x7f8c1d4a3b50> and no hint at all.
Four Ways To Use A Value You Do Not Have Yet
Most of the time you need none of the four. Passing an Output straight into another resource's argument works with no ceremony, because that argument is an Input<T> and the engine unwraps it for you. Reach for a helper only when your own code has to touch the value.
import * as pulumi from "@pulumi/pulumi";import * as aws from "@pulumi/aws";const bucket = new aws.s3.BucketV2("audit-logs", {});// SQS is Simple Queue Service, the AWS message queue.const queue = new aws.sqs.Queue("audit-events", {});// 1. Straight in. No helper, and this is what creates the dependency edge.new aws.s3.BucketVersioningV2("audit-logs-versioning", {bucket: bucket.id,versioningConfiguration: { status: "Enabled" },});// 2. An Output inside a string: interpolate (or pulumi.concat).export const logsUrl = pulumi.interpolate`https://${bucket.bucketDomainName}/`;// 3. Several Outputs at once: all(). The object form keeps the names readable.export const wiring = pulumi.all({ arn: bucket.arn, queueUrl: queue.url }).apply(({ arn, queueUrl }) => `${arn} -> ${queueUrl}`);// 4. Real logic on the resolved value: apply().// An SQS ARN reads arn:aws:sqs:eu-west-1:123456789012:audit-events, so [3] is the region.// (An S3 ARN has an empty region field, which is a nice way to fool yourself here.)export const queueRegion = queue.arn.apply(a => a.split(":")[3]);
apply(fn) is the general case. Your function runs once the value resolves, and you get back a new Output wrapping whatever you returned. pulumi.interpolate is a template string that understands Outputs. pulumi.concat glues pieces together. pulumi.all waits for a whole set of them, so you can combine values from resources that know nothing about each other. Python has the same four under different names: Output.format, Output.concat, Output.all and .apply. One rule falls out of all of this. Once a value is an Output, everything you compute from it is an Output too, right up until you export it or feed it into a resource.
The case that bites hardest is a policy document, because it is one string that has to carry several Outputs in exact positions. pulumi.jsonStringify exists for that. It takes an object with Outputs buried anywhere inside it, waits for every one of them, then hands back an Output<string> of valid JSON (JavaScript Object Notation, the plain-text format IAM policies are written in). IAM is Identity and Access Management, the AWS service that decides who is allowed to do what.
const appRole = new aws.iam.Role("app-role", {assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ Service: "lambda.amazonaws.com" }),});// Least-privilege read access for the audit processor, scoped to one bucket.const readAudit = pulumi.jsonStringify({Version: "2012-10-17",Statement: [{Effect: "Allow",Action: ["s3:GetObject"],Resource: pulumi.interpolate`${bucket.arn}/*`,}],});const readerPolicy = new aws.iam.RolePolicy("app-reader", {role: appRole.id,policy: readAudit,});// Python: policy=pulumi.Output.json_dumps({...})
Now the version people write first, and what a preview does with it. JSON.stringify knows nothing about Outputs. It asks the object for its JSON form, gets the same style of apology, and bakes it quietly into the policy. The preview below is trimmed to the one resource worth looking at.
const readAudit = JSON.stringify({Version: "2012-10-17",Statement: [{ Effect: "Allow", Action: ["s3:GetObject"], Resource: bucket.arn }],});
pulumi preview --diff
Previewing update (dev):+ aws:iam/rolePolicy:RolePolicy app-reader create[urn=urn:pulumi:dev::logs-infra::aws:iam/rolePolicy:RolePolicy::app-reader]name : "app-reader-9f3c1d0"policy: "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\"],\"Resource\":\"Calling [toJSON] on an [Output<T>] is not supported.\n\nTo get the value of an Output as a JSON value or JSON string consider either:\n 1: o.apply(v => v.toJSON())\n 2: o.apply(v => JSON.stringify(v))\n\nSee https://www.pulumi.com/docs/concepts/inputs-outputs/ for more details.\nThis function may throw in a future version of Pulumi.\"}]}"role : output<string>Resources:+ 4 to create
Two things to notice. The role has not been created yet, so its name shows as output<string>, the CLI's way of writing "unknown". The policy, meanwhile, is fully known, because JSON.stringify resolved it at program time into a paragraph of SDK advice. Reading property values in a preview diff is the cheapest check you have, and a stringified Output is unmistakable there. Send this policy to AWS and IAM rejects it with MalformedPolicyDocument, complaining that the resource must be in ARN format or "*". That is the lucky outcome. The unlucky one is the same mistake in a field the cloud happily accepts: a tag, a description, an object key prefix. That deploys green and then sits in your account looking like configuration somebody meant.
Every Output Drags Its Dependencies Along
The value is only half of what an Output holds. The other half is the list of resources it came from, like a delivery note stapled to the instruction slip: this value came from that bucket. When you pass bucket.id into another resource, Pulumi records an edge, meaning the bucket has to exist first. Every ordering decision in a Pulumi deployment is built this way, into a DAG (directed acyclic graph, a one-way map of what must come before what) that you never write by hand. apply keeps those edges, so a value you transformed still remembers where it came from. A literal wrapped with pulumi.output("prod") carries none, because it came from nothing.
const logGroup = new aws.cloudwatch.LogGroup("audit-processor-logs", {name: "/aws/lambda/audit-processor",retentionInDays: 90,});const fn = new aws.lambda.Function("audit-processor", {name: "audit-processor",// Pulumi infers this edge on its own: the role must exist before the function.role: appRole.arn,runtime: aws.lambda.Runtime.NodeJS20dX,handler: "index.handler",code: new pulumi.asset.FileArchive("./app"),}, {// Pulumi cannot infer these two: nothing in the arguments above mentions them.dependsOn: [readerPolicy, logGroup],});
Ordering is a security property, not a tidiness one. A bucket and its server access logging configuration are separate resources, so there is a window between the bucket existing and logging being switched on. Anything written in that window is missing from the access log forever. Same story for an encryption configuration, a public access block, or the log group a function writes into. Where the ordering is invisible to Pulumi because no argument references the other resource, dependsOn is how you say it out loud. Teardown walks the graph backwards, so a missing edge also turns up as a delete that fails, or as a control that gets removed while the thing it was protecting is still standing.
Preview Runs Blind
During pulumi preview, a resource that does not exist yet has no real outputs. Pulumi marks them unknown, and the CLI (command-line interface, the pulumi command you type) prints them as output<string>. Here is the part that catches people. When an apply's input is unknown, the SDK does not run your callback at all. It checks whether the value is known, sees that it is not, hands back an unknown Output and moves on. The code inside those braces never executes. Unknowns are contagious too, so a single unknown inside a pulumi.all makes the whole combined value unknown.
// Do not ship this. It is here so you can watch a preview lie to you.const bucket = new aws.s3.BucketV2("audit-logs", {});bucket.bucket.apply(name => {if (name.startsWith("audit-")) {new aws.s3.BucketPolicy(`${name}-deny-insecure`, {bucket: name, // inside apply, name is a plain stringpolicy: JSON.stringify({Version: "2012-10-17",Statement: [{Effect: "Deny", Principal: "*", Action: "s3:*",Resource: [`arn:aws:s3:::${name}`, `arn:aws:s3:::${name}/*`],Condition: { Bool: { "aws:SecureTransport": "false" } },}],}),});}});export const bucketArn = bucket.arn;
pulumi preview
Previewing update (dev):Type Name Plan+ pulumi:pulumi:Stack logs-infra-dev create+ └─ aws:s3:BucketV2 audit-logs createOutputs:+ bucketArn: output<string>Resources:+ 2 to create
pulumi up --yes
Updating (dev):Type Name Status+ pulumi:pulumi:Stack logs-infra-dev created (14s)+ ├─ aws:s3:BucketV2 audit-logs created (3s)+ └─ aws:s3:BucketPolicy audit-logs-a1b2c3d-deny-insecure created (2s)Outputs:+ bucketArn: "arn:aws:s3:::audit-logs-a1b2c3d"Resources:+ 3 createdDuration: 17s
Two to create, three created. The bucket policy is a good policy, and it still went in behind the reviewer's back. That is the operational problem in one line: change review is a control, and the artifact that control inspects is the preview your CI (continuous integration, the pipeline that runs on every push) posts on the pull request. If the preview cannot see a resource, the sign-off is decoration. Run it the other way round and you have an attack. Someone who can land a small, dull-looking change in your infrastructure repository would love to hide a resource inside an apply, because the plan a human reads will never mention it.
So keep resources out of apply callbacks. Anything built in there is invisible to preview, which means the plan understates what will change, and it can be registered late enough to confuse teardown ordering, which is how you end up with an orphan that pulumi destroy leaves behind. Side effects have the same shape of problem. An HTTP request, a file write or a console.log inside an apply does nothing at all during a preview whose input is unknown, then happens for real during up. Keep security decisions out of apply entirely. If a policy attachment depends on a condition, base that condition on something your program already knows while it runs, such as stack configuration or the stack name, never on a value the cloud will tell you later.
Secrets Ride Along Until You Drop Them
Pulumi tracks a second flag next to the value: whether it is secret. Feed a secret Output into apply or all and the result stays secret, so a value derived from a password is encrypted in state (Pulumi's record of everything it manages for this stack) without you asking for it. That propagation is deliberate and it does real work. It also leaves exactly three ways to lose the marking, and all three are things a person does on purpose.
import * as command from "@pulumi/command";const cfg = new pulumi.Config();const dbPass = cfg.requireSecret("dbPassword"); // Output<string>, marked secret// Stays secret: the marking rides through apply, even onto a number.export const passLen = dbPass.apply(p => p.length);// Leak 1: inside the callback you are holding plaintext, and this lands in the CI log.// dbPass.apply(p => { console.log("db password:", p); return p; });// Leak 2: state stays encrypted, the cloud does not. A resource NAME built from a// secret is plaintext in the console and in CloudTrail requestParameters.// new aws.ssm.Parameter("p", { name: pulumi.interpolate`/app/${dbPass}`, type: "String", value: "x" });// Leak 3: pulumi.unsecret(dbPass) strips the marking on purpose. Grep for it in review.// A provider that hands back a token without marking it: mark it yourself.// (Vault is HashiCorp's secret store; this shells out to its CLI.)const bootstrap = new command.local.Command("issue-token", {create: "vault write -field=token auth/approle/login role_id=$RID",environment: { RID: cfg.require("vaultRoleId") },}, { additionalSecretOutputs: ["stdout"] }); // Python: additional_secret_outputs
pulumi stack output
Current stack outputs (3):OUTPUT VALUEbucketArn arn:aws:s3:::audit-logs-a1b2c3dlogsUrl https://audit-logs-a1b2c3d.s3.amazonaws.com/passLen [secret]
Even the length is withheld, because it came out of an apply over a secret. To read it you have to ask for it by name, and asking decrypts state rather than reading it, which is the difference between a value that leaks by accident and one that leaks because somebody decided to look.
pulumi stack output passLen --show-secrets
24
Two Checks Worth Wiring Into CI
The first check catches a stringified Output that already shipped. Run it against the stack outputs of every environment you own, and against any stack you inherited from somebody else.
pulumi stack output --json | grep -q 'Calling \[to' \&& { echo 'FAIL: an Output was stringified into a stack output'; exit 1; } \|| echo 'OK: no stringified Outputs'
OK: no stringified Outputs
The second catches resources built inside an apply. Ask the preview how many changes it plans, keep the number, then compare it against what the update reports when it finishes.
pulumi preview --json | jq -c '.changeSummary'
{"create":2}
pulumi up --yes --json | jq -c 'select(.summaryEvent).summaryEvent.resourceChanges'
{"create":3}
Two planned, three created, and the gap is your signal. Fail the pipeline on that mismatch and spell out the reason in the failure message, because the person who hits it next will be certain the tool is broken. It is not. The program described one thing to the reviewer and built another, and the count is the only place that ever showed up.
bucket.id, an Output<string>, and want to use it as the bucket argument of a new BucketVersioningV2. What is the correct, idiomatic way?Input<T> and accept Outputs directly; apply is only for when your own code reads the value.Resource: bucket.arn using JSON.stringify, AWS rejected it with MalformedPolicyDocument, and to make it pass someone changed Resource to the wildcard *. What actually happened?*.pulumi.jsonStringify, which waits for the Output; * is how an async quirk becomes an over-permissioned role.* is far broader than needed.Try this
Run pulumi up --yes > /dev/null && pulumi stack output badMsg 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: the fix that gets reached for at 6pm. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.