Pulumi Secrets and Configuration in Python
Every Pulumi program reads two kinds of input: settings that describe how one environment differs from another — region, instance class, replica count — and secrets that must never land in a repository, a terminal scrollback, or a CI job transcript. Pulumi funnels both through a single mechanism, stack configuration, but the two demand very different discipline. This topic sits under Pulumi patterns and provider management and covers how the configuration hierarchy resolves, how pulumi.Config should be wrapped in typed Python, how a secret is actually encrypted on disk, and where secrets leak when nobody is watching.
Four guides go deeper on the individual tasks: using pulumi.Config and typed settings in Python, structuring per-environment configuration, rotating stack secrets without downtime, and passing configuration from Pulumi to application code.
Problem Framing
The failure mode is rarely dramatic. It looks like a dbPassword that started as a --secret value in Pulumi.prod.yaml, got read with config.require() instead of config.require_secret() because a type error was annoying, became a plain Python str, got interpolated into a connection string, and was exported as a stack output so a downstream stack could consume it. Six months later the value is in three CI logs, one Slack paste, and the outputs block of every state checkpoint since. Nothing errored. Nothing was flagged. The encryption at rest worked perfectly and protected nothing.
Pulumi's secret handling is a taint-propagation system, not a vault. A value marked secret carries that mark through apply, Output.all, Output.concat, into resource inputs, and out through any resource output derived from it — but only while it stays inside an Output. The moment your code resolves it to a plain string, the mark is gone and the engine has no way to know the string was ever sensitive. Most real leaks are a deliberate, one-line unwrapping that seemed harmless at the time.
The second failure mode is operational. Teams pick the default secrets provider on day one without thinking, ship forty stacks, and then discover that the passphrase lives in one engineer's password manager, or that the compliance team requires a customer-managed key. Changing the secrets provider afterwards is possible, but it is a decrypt-everything-then-re-encrypt-everything operation that needs the old provider to still work.
FIG1
Prerequisites
- Python 3.9+ with
pulumi>=3.0installed in the project virtualenv, pluspulumi-aws>=6.0andpulumi-random>=4.0for the examples below. - A Pulumi CLI logged into a backend — Pulumi Cloud (
pulumi login), a self-managed bucket (pulumi login s3://my-state-bucket), or the local filesystem (pulumi login --local). The backend stores state; the secrets provider encrypts the sensitive parts of it. They are separate choices. - If you plan to use a cloud KMS secrets provider, a key you can already
EncryptandDecryptwith from your workstation and from CI. Confirm before you create the stack, not after. - Credentials sourced from the environment rather than hardcoded — see best practices for managing cloud credentials in Python for the ambient-credential patterns this page assumes.
# CLI: confirm the CLI version and which backend and stack you are pointed at
pulumi version
pulumi whoami --verbose
pulumi stack ls
The Configuration Hierarchy
Four things can supply a configuration value, and they do not have equal weight.
Pulumi.yaml — project scope. The project file may declare configuration keys with a type, a default, and a secret flag. Declared keys are validated before the program runs, so a missing required value fails at pulumi preview with a clear message instead of deep inside your Python. Unnamespaced keys resolve to the project namespace.
# Pulumi.yaml — project-wide declarations and defaults
# CLI: pulumi config set myapp:instanceSize m6i.large
name: myapp
runtime: python
description: Application platform
config:
myapp:instanceSize:
type: string
default: t3.small
myapp:replicas:
type: integer
default: 2
myapp:dbPassword:
type: string
secret: true
Pulumi.<stack>.yaml — stack scope. This is where per-environment values live and where encrypted values are stored. It overrides project defaults. Commit it: the secret values in it are ciphertext, and the plaintext ones are exactly the environment description you want reviewed in a pull request.
# Pulumi.prod.yaml — per-environment values, secrets stored as ciphertext
# CLI: pulumi config set --secret myapp:dbPassword "$(openssl rand -base64 24)"
secretsprovider: awskms://alias/pulumi-prod?region=eu-west-1
encryptedkey: AQIDAHhw2vLPvXk3ZQ0mQ0k9dQ1Y3o0K0Bq0d1sQ0h6b4rY9AgFqf1nJ8w==
config:
aws:region: eu-west-1
myapp:instanceSize: m6i.large
myapp:replicas: 6
myapp:network:
vpcCidr: 10.40.0.0/16
subnetCount: 3
singleNatGateway: false
myapp:dbPassword:
secure: v1:9Rr2mZ0pQ1s4TfXu:8mCq0Rv1Kx7Zt2Yb9Nn3Ll4Ee6Oo5Ii8Uu
CLI flags — invocation scope. pulumi up --config myapp:replicas=8 overrides both for that run. The important subtlety: the CLI writes the value into the stack config file as part of the update, so a "temporary" override becomes a permanent one unless you revert it. Use pulumi config set deliberately, or --config-file to point at an alternative file, rather than treating --config as a scratch flag.
Environment variables — mostly not what you think. There is no PULUMI_CONFIG_MYAPP_REPLICAS escape hatch. pulumi.Config reads only the resolved stack configuration; the environment influences three separate things: the secrets provider (PULUMI_CONFIG_PASSPHRASE, PULUMI_CONFIG_PASSPHRASE_FILE), the backend (PULUMI_ACCESS_TOKEN), and provider credentials and defaults that the provider plugin reads on its own (AWS_REGION, AWS_PROFILE, GOOGLE_PROJECT). Setting AWS_REGION changes where resources land without aws:region appearing anywhere in your config — a genuine source of "it deployed to the wrong region in CI" incidents.
If your organisation uses Pulumi ESC, a stack file can also carry an environment: list whose values are resolved at run time and merged into config. Values set directly in Pulumi.<stack>.yaml win over imported ones, which makes the imported environment a sane place for org-wide defaults and shared credentials.
Typed Access: Wrapping pulumi.Config
pulumi.Config is a stringly-typed accessor. Its surface is small and worth memorising: get/require for strings, get_int/require_int, get_bool, get_float, get_object/require_object for structured values, and a *_secret variant of each. The get_* family returns Optional[T]; the require_* family raises pulumi.ConfigMissingError with the message Missing required configuration variable 'myapp:dbPassword' and a hint telling you the exact pulumi config set command to run.
Scattering those calls through your program is the mistake. Every module then has its own opinion about defaults, nothing is validated until the code path that needs it executes, and a typo in a key name is a silent None. Read configuration exactly once, at the top of the program, into a frozen dataclass.
FIG2
# settings.py — one typed view of everything the program reads
# CLI: pulumi preview --stack prod
from dataclasses import dataclass
import pulumi
@dataclass(frozen=True)
class AppSettings:
region: str
instance_size: str
replicas: int
enable_backups: bool
allowed_cidrs: list[str]
db_password: pulumi.Output[str]
@staticmethod
def load() -> "AppSettings":
cfg = pulumi.Config() # namespace defaults to the project name
aws_cfg = pulumi.Config("aws") # provider namespace, same stack file
backups = cfg.get_bool("enableBackups")
return AppSettings(
region=aws_cfg.require("region"),
instance_size=cfg.get("instanceSize") or "t3.small",
replicas=cfg.get_int("replicas") or 2,
enable_backups=True if backups is None else backups,
allowed_cidrs=cfg.require_object("allowedCidrs"),
# require_secret returns Output[str]; the value is never a plain str here
db_password=cfg.require_secret("dbPassword"),
)
Two properties make this worth the boilerplate. First, mypy now checks every downstream use — settings.replicas + 1 type-checks, settings.replicaz does not. Second, db_password is declared as pulumi.Output[str], so any code that tries to use it as a string fails at type-check time rather than quietly stripping the secret marker at run time.
For nested configuration, require_object returns whatever JSON-shaped value the YAML holds, typed as Any. Feed it straight into a Pydantic model so that a malformed stack file fails before a single API call is made.
# network_settings.py — validate structured config at load time
# CLI: pulumi up --stack prod
import pulumi
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class NetworkSettings(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="forbid")
vpc_cidr: str = Field(alias="vpcCidr", pattern=r"^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$")
subnet_count: int = Field(alias="subnetCount", ge=2, le=6)
single_nat_gateway: bool = Field(alias="singleNatGateway", default=False)
def load_network() -> NetworkSettings:
cfg = pulumi.Config()
try:
return NetworkSettings.model_validate(cfg.require_object("network"))
except ValidationError as exc:
# RunError prints cleanly without a Python traceback in the CLI output
raise pulumi.RunError(f"invalid myapp:network configuration: {exc}") from exc
extra="forbid" is the line that earns its keep: it turns subnetCounts: 3 — a plausible typo in a hand-edited stack file — into a hard failure rather than a silent fallback to the default. Set nested values with --path so the CLI builds the structure for you, and note that it infers types, so 3 becomes an integer and "3" stays a string.
# CLI: build structured config without hand-editing YAML
pulumi config set --path 'myapp:network.vpcCidr' 10.40.0.0/16
pulumi config set --path 'myapp:network.subnetCount' 3
pulumi config set --path 'myapp:allowedCidrs[0]' 10.0.0.0/8
pulumi config set --path 'myapp:allowedCidrs[1]' 192.168.0.0/16
How Secret Values Are Encrypted
When you run pulumi config set --secret myapp:dbPassword <value>, the CLI does not send the plaintext anywhere near the stack file. It asks the stack's secrets manager for a data key, encrypts the value with AES-256-GCM, and writes the ciphertext under a secure: key. Everything about how that data key is obtained depends on which secrets provider the stack was initialised with.
FIG3
Passphrase provider. The data key is derived from PULUMI_CONFIG_PASSPHRASE (or the file named by PULUMI_CONFIG_PASSPHRASE_FILE) using PBKDF2 with a random salt. The salt is stored in the stack file as encryptionsalt, and the field carries more than a salt — it also contains a short encrypted canary value, which is how the CLI can tell you the passphrase is wrong instead of handing back garbage plaintext. No network call is involved, and no key escrow exists: lose the passphrase and the ciphertext is gone.
Cloud KMS providers. awskms://, gcpkms://, azurekeyvault://, and hashivault:// use envelope encryption. Pulumi generates one random data key for the stack, asks the KMS to encrypt it, and stores the wrapped result as encryptedkey in the stack file. Individual config values and state secrets are encrypted locally with that data key; the KMS only ever sees the wrapped key, and only on the first decrypt of each CLI invocation. Access control, rotation of the wrapping key, and an audit trail of every decrypt all become the cloud provider's problem, which is usually the point.
Pulumi Cloud (the default). With no --secrets-provider flag, the stack uses a per-stack key managed by the service, and the CLI calls the service to encrypt and decrypt. The stack file carries no encryptionsalt and no encryptedkey, which is why a Pulumi Cloud stack file looks conspicuously clean. The trade-off is explicit: the plaintext transits the service at encryption time, and decryption requires a working login.
Whichever provider is chosen, the resulting secure: value is opaque and stable in review — a changed secret shows as a changed ciphertext blob in a diff, so reviewers can see that a secret changed without seeing what it changed to.
FIG4
The same encryption applies to state. When a secret-marked value flows into a resource input or output, the checkpoint stores it as a small tagged object rather than a raw string, using Pulumi's sentinel key 4dabf18193072939515e22adb298388d with the value 1b47061264138c4ac30d75fd1eb44270 to mark the object as a secret, alongside a ciphertext field. That sentinel is the fastest way to audit a stack: if you expect three secrets in state and find eleven, something is marking more than you thought — or if you find zero where you expected three, something has been unwrapped.
Config Secrets Versus Output.secret
There are two distinct ways a value becomes secret, and conflating them causes most of the confusion.
A config secret originates outside the program: someone ran pulumi config set --secret, the ciphertext lives in the stack file, and require_secret returns it already wrapped in an Output with the secret marker set. Its lifetime is the stack's, and rotating it means editing configuration.
An Output.secret originates inside the program. pulumi.Output.secret(value) takes any value or Output and returns one marked secret; pulumi.Output.unsecret(o) removes the mark deliberately. You need this when a provider hands back something sensitive that its schema does not mark — a bootstrap token, a generated connection string, an API key returned by a dynamic provider. The related resource option additional_secret_outputs does the same job at the resource level, marking named output properties secret regardless of what the provider schema says.
The marker propagates. Any apply on a secret Output produces a secret Output; Output.all(a, b, c) is secret if any input is; Output.concat likewise. This is why the connection-string pattern below is safe: the interpolated DSN inherits secret-ness from the password, so it is encrypted in state and masked in outputs without anyone remembering to mark it.
# db.py — secret provenance through the resource graph
# CLI: pulumi up --stack prod
import pulumi
import pulumi_aws as aws
import pulumi_random as random
cfg = pulumi.Config()
generated = random.RandomPassword(
"db-password",
length=32,
special=True,
override_special="!#$%*()-_=+[]{}<>:?",
)
# Provider note: RandomPassword.result is already marked secret by the provider schema
password: pulumi.Output[str] = cfg.get_secret("dbPassword") or generated.result
db = aws.rds.Instance(
"app-db",
engine="postgres",
engine_version="16.3",
instance_class="db.t4g.medium",
allocated_storage=100,
db_name="app",
username="appadmin",
password=password,
storage_encrypted=True,
skip_final_snapshot=False,
final_snapshot_identifier="app-db-final",
# State implication: the password input is written to the checkpoint encrypted
opts=pulumi.ResourceOptions(additional_secret_outputs=["password"]),
)
dsn = pulumi.Output.all(db.address, db.port, password).apply(
lambda parts: f"postgresql://appadmin:{parts[2]}@{parts[0]}:{parts[1]}/app"
)
pulumi.export("dbHost", db.address) # plain: safe to read in CI
pulumi.export("dsn", dsn) # secret: printed as [secret] without --show-secrets
One asymmetry to internalise: reading a config secret with the non-secret accessor still works. cfg.require("dbPassword") returns the decrypted plaintext as a str and logs a warning of the form Configuration 'myapp:dbPassword' value is a secret; use require_secret instead of require. A warning in a busy CI log is not a control. Treat that warning as a build failure by grepping for it in your pipeline, or avoid the whole class of mistake by never letting the raw Config object out of AppSettings.load().
Where Secrets Leak, and How to Stop It
Encryption at rest is the easy half. The leaks are all at the boundaries.
FIG5
Stack outputs. pulumi.export on an unmarked value publishes it in cleartext to anyone with read access to the stack, and to every stack that consumes it via a StackReference. A StackReference.get_output on a secret returns a secret, so provenance survives the hop — but only if it was marked in the producing stack. Export identifiers and endpoints; export secrets only when a downstream stack genuinely needs them.
Command output. pulumi config get myapp:dbPassword decrypts and prints to stdout. pulumi stack output --show-secrets and pulumi stack export --show-secrets do the same in bulk. These are legitimate break-glass commands and terrible CI steps. If a pipeline needs a secret, have the pipeline read it from the same KMS-backed store the application reads it from, not from Pulumi.
Logs from inside apply. The engine masks secrets in diffs and in the resource table, but it does not sanitise strings your program passes to pulumi.log.info or to print. Anything you resolve inside an apply and log is logged in plaintext. When you need to confirm which secret is deployed, log a fingerprint instead.
# fingerprint.py — prove which value is deployed without printing it
# CLI: pulumi up --stack prod
import hashlib
import pulumi
def log_fingerprint(name: str, value: pulumi.Output[str]) -> None:
# Provider note: runs during apply, so it is skipped in preview for unknown values
value.apply(
lambda raw: pulumi.log.info(
f"{name} sha256={hashlib.sha256(raw.encode()).hexdigest()[:12]}"
)
)
Delivery into application runtime. Writing a secret into a container environment variable, a Kubernetes ConfigMap, or a task-definition literal puts the plaintext in a cloud API that is far more widely readable than your state file. Pass a reference — an SSM parameter name, a Secrets Manager ARN, a Key Vault URI — and let the workload's own identity fetch it. This is the whole subject of passing configuration from Pulumi to application code.
CI transcripts. Set PULUMI_CONFIG_PASSPHRASE as a masked pipeline secret, never as a literal in a workflow file, and never echo the command that sets it. Prefer a KMS provider with OIDC-federated credentials so no long-lived secret exists in CI at all.
Choosing a Secrets Provider at Stack Init
The secrets provider is chosen when the stack is created and is recorded in the stack file. Choose it before the first pulumi up, because everything encrypted afterwards is bound to it.
FIG6
# CLI: create each stack with an explicit secrets provider
pulumi stack init dev --secrets-provider=passphrase
pulumi stack init prod --secrets-provider="awskms://alias/pulumi-prod?region=eu-west-1"
# other supported URLs
# gcpkms://projects/P/locations/global/keyRings/R/cryptoKeys/K
# azurekeyvault://my-vault.vault.azure.net/keys/pulumi
# hashivault://pulumi-transit-key
Changing your mind later is a single command, but not a cheap one:
# CLI: decrypt everything with the old manager, re-encrypt with the new one
pulumi stack select prod
export PULUMI_CONFIG_PASSPHRASE='<the current passphrase>'
pulumi stack change-secrets-provider "awskms://alias/pulumi-prod?region=eu-west-1"
# State implication: rewrites every secure: value in Pulumi.prod.yaml AND every
# encrypted value in the checkpoint, producing a new state version.
The command requires the old provider to still be usable, because it must decrypt before it can re-encrypt. If the passphrase is lost, there is no migration path: you delete the affected config keys, set them again under the new provider, and refresh or replace any resource whose state holds an unreadable secret. Take a pulumi stack export > backup.json before running it, and run it when no other update is in flight — a concurrent update against a half-migrated stack file will fail to decrypt.
Step-by-Step: A Secure Configuration Baseline
FIG7
1. Create the stack with an explicit provider and declare your keys
Initialise with --secrets-provider, then add the config: block to Pulumi.yaml shown earlier so required keys are validated up front.
# CLI: initialise and confirm what landed in the stack file
pulumi stack init staging --secrets-provider="awskms://alias/pulumi-staging?region=eu-west-1"
grep -E 'secretsprovider|encryptedkey' Pulumi.staging.yaml
2. Populate configuration, separating plain from secret
# CLI: plain values are reviewable; secrets go in with --secret
pulumi config set aws:region eu-west-1
pulumi config set myapp:instanceSize m6i.large
pulumi config set myapp:replicas 4
pulumi config set --secret myapp:dbPassword "$(openssl rand -base64 24)"
pulumi config set --secret --path 'myapp:integrations.stripeKey' "$STRIPE_KEY"
3. Load once into typed settings
Import AppSettings.load() at the top of __main__.py and pass the resulting object down. No module below the entry point should import pulumi.Config at all — that rule alone eliminates most accidental unwrapping.
# __main__.py — the only place configuration is read
# CLI: pulumi up --stack staging
import pulumi
from network_settings import load_network
from settings import AppSettings
settings = AppSettings.load()
network = load_network()
pulumi.log.info(f"deploying {settings.replicas}x {settings.instance_size} in {settings.region}")
# Provider note: nothing here resolves settings.db_password, so it stays an Output
4. Preview and confirm the masking
pulumi preview --diff shows [secret] in place of any masked value. If a value you expected to be masked shows in cleartext, it was unwrapped somewhere between config and resource input — fix it before the first up.
Verification
# CLI: secrets appear as [secret]; plain values appear in full
pulumi config
# CLI: outputs are masked unless you explicitly ask (break-glass only)
pulumi stack output
pulumi stack output dsn --show-secrets
# CLI: count secret-marked values in the checkpoint via Pulumi's sentinel key
pulumi stack export | grep -o '4dabf18193072939515e22adb298388d' | wc -l
# CLI: prove the stack file holds no plaintext for a known secret
grep -c 'secure:' Pulumi.staging.yaml
Add the sentinel count to a review checklist. A pull request that changes the count is a pull request that changed which values are protected, and that deserves a human reading it. For the config file itself, a pre-commit hook that fails on any line matching a plausible credential pattern outside a secure: key catches the case where someone edits YAML by hand instead of using the CLI.
Troubleshooting
error: Missing required configuration variable 'myapp:dbPassword' — a require_* call found nothing. The message includes the exact pulumi config set command to fix it. In CI this usually means the job checked out the repository but is running against a stack whose config file was never committed, or the stack name is wrong.
error: Configuration 'myapp:replicas' value 'three' is not a valid int — raised as pulumi.ConfigTypeError from require_int. Someone set the value with quotes, or --path inferred a string. Re-set it without quotes.
error: constructing secrets manager of type "passphrase": incorrect passphrase — the canary in encryptionsalt failed to decrypt. The passphrase is wrong, or you are pointed at a different stack than you think. Check pulumi stack --show-name before assuming the passphrase is at fault.
error: constructing secrets manager of type "passphrase": passphrase must be set with PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE environment variables — no passphrase in the environment at all. Common in CI when a job step runs in a different shell than the one that exported it.
AccessDeniedException: ... is not authorized to perform: kms:Decrypt — the identity running Pulumi lacks decrypt rights on the wrapping key. Every engineer and every CI role that runs preview or up needs both kms:Encrypt and kms:Decrypt on that key, and if the key policy restricts by alias, the alias must resolve in the region named in the provider URL.
Warning Configuration 'myapp:dbPassword' value is a secret; use require_secret instead of require — the code is unwrapping a secret. It will succeed. Treat it as an error in your pipeline.
A secret you expected to be masked appears in a diff — trace it backwards. Either it was never a config secret (someone used pulumi config set without --secret), or an apply returned a value derived from something already unwrapped. Output.secret re-marks it, but fix the origin rather than papering over it.
FAQ
Should Pulumi.prod.yaml be committed to git?
Yes. Secret values in it are ciphertext, and the plaintext values are exactly the environment description you want visible in code review. The thing that must not be committed is the passphrase, or anything that grants decrypt access to the wrapping key.
Can I use different secrets providers for different stacks in one project?
Yes — the provider is per stack, recorded in each Pulumi.<stack>.yaml. A common layout is passphrase for ephemeral developer stacks and a cloud KMS key for staging and production, which keeps local iteration fast without weakening the environments that matter.
Does marking a value secret encrypt it in the cloud provider too?
No. Pulumi encrypts the value in its own config and state. What the cloud provider does with the value once it is submitted is entirely up to that service's API and storage — which is why an RDS password ends up in the AWS control plane regardless, and why delivering secrets by reference beats delivering them by value.
How do I read a secret in a pytest unit test?
Set the configuration through the test harness rather than the CLI: pulumi.runtime.set_mocks plus a Pulumi.<stack>.yaml fixture, or construct your AppSettings object directly with literal values. Tests should never depend on a real secrets provider being reachable.
What happens if two engineers set the same secret at the same time?
Both writes go to Pulumi.<stack>.yaml and the second commit wins in git, exactly like any other file conflict — the ciphertexts differ even for identical plaintext because of the per-write nonce, so git cannot merge them. Resolve by re-running pulumi config set --secret once on the merged file.
Is there a way to see which stacks use a given KMS key?
Grep the repository for secretsprovider: across all Pulumi.*.yaml files, and cross-check with the key's CloudTrail decrypt events. There is no server-side index of stacks by key, so treat the stack files as the authoritative inventory.
Related
- Using pulumi.Config and Typed Settings in Python — the accessor API and dataclass wrapper in full detail.
- Structuring Per-Environment Configuration in Pulumi — how to keep dev, staging, and production config files honest.
- Rotating Pulumi Stack Secrets Without Downtime — the two-phase rotation procedure for live credentials.
- Best Practices for Managing Cloud Credentials in Python — the ambient-credential patterns that keep provider keys out of config entirely.