Skip to content

Pulumi Footguns


1. Using apply() Inside Resource Arguments Creates Ordering Problems

You use .apply() to transform an output inside another resource's constructor: instance.id.apply(lambda id: f"prefix-{id}"). If the transformation itself creates new resources or has side effects, Pulumi can't statically analyze the dependency graph, leading to out-of-order applies or resources created in the wrong sequence. Fix: Use Pulumi's built-in output combinators (Output.concat, Output.all, Output.format) for string manipulation and combination. Reserve .apply() for transformations that don't create resources. Keep resource creation in the main program body, not inside apply() callbacks.

Remember: Think of .apply() like a .then() on a Promise — it transforms the resolved value, but the DAG engine cannot see inside it. If you create resources inside .apply(), those resources are invisible to pulumi preview until the parent output resolves, making plans unreliable.


2. Storing State in Pulumi Cloud Without Backup

You use Pulumi Cloud (the default backend) and assume state is always available. If your organization loses access to Pulumi Cloud (billing issue, account suspension, service outage), you can't run any stack operations — including emergency destroy runs during an incident. Fix: Periodically export and archive stack state: pulumi stack export > stack-state-$(date +%Y%m%d).json. For critical infrastructure, use a self-managed backend (S3, GCS, Azure Blob) where you control access. Document the recovery procedure for accessing state without the Pulumi CLI.

War story: Teams have been locked out of their infrastructure during incidents when the Pulumi Cloud service experienced downtime. If you cannot run pulumi up or pulumi destroy during an outage, your incident response is blocked. Self-managed backends (S3 + DynamoDB) give you full control over availability.


3. Provider Version Drift Across Team Members

Different engineers run pulumi up with different provider package versions installed. One engineer gets version 5.x of pulumi-aws, another has 6.x. The resource model differs between versions — properties are renamed, deprecated, or have different defaults. Plans are not reproducible. Fix: Pin provider versions in requirements.txt (Python), package.json (TypeScript), or go.mod (Go) with exact or constrained versions. Commit lockfiles (Pipfile.lock, package-lock.json, go.sum). Use pulumi about to show installed provider versions and verify they match across the team.


4. pulumi destroy Doesn't Delete Everything Because of Protect Flag

You set protect=True on critical resources to prevent accidental deletion — but then forget you did. When you legitimately run pulumi destroy, it fails with "cannot delete protected resource." In a hurry, you skip the error, leaving orphaned cloud resources that continue to accrue costs. Fix: Track protected resources: pulumi stack export | jq '.deployment.resources[] | select(.protect == true) | .urn'. Before destroy, explicitly unprotect: pulumi state unprotect <urn>. Document which resources are protected and why. Use protection sparingly — it's a speed bump, not a hard barrier.


5. Outputs With Pulumi's Async Model Cause Unexpected None Values

You write print(vpc.id) expecting to see the VPC ID. You get Output<str> instead (Python) or [Output] (TypeScript). You try to use the raw output object as a string in a config file or as an argument to a subprocess, and get a useless string representation. Fix: Understand that Pulumi outputs are asynchronous. To use output values: chain with .apply(), use Output.all() to combine multiple outputs, or export them and read back with pulumi stack output --show-secrets. Never use raw output objects in string contexts — always resolve through Pulumi's output system.


6. Running pulumi up Without --target Accidentally Modifies Unrelated Resources

Your stack manages 50 resources. You make a small change intended for one security group. Running pulumi up re-evaluates all resources and the plan includes unrelated changes — maybe from drift, maybe from provider schema changes. You approve quickly and modify things you didn't intend to. Fix: Use --target <urn> to limit applies to specific resources. Review the preview carefully before approving — look at the "Resources" summary line. Run pulumi preview --diff for a detailed line-by-line diff. In CI, fail the build if unexpected resources would change.


7. Cross-Stack References Create Tight Coupling That Breaks Independent Deployments

You reference 15 outputs from a network stack in your application stack using StackReference. Now you can't deploy the application stack without the network stack being fully up-to-date. In an incident, you can't fix the app stack without also having access to the network stack state. Fix: Minimize cross-stack references. Pass only truly shared, stable values between stacks (VPC ID, cluster name). Prefer configuration (pulumi config set) for values that change rarely. For values that might not exist yet, handle the None case: network.get_output("subnet_id") or config.get("fallback_subnet_id").


8. Secrets in Config Are Encrypted But Logged in CI Output

You properly use config.require_secret("dbPassword") and the value is encrypted in Pulumi state. But your CI pipeline runs with verbose logging, and a print() statement somewhere in your Pulumi code logs the secret value via .apply() to stdout, which goes into CI logs. Fix: Never print() or log secret output values. If you need to debug, check the secret value separately: pulumi stack output --show-secrets dbPassword. Review CI logs for accidental secret exposure. Use Pulumi's secret output type and let the framework handle masking — don't bypass it with manual string formatting.

Gotcha: The most common leak path is f"Connecting to {db_url.apply(lambda u: u)}" inside a print or log statement. Pulumi's .apply() unwraps the secret, and print() writes the plaintext to stdout. CI systems like GitHub Actions and GitLab CI store stdout in persistent, searchable logs.