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:

pulumi config set image-tag 0.4-plain-text

Update __main__.py to read the value:

import pulumi
import pulumi_docker as docker


def main():
    config = pulumi.Config()
    image_tag = config.require("image-tag")

    image = docker.RemoteImage(
        "nginx-image",
        name=f"nginxdemos/hello:{image_tag}",
        keep_locally=True
    )

    container = docker.Container(
        "nginx-container",
        image=image.image_id,
        ports=[docker.ContainerPortArgs(internal=80, external=8080)]
    )

    message = container.name.apply(
        lambda name: f"Container '{name}' is running — curl http://localhost:8080"
    )
    summary = pulumi.Output.all(container.name, image.name).apply(
        lambda args: f"[{args[0]}] running image: {args[1]}"
    )

    pulumi.export("container_name", container.name)
    pulumi.export("message", message)
    pulumi.export("summary", summary)


main()

Deploy and inspect where the config value landed:

pulumi up
cat config/Pulumi.dev.yaml

Now switch to an older tag and redeploy:

pulumi config set image-tag 0.3
pulumi up

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.

CallReturnsIf not set
config.get("key")Optional[str]Returns None
config.require("key")strRaises 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:

pulumi config set port 8080

Update __main__.py to read the port and use it everywhere 8080 appears:

    config = pulumi.Config()
    image_tag = config.require("image-tag")
    port = config.get_int("port", default=8080)

    ...

    container = docker.Container(
        "nginx-container",
        image=image.image_id,
        ports=[docker.ContainerPortArgs(internal=80, external=port)]
    )

    message = container.name.apply(
        lambda name: f"Container '{name}' is running — curl http://localhost:{port}"
    )

Apply and verify:

pulumi up
curl http://localhost:8080

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:

name: pulumi-basics
runtime: python
config:
  port:
    value: 8080

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:

pulumi config set --secret api-key my-super-secret-key

Inspect the config file to see how the value is stored:

cat config/Pulumi.dev.yaml

Update __main__.py to read the secret and inject it as a container environment variable:

    api_key = config.require_secret("api-key")

    container = docker.Container(
        "nginx-container",
        image=image.image_id,
        ports=[docker.ContainerPortArgs(internal=80, external=port)],
        envs=api_key.apply(lambda k: [f"API_KEY={k}"])
    )

Apply:

pulumi up

Verify the env var is set inside the running container:

docker exec $(docker ps -qf "name=nginx") env | grep API_KEY

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]].

CallReturn typeUse when
config.get("key")Optional[str]Plain value, optional
config.require("key")strPlain 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:

pulumi stack init prod --secrets-provider="azurekeyvault://my-vault.vault.azure.net/keys/pulumi"

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
import pulumi
import pulumi_docker as docker


def main():
    config = pulumi.Config()
    image_tag = config.require("image-tag")
    port = config.get_int("port", default=8080)
    api_key = config.require_secret("api-key")

    image = docker.RemoteImage(
        "nginx-image",
        name=f"nginxdemos/hello:{image_tag}",
        keep_locally=True
    )

    container = docker.Container(
        "nginx-container",
        image=image.image_id,
        ports=[docker.ContainerPortArgs(internal=80, external=port)],
        envs=api_key.apply(lambda k: [f"API_KEY={k}"])
    )

    message = container.name.apply(
        lambda name: f"Container '{name}' is running — curl http://localhost:{port}"
    )
    summary = pulumi.Output.all(container.name, image.name).apply(
        lambda args: f"[{args[0]}] running image: {args[1]}"
    )

    pulumi.export("container_name", container.name)
    pulumi.export("message", message)
    pulumi.export("summary", summary)


main()

Step 4 — Maps and lists

Config values are not limited to strings. Set a map of resource tags using the --path flag:

pulumi config set --path 'tags.env' dev
pulumi config set --path 'tags.team' workshop

Set a list of allowed hosts the same way:

pulumi config set --path 'allowed-hosts[0]' localhost
pulumi config set --path 'allowed-hosts[1]' example.com

Inspect the config file to see the structure Pulumi produced:

cat config/Pulumi.dev.yaml

Read both values in __main__.py and pass the tags as Docker container labels:

tags = config.get_object("tags") or {}
allowed_hosts = config.get_object("allowed-hosts") or []

container = docker.Container(
    "nginx-container",
    ...
    labels=[docker.ContainerLabelArgs(label=k, value=v) for k, v in tags.items()]
)

pulumi.export("allowed-hosts", allowed_hosts)

Apply and confirm:

pulumi up
pulumi stack output allowed-hosts
docker inspect $(docker ps -qf "name=nginx") --format '{{json .Config.Labels}}'

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:
  tags:
    env: dev
    team: workshop
  allowed-hosts:
    - localhost
    - example.com

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:

tags = config.get_object("tags") or {}
rg = resources.ResourceGroup("rg-lab-dev", tags=tags)