Passing Configuration from Pulumi to Application Code
Half of an application's settings are known before anything is deployed, and half do not exist until the cloud has finished creating things — the RDS endpoint, the queue URL, the role ARN, the connection string assembled from four of them. The first half is a solved problem. The second half is a handoff between two systems with different clocks, and the design choice is not whether to hand the values over but which channel carries them. This guide sits under Pulumi secrets and configuration in Python in Pulumi patterns and provider management, and it compares the four channels that are actually used in production, including what each one costs you in coupling.
Context
A Pulumi program runs twice over, in a sense. First it executes top to bottom as ordinary Python, constructing a resource graph — at that moment instance.address is a promise, not a hostname, because no CreateDBInstance call has been made. Then the engine walks the graph, calls the provider, and resolves those promises. Any code that needs the real string has to be scheduled into that second pass, which is what Output.apply does.
This is why the first thing every newcomer tries fails:
# broken.py — the mistake that produces an unusable value
# CLI: pulumi preview --stack dev
import pulumi
import pulumi_aws as aws
db = aws.rds.Instance("app-db", instance_class="db.t4g.micro", allocated_storage=20,
engine="postgres", engine_version="16.3", skip_final_snapshot=True,
username="appuser", password="placeholder-set-via-config")
# Produces the literal text "Calling __str__ on an Output[T] is not supported." —
# a warning, not an exception, so it silently becomes part of the string.
url = "postgresql://appuser@" + str(db.address) + ":5432/app"
pulumi.export("bad_url", url)
The SDK deliberately warns rather than raising, so the bad value propagates. It gets exported, written to a ConfigMap, baked into a task definition, and the failure surfaces hours later as a DNS error naming a hostname that is an English sentence.
Prerequisites
pulumi>=3.0withpulumi-aws>=6.0andpulumi-kubernetes>=4.0for the examples, on Python 3.9+.- A stack that already provisions the resources whose attributes you intend to pass along — this guide is about the handoff, not the provisioning.
- An application that can read its settings from at least one of environment variables, a mounted file, or a runtime SDK call. If it can only read a file baked into the container image, the handoff has to happen at image build time and none of the mechanisms below apply cleanly.
- Decrypt permission on the stack's secrets provider for anything that reads secret stack outputs, as described in the parent topic.
Implementation
Step 1 — Compose apply-time values correctly
Output.apply schedules a function to run once the value is known and returns a new Output wrapping the result. Output.all does the same across several values at once, and Output.concat is sugar for the common string-joining case.
# compose.py — building a connection string from values that do not exist yet
# CLI: pulumi up --stack dev
from __future__ import annotations
from typing import Any, Dict, List
import pulumi
import pulumi_aws as aws
cfg = pulumi.Config()
db_password = cfg.require_secret("dbPassword")
db = aws.rds.Instance(
"app-db",
instance_class="db.t4g.small",
allocated_storage=50,
engine="postgres",
engine_version="16.3",
db_name="app",
username="appuser",
password=db_password,
skip_final_snapshot=False,
final_snapshot_identifier="app-db-final",
)
queue = aws.sqs.Queue("app-jobs", visibility_timeout_seconds=120)
# Output.concat: the readable form when the result is one string.
# Provider note: db_password is secret, so the concatenation is secret too.
database_url: pulumi.Output[str] = pulumi.Output.concat(
"postgresql://appuser:", db_password, "@", db.address, ":",
db.port.apply(str), "/app?sslmode=require",
)
# Output.all with keyword arguments: the readable form when the result is a dict.
runtime_settings: pulumi.Output[Dict[str, Any]] = pulumi.Output.all(
database_url=database_url,
queue_url=queue.url,
region=aws.get_region().name,
).apply(lambda a: {
"DATABASE_URL": a["database_url"],
"QUEUE_URL": a["queue_url"],
"AWS_REGION": a["region"],
"POOL_SIZE": "10",
})
Two rules keep apply callbacks safe. Do no I/O inside one — no HTTP call, no file write, no boto3 client — because the callback runs inside the engine's resolution pass on every update and turns your program into something whose result depends on the network. And expect it to be skipped: during pulumi preview the attributes of resources that do not exist yet are unknown, so the callback is not invoked and a print inside it produces nothing. That is normal, not a bug, and it is why debugging by printing from inside apply is so frustrating.
Step 2 — Choose the delivery channel deliberately
Four channels are in common use. They differ mainly in who has to be running when the value changes, and that is the property worth optimising.
A stack output read by a deploy step keeps the value inside the deployment pipeline: pulumi stack output database_host feeds a subsequent job. It is the loosest coupling for the application, which never learns Pulumi exists, and the tightest for the pipeline, which must be able to authenticate to the state backend. It is a poor channel for secrets, because everything with permission to read the stack can read them.
A cloud secret manager entry written by the stack decouples the two systems almost entirely. Pulumi writes an aws.secretsmanager.Secret, the application reads it by name at startup with its own credentials, and neither side needs the other to be present. The cost is a runtime dependency: a Secrets Manager outage or a missing IAM permission becomes a start-up failure, and the application now needs an SDK, a retry policy, and a cache.
Environment variables baked into a task or function definition are the simplest thing that works and the most rigid. The value is fixed at deployment, so changing it requires a new task definition revision or a UpdateFunctionConfiguration — the application cannot pick up a new value on its own. That rigidity is a feature for things like a queue URL that should never change under a running process, and a liability for anything rotated frequently.
A Kubernetes Secret or ConfigMap sits between the two: written by the stack, read by the platform, mounted into pods. Values mounted as files refresh in place within a kubelet sync period; values injected via secretKeyRef are read once at container start and need a rollout.
| Channel | Who reads it | Update reaches the process | Suitable for secrets |
|---|---|---|---|
| Stack output + deploy step | The pipeline | Next deployment | No |
| Cloud secret manager entry | The application, at runtime | Next fetch or cache expiry | Yes |
| Task or function env var | The container runtime | New revision, rolling restart | Only via valueFrom |
Kubernetes Secret/ConfigMap |
The kubelet | File mounts: minutes. Env: rollout | Yes, with encryption at rest |
Step 3 — Wire up the two channels that carry secrets safely
The secret manager entry and the ECS secrets block are worth showing together, because they are usually used as a pair: the stack writes the value once, and the task definition references it by ARN rather than embedding it.
# delivery.py — write once, reference by ARN, never inline the plaintext
# CLI: pulumi up --stack prod
from __future__ import annotations
import json
import pulumi
import pulumi_aws as aws
from compose import database_url, runtime_settings
app_secret = aws.secretsmanager.Secret(
"app-runtime",
name="app/prod/runtime",
description="Connection settings produced by the platform stack",
recovery_window_in_days=7,
)
app_secret_version = aws.secretsmanager.SecretVersion(
"app-runtime-value",
secret_id=app_secret.id,
secret_string=runtime_settings.apply(json.dumps),
# State implication: secret_string is immutable, so a changed value REPLACES
# this resource — a new version takes AWSCURRENT, the old keeps AWSPREVIOUS.
)
execution_role = aws.iam.Role(
"app-exec",
assume_role_policy=json.dumps({
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "sts:AssumeRole",
"Principal": {"Service": "ecs-tasks.amazonaws.com"}}],
}),
)
aws.iam.RolePolicy(
"app-exec-read-secret",
role=execution_role.id,
policy=app_secret.arn.apply(lambda arn: json.dumps({
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": ["secretsmanager:GetSecretValue"],
"Resource": arn}],
})),
)
task = aws.ecs.TaskDefinition(
"app",
family="app",
cpu="512",
memory="1024",
network_mode="awsvpc",
requires_compatibilities=["FARGATE"],
execution_role_arn=execution_role.arn,
container_definitions=pulumi.Output.json_dumps([{
"name": "api",
"image": "ghcr.io/example/api:1.24.0",
"essential": True,
# Plain settings are fine inline; they show up in describe-task-definition.
"environment": [{"name": "POOL_SIZE", "value": "10"}],
# Secrets are fetched by the agent at task start from the ARN.
"secrets": [{"name": "APP_RUNTIME", "valueFrom": app_secret.arn}],
"portMappings": [{"containerPort": 8080, "protocol": "tcp"}],
}]),
)
Note what is not in that task definition: the connection string. Anything placed in environment is returned verbatim by aws ecs describe-task-definition to every principal with read access and is rendered in the console. The secrets block stores an ARN instead, and the ECS agent resolves it at task start using the execution role.
The Kubernetes equivalent splits along the same line — plain settings in a ConfigMap, sensitive ones in a Secret:
# k8s_delivery.py — plain settings and sensitive settings in different objects
# CLI: pulumi up --stack prod
from __future__ import annotations
import pulumi
import pulumi_kubernetes as k8s
from compose import database_url, queue
config_map = k8s.core.v1.ConfigMap(
"app-config",
data={
"QUEUE_URL": queue.url,
"POOL_SIZE": "10",
"LOG_LEVEL": "info",
},
)
app_secret = k8s.core.v1.Secret(
"app-secret",
# Provider note: string_data is base64-encoded by the API server, not
# encrypted — encryption at rest is an etcd configuration, not a Pulumi one.
string_data={"DATABASE_URL": database_url},
)
pulumi.export("app_config_name", config_map.metadata.name)
Step 4 — Stop derived values leaking into stack outputs
Secrecy in Pulumi is a taint that travels with the Output. A value derived from a secret through apply, all, or concat is secret; a value derived from it after you have unwrapped it to a plain str is not, and the engine has no way to know.
# outputs.py — export the addressing information, never the credential
# CLI: pulumi stack output --stack prod --json
from __future__ import annotations
import pulumi
from compose import database_url, db, queue
# Safe: an endpoint is not a credential, and downstream stacks need it.
pulumi.export("database_host", db.address)
pulumi.export("queue_url", queue.url)
# Derived from a secret, so already tainted — but be explicit rather than
# relying on the reader to trace the provenance three files back.
pulumi.export("database_url", pulumi.Output.secret(database_url))
# Better still: do not export it at all. Export the pointer instead, and let
# whoever needs the value fetch it with their own credentials.
pulumi.export("runtime_secret_name", "app/prod/runtime")
Where a provider does not mark a sensitive attribute as secret in its schema, add it at the resource:
# additional.py — taint an attribute the provider schema leaves in the clear
# CLI: pulumi up --stack prod
import pulumi
import pulumi_aws as aws
key = aws.iam.AccessKey(
"ci-user-key",
user="ci-deployer",
opts=pulumi.ResourceOptions(additional_secret_outputs=["secret", "sesSmtpPasswordV4"]),
)
Verification
Check both ends: that the value arrived, and that it did not arrive anywhere else.
# CLI: stack outputs contain endpoints and pointers, no credentials
pulumi stack output --stack prod --json | python3 -c \
"import json,sys; d=json.load(sys.stdin); print([k for k,v in d.items() if v=='[secret]'])"
# CLI: the task definition holds an ARN, not a connection string
aws ecs describe-task-definition --task-definition app \
--query 'taskDefinition.containerDefinitions[0].{env:environment,secrets:secrets}'
# CLI: the value the application will actually read
aws secretsmanager get-secret-value --secret-id app/prod/runtime \
--query 'SecretString' --output text | python3 -m json.tool
# CLI: nothing in the ConfigMap that belongs in the Secret
kubectl get configmap -o json | grep -ci 'password\|token\|postgresql://'
The end-to-end check is the only one that catches a wrong-but-plausible value: start one task, and assert on the application's own readiness probe rather than on Pulumi's exit code. A pulumi up that succeeds while the container crash-loops on could not translate host name means the handoff produced a string, just not a usable one.
Gotchas & Edge Cases
apply does not run at preview time. Unknown inputs make the whole callback unknown, so validation logic placed inside an apply never fires during pulumi preview and only fails during pulumi up. Validate configuration eagerly, before it touches an Output.
Circular handoffs are a design error, not a Pulumi limitation. If the container image needs a value that only exists after the container runs, no channel can help. Break the cycle by making the value discoverable at runtime rather than at build time.
Size limits bite quietly. Lambda caps the total environment block at 4 KB, and a Kubernetes ConfigMap at roughly 1 MiB. A settings blob that grows one key per service crosses the Lambda limit and fails with InvalidParameterValueException: Lambda was unable to configure your environment variables because the environment variables you have provided exceeded the 4KB limit.
Marking an output secret does not encrypt it downstream. pulumi.Output.secret encrypts the value in the checkpoint and masks it in the CLI. Once it is written into a ConfigMap or an environment block, its protection is whatever that system provides — which for a ConfigMap is none.
A stack output read by a deploy step needs decrypt permission. pulumi stack output db_url --show-secrets fails in CI with error: failed to decrypt encrypted configuration value when the pipeline role lacks kms:Decrypt, and the resulting empty variable is often not checked before it is used.
Every channel has a different staleness window. A file-mounted Secret updates in minutes, an env var never updates, and a runtime fetch updates on cache expiry. Mixing them for related settings gives you a window in which the application holds a queue URL from one deployment and a connection string from another.
Operational Notes
Name the settings once and keep the name stable across channels. If the stack exports queue_url, the ConfigMap key is QUEUE_URL, and the secret manager document uses queue_url as well, then grepping for the name finds every place the value lives. Renaming per channel is how a value ends up delivered twice with two different meanings.
Prefer a pointer over a payload whenever the consumer can dereference it. Exporting runtime_secret_name rather than the connection string means the blast radius of a leaked stack output is the name of a secret, and the permission to read the actual value stays with the application's own role. It also removes the stack from the rotation path — a point developed in rotating Pulumi stack secrets without downtime.
Version the shape of what you hand over. A settings document written as JSON gains and loses keys over time, and an application that reads it with d["QUEUE_URL"] fails with a KeyError on the deployment where the key was renamed. Add a schema_version field and have the application refuse to start on an unexpected value, which converts a subtle misconfiguration into a loud one.
Where the handoff crosses a stack boundary rather than a system boundary, a stack reference is usually the better tool than any of these channels — the mechanics are covered in handling Pulumi stack outputs and cross-stack references. The four channels here are for getting values to code that is not a Pulumi program at all.
FAQ
Why can't I just call str() on an Output and be done with it?
Because the string does not exist yet when your Python runs. The SDK returns a warning message instead of the value, and since it does not raise, the placeholder text travels downstream and fails somewhere far from the cause. Use apply or Output.concat.
Should the application read Pulumi stack outputs directly at startup?
No. That makes the state backend a runtime dependency of the application, requires it to hold credentials for the backend, and couples the application's startup to a tool that exists to deploy it. Write the value to a secret manager entry or a mounted file and let the application read that.
How do I pass a value produced by one stack into another stack's application?
Export it from the producing stack, consume it in the second stack with pulumi.StackReference, and let the second stack write it into whichever channel its application uses. The application still never talks to Pulumi.
Does Output.all preserve the secret marking of its inputs?
Yes. If any input to Output.all, Output.concat, or apply is secret, the result is secret, and it stays that way until something resolves it to a plain Python value. The taint is lost the moment you unwrap it, which is why unwrapping is worth flagging in review.
What is the right channel for a value that changes every hour?
A runtime fetch from a secret manager or parameter store, with a short client-side cache. Anything baked into a task definition or an image requires a deployment per change, and a deployment per hour is an operational cost with no upside.
Can I write configuration to a file on disk from the Pulumi program?
You can, inside an apply, and you should not. It does not happen at preview, it happens again on every update, and the file ends up on whichever machine ran the deployment rather than the one running the application. Use a resource the engine can track instead.
Related
- Pulumi Secrets and Configuration in Python — the parent topic covering the configuration hierarchy, typed settings, and secret encryption.
- Rotating Pulumi Stack Secrets Without Downtime — what happens to each of these channels when the value behind it changes.
- Handling Pulumi Stack Outputs and Cross-Stack References — the stack-to-stack case, as opposed to the stack-to-application case here.
- Deploying AWS Lambda Functions with Pulumi Python — where the environment-variable channel and its 4 KB ceiling apply directly.