Config & secrets
Per-stack config and encrypted secrets.
One Pulumi program can build dev, staging, and production. What makes those three copies different is a handful of labelled dials bolted to each one: which region, how big the database, how many days of backups you keep. A stack is one deployed copy of your program. Config is that copy's set of dials. Most of them are boring, and anyone with the repo can read them. One or two belong in a locked drawer: a database password, a third-party API token (API = application programming interface, the way one program calls another over the network). You set both kinds with the same command. The difference is what happens on the way to disk. The locked-drawer values get encrypted first, and they stay encrypted inside state, the file Pulumi keeps describing everything it has built for that stack.
Where Config Actually Lives
Pulumi.yaml describes the project. Right beside it, Pulumi.<stack>.yaml holds the config for one stack (YAML is a plain-text format for writing structured data by hand), so a repo with three environments has three of those files. Keys are namespaced the way flats in an apartment block are numbered: two tenants can both be called "region" as long as the door number differs. A bare key you set gets your project's namespace, so dbInstanceClass in the webapp project is really webapp:dbInstanceClass. A provider setting carries the provider's own namespace, which is why you write aws:region and never plain region. You do not hand-edit these files. The CLI (command-line interface, the pulumi command you type) does the writing, because it also does the encrypting.
# Set the dials for the prod stack. Bare keys land in the project# namespace; this project is called "webapp".pulumi config set --stack prod dbInstanceClass db.t3.micropulumi config set --stack prod backupRetentionDays 14pulumi config set --stack prod adminUsername appadmin# Provider settings carry the provider's own namespace.pulumi config set --stack prod aws:region eu-west-1# --path builds a structured object instead of a flat string.pulumi config set --stack prod --path 'data.tags.team' platform# List every key this stack carries.pulumi config --stack prod
KEY VALUEadminUsername appadminaws:region eu-west-1backupRetentionDays 14data {"tags":{"team":"platform"}}dbInstanceClass db.t3.micro
That file belongs in git. Plain values sit there in the open, which is the point: a reviewer can see in a pull request that someone dropped prod's backup retention from 14 days to 1, or quietly moved a stack to a different region. Those are security changes. A human should get to look at them before they ship. Secret values are already ciphertext by the time they reach the disk, so the same commit that shows the readable dials shows nothing readable about the password.
Reading Config In The Program
Inside the program you build a Config object and pull typed values off it. The require* family is strict: if the key is missing, the deployment stops during preview with a message naming the exact key and the exact command to fix it. The get* family is forgiving, returns undefined, and lets you keep the default in code. Typed helpers like getNumber, getBoolean, and requireObject parse the underlying text for you, because everything in that file is a string until something interprets it. requireSecret is the interesting one. It hands back an Output, never a bare string, so the value stays wrapped for the rest of the program. Python spells these the same way with underscores (require_secret, get_number), and new pulumi.Config("aws") reads another namespace's keys.
import * as pulumi from "@pulumi/pulumi";import * as aws from "@pulumi/aws";// Namespace defaults to the project name ("webapp").const config = new pulumi.Config();// Strict: preview stops here if this stack never set it.const instanceClass = config.require("dbInstanceClass");// Forgiving: parsed as a number, default lives in code.const retentionDays = config.getNumber("backupRetentionDays") ?? 7;// The structured value built by `--path`.const meta = config.requireObject<{ tags: Record<string, string> }>("data");// Secrets arrive wrapped: Output<string>, never a bare string.const dbPassword = config.requireSecret("dbPassword");const db = new aws.rds.Instance("app", {engine: "postgres",instanceClass,allocatedStorage: 20,username: "appuser",password: dbPassword, // the AWS provider marks this field sensitive toobackupRetentionPeriod: retentionDays,skipFinalSnapshot: true,tags: meta.tags,});
Config is read while the program is building resources, so turning a dial and re-running pulumi up is enough to reshape the stack with no code edit. A stack missing a required key never gets halfway. It fails before the first call to any cloud API.
# Pretend someone created the stack but forgot a key.pulumi config rm --stack prod dbInstanceClasspulumi preview --stack prod
Previewing update (prod):Type Name Plan Info+ pulumi:pulumi:Stack webapp-prod create 1 errorDiagnostics:pulumi:pulumi:Stack (webapp-prod):error: Missing required configuration variable 'webapp:dbInstanceClass'please set a value using the command `pulumi config set webapp:dbInstanceClass <value>`
What --secret Actually Does
Marking a value secret is real encryption, not a display trick. When you pass --secret, the CLI hands the plaintext to the stack's secrets provider, gets ciphertext back, and writes that into the stack file as secure: v1:.... Where the encrypting happens depends on which provider the stack uses, and that matters if your rule is that plaintext must never leave the building. With a passphrase or a cloud key, the cipher runs on your own machine and nothing readable goes over the wire. With the Pulumi Cloud default, the CLI sends the plaintext to the service over TLS (Transport Layer Security, the encryption behind the padlock in your browser) and the service encrypts it with a key it holds for that stack. Either way the algorithm is AES-256-GCM, a standard cipher that also detects tampering. Either way preview, up, and config get run the whole thing backwards in memory.
# Omit the value and Pulumi prompts for it. Nothing sensitive ends up in# your shell history or in the command line of a running process.pulumi config set --stack prod --secret dbPassword# Encrypt one leaf of a structured value; its siblings stay readable.pulumi config set --stack prod --path --secret 'data.apiToken'
value:value:
# The list view masks anything encrypted.pulumi config --stack prod# Reading one back decrypts on purpose. Mind who can see the screen.pulumi config get --stack prod dbPassword
KEY VALUEadminUsername appadminaws:region eu-west-1backupRetentionDays 14data [secret]dbInstanceClass db.t3.microdbPassword [secret]s3cr3t-rotate-me-2026
encryptionsalt: v1:sT9k1Qv2Zm0=:v1:8Jq2mQ0dK6Yb3s7T:2mQ1yF7hCk9Vv0v4lQe1s3fconfig:aws:region: eu-west-1webapp:adminUsername: appadminwebapp:backupRetentionDays: "14"webapp:data:apiToken:secure: v1:9Vv0v4lQe1s3f8Qy:R2p+dQkUu2wIkPq6mQ1yF7hCk9Vv0hQ==tags:team: platformwebapp:dbInstanceClass: db.t3.microwebapp:dbPassword:secure: v1:0FQmZ1nZP0Yb3s7T:kUu2wIkPq6mQ1yF7hCk9Vv0v4lQe1s3f8QyR2p+dQ==
Two details in that file earn their keep. The encryptionsalt line only appears on passphrase-backed stacks, and it holds both the salt used to derive the key and a small encrypted canary the CLI decrypts to check you typed the right passphrase. Anyone who clones the repo can tell at a glance which lock they are looking at. The second detail is in the data block: only the apiToken leaf is ciphertext, and data.tags.team is still readable, because --path --secret protects the leaf you name and leaves its siblings alone. On disk, that is where it ends. In the program it does not: once any leaf under data is secret, Pulumi treats the whole data key as sensitive, so requireObject("data") refuses the call and tells you to use requireSecretObject. That is a code change, not a warning you can shrug off.
Which lock you get is decided when the stack is created. On Pulumi Cloud the default is a per-stack key the service manages, and the stack file holds no key material at all. On a self-managed backend (an S3 bucket, meaning Amazon's object storage, or a plain directory on disk) the default is a passphrase you type. Point it at a cloud KMS (Key Management Service, a managed vault that holds keys you can use but never export) and you get envelope encryption, which works like a bank vault holding one small key: Pulumi generates a data key, asks KMS to lock that data key once, stores the wrapped copy in the stack file as encryptedkey, and then uses the unwrapped data key locally for every secret in the stack. That is why a deployment carrying fifty secrets makes one kms:Decrypt call instead of fifty.
# Choose the lock when the stack is born. This one is backed by a key# you own and can audit, not by a passphrase in someone's notes app.pulumi stack init staging \--secrets-provider="awskms://alias/pulumi-staging?region=eu-west-1"head -2 Pulumi.staging.yaml
Created stack 'staging'secretsprovider: awskms://alias/pulumi-staging?region=eu-west-1encryptedkey: AQIDAHhwZ2i6Yl1QRz7v0mUu2wIkPq6mQ1yF7hCk9Vv0v4lQeAF2c1s3f8QyR2p+dQkU
For a stack that already exists, pulumi stack change-secrets-provider "awskms://alias/pulumi-prod?region=eu-west-1" re-encrypts every config secret and every secret in state under the new key. The same command with passphrase or default walks you back to a passphrase or to the Pulumi Cloud managed key. Plan a maintenance window for a large stack, because it rewrites the whole checkpoint file.
PULUMI_CONFIG_PASSPHRASE and there is no reset, no recovery, no support ticket: every secret in Pulumi.prod.yaml and in state is permanently unreadable, and you rebuild the stack from scratch. The same applies if someone schedules deletion of the KMS key or drops the deploy role's kms:Decrypt grant. Store passphrases wherever you keep break-glass credentials, the ones sealed for emergencies. Put deletion protection and an alarm on the key. And remember that one shared passphrase across every runner is a single secret guarding every environment you own.The Secret Marker Travels, Until You Unwrap It
Pulumi tracks secrecy the way a food-safety label follows an ingredient through a kitchen. Anything computed from a secret is itself secret: .apply(), pulumi.interpolate, pulumi.all, a resource input built from one, a stack output derived from that resource. All of it renders as [secret] in the CLI and lands as ciphertext in state. Returning a value out of an .apply() callback does not peel the label off, so the defensive instinct to re-wrap it afterwards is wasted effort.
The leak happens when you reach inside the wrapper. The callback you hand to .apply() receives the decrypted string, and whatever you do with it in there is outside Pulumi's control: a console.log, an HTTP call to some webhook, writing it to a file. Timing makes this worse than it looks. A config secret is known at preview time, so a stray log line prints during pulumi preview too. A value derived from a resource that does not exist yet is unknown during preview, so the callback is skipped entirely, and the leak first shows up in the log of a real production up.
// index.ts, continuedimport * as command from "@pulumi/command";// The marker rides along: interpolate is built on apply, so anything// touching dbPassword comes out marked secret as well.export const connString =pulumi.interpolate`postgres://appuser:${dbPassword}@${db.endpoint}/app`;// A plain field that happens to be sensitive: this script prints a join// token to the screen, and no provider schema knows that. Mark it yourself.const bootstrap = new command.local.Command("bootstrap", {create: "./issue-join-token.sh",}, { additionalSecretOutputs: ["stdout"] });export const joinToken = bootstrap.stdout;// A hostname you are happy to publish stays plain.export const dbAddress = db.address;// pulumi.secret() marks a value on the way out, even a plain string.export const adminUser = pulumi.secret(config.require("adminUsername"));// This is the leak. The callback gets the decrypted string, and the log// line goes wherever your CI (continuous integration) system keeps logs.dbPassword.apply(p => console.log(`db password is ${p}`));
pulumi stack output --stack prod# Decrypt one output on purpose, on a machine you trust.pulumi stack output --stack prod connString --show-secrets
Current stack outputs (4):OUTPUT VALUEadminUser [secret]connString [secret]dbAddress app-8f3c1d2.cxyz123abc.eu-west-1.rds.amazonaws.comjoinToken [secret]postgres://appuser:s3cr3t-rotate-me-2026@app-8f3c1d2.cxyz123abc.eu-west-1.rds.amazonaws.com:5432/app
Prove It On Disk
Do not take the CLI's word for it. State is a JSON document (JSON is the text format machines use to pass structured data around), and a secret inside it is a small object with a signature key rather than a bare string. So you can check by hand, or in a pipeline, with jq, a command-line tool for slicing JSON.
# Point the CLI at a passphrase file that only you can read (chmod 600)# rather than an env var, which any process running as you can read# out of /proc/<pid>/environ.export PULUMI_CONFIG_PASSPHRASE_FILE="$HOME/.config/pulumi/prod.pass"# No --show-secrets, so nothing is decrypted. Look at what state stores.pulumi stack export --stack prod \| jq '.deployment.resources[]| select(.type == "aws:rds/instance:Instance")| .inputs.password'
{"4dabf18193072939515e22adb298388d": "1b47061264138c4ac30d75fd1eb44270","ciphertext": "v1:8Jq2mQ0dK6Yb3s7T:2mQ1yF7hCk9Vv0v4lQe1s3f8QyR2p+dQkUu2wIkPq6"}
That hex string 4dabf18193072939515e22adb298388d is Pulumi's marker for a special value, and 1b47061264138c4ac30d75fd1eb44270 is the one that means "this is a secret". If you ever find a readable string where that object should be, the value was never marked, and it is sitting in cleartext in every copy of your state file and in every backup of it. The same export carries a .deployment.secrets_providers block naming the provider type and, for cloud keys, the URL. That tells you at a glance which stacks are still riding on a passphrase.
Now work out who can actually read these things. Someone with read access to the repo gets ciphertext, a key alias, and a map of your environments. Worth something to an attacker, but not the password. Someone with the passphrase gets every secret in that stack, forever, and leaves no trace anywhere. Someone with kms:Decrypt on the key gets everything too, but leaves a trail: every pulumi up and every pulumi config get lands in CloudTrail, AWS's log of who called which API, as a Decrypt event with a principal, a timestamp, and a source IP. Scope that permission to the deploy role and one break-glass role, then alert on any other caller. Having an audit trail instead of none is the strongest argument for putting a real key behind production stacks. Keep in mind too that the state backend holds the whole resource graph, ciphertext included, so that bucket needs its own tight IAM (identity and access management, the rules saying who may touch what) policy, object versioning, and access logging.
pulumi config get, pulumi stack output --show-secrets, and pulumi stack export --show-secrets all print cleartext to stdout, the normal output stream. In a pipeline that means straight into a build log that is kept for months and readable by everyone with pipeline access. Passing a secret as an argument (pulumi config set --secret dbPassword hunter2) is the same mistake wearing a different hat: it lands in ~/.bash_history, and while the command runs, any user on the box can read it out of /proc/<pid>/cmdline, which is world-readable by default. Let the CLI prompt you, keep --show-secrets off every runner, and treat set -x in a deploy job as a finding.dbPassword with --secret, then export a connection string built with pulumi.interpolate that embeds it. After a successful pulumi up, what does pulumi stack output connString print?pw is ${p})), where dbPassword came from config.requireSecret. During which Pulumi operation does the plaintext password first appear in the logs?One distinction trips people during an incident: rotating the encryption key and rotating the credential are different jobs. pulumi stack change-secrets-provider re-encrypts config and state under a new key, which is what you do when a passphrase got shared too widely or you are finally moving to KMS. It does nothing about the old ciphertext already sitting in git history and in your backend's update history. If the password itself was exposed, change it at the database first, then run pulumi config set --secret dbPassword, then pulumi up. Pulumi will not show you the old and new values in the diff, since both render as [secret], so verify the rotation by checking that the database resource appears in the update plan at all, and confirm afterwards that the old credential no longer authenticates.
Try this
Run pulumi config set --stack prod dbInstanceClass db.t3.micro 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 lost key is a lost stack. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.