2.5. Config and Secrets
Config and secrets let each stack carry its own values — image versions, ports, credentials — without hardcoding
them in __main__.py.
Step 1 — Make the image tag configurable
Set a config value for the image tag, then read it in code:
Update __main__.py to read the value:
Deploy and inspect where the config value landed:
Now switch to an older tag and redeploy:
Explanation
pulumi.Config() reads values from the active stack’s config file — config/Pulumi.dev.yaml when dev is
active. config.require("image-tag") returns the value as a plain Python str and fails immediately if
the key is not set, printing a clear error before any resources are touched.
| Call | Returns | If not set |
|---|---|---|
config.get("key") | Optional[str] | Returns None |
config.require("key") | str | Raises an error |
Prefer require for values that every deploy must have — it makes missing config a loud, early failure rather
than a silent wrong default. Use get (with default=) for values that are genuinely optional or have a
sensible fallback, like port above.
Switching from 0.4-plain-text to 0.3 triggers a replace: Pulumi pulls the new image and recreates the
container because the image reference changed.
All config values written with pulumi config set are stored in the stack config file. That file is committed
to the repository — config travels with the code so every team member sees the same values when they select the
same stack.
Use kebab-case for config key names
Name config keys with kebab-case: image-tag, not image_tag. That is the Pulumi convention and matches the
CLI output.
Step 2 — Make the port configurable
Set a port value for the dev stack:
Update __main__.py to read the port and use it everywhere 8080 appears:
Apply and verify:
Explanation
config.get_int("port", default=8080) reads the value, converts it to a Python int, and returns 8080 if
the key is absent. The typed getters (get_int, get_float, get_bool) all accept a default argument —
use it instead of or <fallback> so the intent is explicit and the type is guaranteed.
The port is a plain int, not a pulumi.Output, so it can be embedded directly in the message f-string
without apply().
The real payoff comes in the stacks lab: when you create a prod stack alongside dev, you set
pulumi config set port 8081 for prod. Both containers run simultaneously on the same Docker host without
competing for the same port.
Project-level defaults in Pulumi.yaml
Instead of default=8080 in code, you can declare the default in Pulumi.yaml under a config: section:
Stack config files override this project value — a Pulumi.prod.yaml with port: 8081 takes precedence.
Defaults in Pulumi.yaml are the right place for values that have a sensible project-wide baseline and
should be visible to anyone reading the project. The default= argument in code is useful when the fallback
is an implementation detail that callers should not need to know about.
Step 3 — Store a secret
Store a fake API key as an encrypted secret:
Inspect the config file to see how the value is stored:
Update __main__.py to read the secret and inject it as a container environment variable:
Apply:
Verify the env var is set inside the running container:
Explanation
--secret encrypts the value before writing it to config/Pulumi.dev.yaml. The file stores a ciphertext
— safe to commit. Only Pulumi (with the stack’s encryption key) can decrypt it at deploy time.
config.require_secret("api-key") returns a pulumi.Output[str]. Pulumi keeps the decrypted value inside
the output wrapper and never exposes it to plain Python strings. The apply() call builds ["API_KEY=…"]
after decryption, which is why envs receives an Output[List[str]].
| Call | Return type | Use when |
|---|---|---|
config.get("key") | Optional[str] | Plain value, optional |
config.require("key") | str | Plain value, required |
config.get_secret("key") | Optional[Output[str]] | Encrypted value, optional |
config.require_secret("key") | Output[str] | Encrypted value, required |
Secrets stay encrypted in state
Any resource property that receives a secret Output is automatically marked encrypted in the Pulumi state
file. You will see [secret] in pulumi preview and pulumi up output wherever a secret value flows —
Pulumi never prints the plaintext.
Production: use a KMS-backed secrets provider
The local backend encrypts secrets with a passphrase — adequate for dev, but a shared static secret is not suitable for production. On a team backend (Azure Blob, S3), configure a managed key instead:
With a KMS-backed provider, the encryption key lives in Azure Key Vault (or AWS KMS / GCP KMS). Pulumi holds no key material — Key Vault controls who can encrypt and decrypt, and every access is audited. The ciphertext in the config file is useless without Key Vault access.
main.py after this step
Step 4 — Maps and lists
Config values are not limited to strings. Set a map of resource tags using the --path flag:
Set a list of allowed hosts the same way:
Inspect the config file to see the structure Pulumi produced:
Read both values in __main__.py and pass the tags as Docker container labels:
Apply and confirm:
Explanation
--path uses dot notation for map keys and bracket notation for list indices. The resulting YAML in the stack
config file is native YAML structure — not a JSON string — so it is readable and mergeable:
config.get_object("key") returns the value parsed to a plain Python dict or list (or None if absent).
There is also config.require_object("key") for values that must always be present. Both work with any
JSON-compatible structure: nested maps, mixed lists, and lists of maps are all valid.
Tags on every Azure resource
In the Azure labs, you will want to attach the same tags map to every resource — Resource Group, VNet, AKS
cluster, ACR registry. Reading a tags map from config once and passing it to every resource is the standard
pattern: