Ulzurrun de Asanza i Sàez

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.

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:

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

There are no comments yet.

Leave a Reply

Your email address will not be published.

Required fields are marked *

Your avatar