Rookie mistakes I made with Pulumi dependency tracking
This post is part of my series on migrating my Homelab from Terraform to Pulumi. In this article, I’ll walk through a few rookie mistakes I made when modelling dependencies in Pulumi, why they caused problems, and how to avoid them.
Other parts in this series:
- Why I am migrating my Homelab IaC from Terraform to Pulumi
- Migrating OVH DNS records from Terraform to Pulumi
- Migrating Proxmox LXC containers from Terraform to Pulumi
- How to manage Pulumi Secrets with 1Password
- Rookie mistakes I made with Pulumi dependency tracking (this one)
- Pulumi vs Terraform: honest retrospective after a full migration
Chaining apply to create resources
I discovered .apply(callback) very early and started using it
everywhere, long before I really understood what it does. Most Pulumi
resources expose lazy values wrapped in some sort of Input. A
very common beginner mistake is to try to unwrap those values by
creating other resources inside apply chains like this:
import * as pulumi from "@pulumi/pulumi";
import { RandomPassword } from "@pulumi/random";
import { remote } from "@pulumi/command";
const password = new RandomPassword('password');
const connection = {}; // Omitted for simplicity
export const changePassword = pulumi
.output(password.result)
.apply((password) => {
return new remote.Command('change-password', {
connection,
create: `echo 'root:${password}' | chpasswd`
});
});Code language: TypeScript (typescript)
Before Pulumi can apply a plan, it has to compute it. Pulumi does this by
running in preview (dry-run) mode, which does not create, update, or delete
any resources. Instead, it computes the resources that would exist
and compares them against the current state. During preview, Pulumi will not
run apply callbacks because those callbacks depend on provider
side effects to resolve the lazy values.
In the example above, during preview Pulumi correctly detects that it needs
to create a new RandomPassword, but it never sees the
remote.Command meant to change the password because the
apply callback is never executed.
This compounds quickly, especially when you use apply to manage
dependencies for remote files or scripts. You might need to run
mkdir -p to ensure the parent folder exists, then copy the
file, then make it executable with chmod, and finally execute
it. I ended up with deep chains of nested apply calls just to
deploy scripts for restarting services or reloading configuration. That
easily becomes four or five levels of nesting once you add container
creation into the mix.
The fix is straightforward: do not create resources inside
apply callbacks. Instead, declare resources at the top level
and use apply only to compute the inputs that those resources
need.
Here is the same example rewritten correctly:
import * as pulumi from "@pulumi/pulumi";
import { RandomPassword } from "@pulumi/random";
import { remote } from "@pulumi/command";
const password = new RandomPassword('password');
const connection = {}; // Omitted for simplicity
export const changePassword = new remote
.Command('change-password', {
connection,
create: pulumi
.output(password.result)
.apply((password) =>
`echo 'root:${password}' | chpasswd`
)
});Code language: TypeScript (typescript)
Now Pulumi can see both resources clearly, and it knows that
remote.Command depends on the output of
RandomPassword. It will create the
RandomPassword first and then the remote.Command.
This pattern matters not only for performance (Pulumi can parallelize work more effectively when dependencies are explicit and granular) but also for producing accurate previews.
My recommendations
Never create resources inside apply callbacks. Use
apply only to derive input values for resources that are
declared at the top level.
Overusing pulumi.Input
Another rookie mistake I made was typing almost everything as a lazy value
using pulumi.Input. This caused two kinds of problems:
- It prevents you from using the value in places that need eager evaluation (for instance, in resource names, which must be known at preview time to detect duplicates and changes).
- It breaks class prototype chains in a way that is only detected at runtime. This is fine if you pass around plain objects, but it can be quite tricky if you pass something more complex, like a 1Password client.
For each value, it’s worth asking whether it is truly lazy at its source.
One of my early mistakes was to build helpers around
remote.Command and remote.CopyToRemote that
treated the remote file path as a lazy value, even though the path was
completely static and known before any code ran. That prevented me from
using the path in resource names, forced me into extra
apply chains, and made the preview diffs less useful.
My recommendations
Use pulumi.Input only for values that genuinely depend on
provider side effects or other lazy outputs. Keep static values as plain
types so you can safely use them in resource names and other places that
must be known during preview.
Not leaning on the parent property
Sometimes we need to create explicit dependencies between resources because Pulumi cannot reliably infer them from the resource definitions alone.
For example, when copying a file to a remote path, the copy will fail if the
target directory does not already exist. Creating that directory is just a
simple mkdir -p on the remote host, but Pulumi has no way to
know that the directory must be created before the file is copied.
We can solve this with dependsOn, triggers, or (
usually the better option) the parent property. Each resource
can have a single parent. Pulumi will then create the child
after the parent, delete the child before the parent, and recreate the child
when the parent is replaced. That behaviour is extremely helpful when you
want to ensure, for example, that all files are recopied whenever a
container is replaced.
Consider the following code:
import { remote } from "@pulumi/command";
import * as pulumi from "@pulumi/pulumi";
import { dirname } from "node:path";
const remotePath = "/tmp/my-folder/my-file";
const connection = {}; // Omitted for simplicity
const createContainerFolder = new remote.Command('create-container-folder', {
connection,
create: `mkdir -p ${dirname(remotePath)}`,
})
export const remoteFile = new remote.CopyToRemote(
`my-file`,
{
connection,
source: new pulumi.asset.StringAsset("Hello, world!"),
remotePath,
},
{ parent: createContainerFolder },
);Code language: TypeScript (typescript)
This integrates nicely with Pulumi: the remote file is visible in preview mode, is copied only after the container folder is created, and is removed before the command that creates the folder is deleted. The preview also shows the remote file nested under that resource, which makes the dependency obvious when you inspect the plan.
However, this approach has some limitations. Each resource can have at most a single parent, but there are many situations where a resource depends on multiple others. There is also a subtler issue: changing a resource’s parent modifies its URN, which means Pulumi cannot update it in-place — it must destroy and recreate it instead. That is acceptable for some resources, but I ran into many situations where I did not want to risk losing important data by destroying the original.
My recommendations
Prefer parent over dependsOn or
triggers when you want to express a clear, structural
dependency between resources and have that relationship reflected in
previews and replacements. Just be aware that changing a resource’s parent
will trigger a destroy-and-recreate cycle rather than an in-place update.
No replies on “Rookie mistakes I made with Pulumi dependency tracking”