flowchart TD
A[Declarative provisioning] --> B{Provisioning<br/>from inside an app?}
B -- yes --> P[Pulumi: Automation API]
B -- no --> C{Want unit tests<br/>on infra logic?}
C -- yes --> P
C -- no --> D{Team already<br/>fluent in HCL?}
D -- yes --> T[Terraform / OpenTofu]
D -- no --> E[Either. Pick for ecosystem size]
Pulumi vs Terraform: Two Genuine Differences, and a Lot of Parity
The home lab is provisioned with Terraform and configured with Ansible, and that split still holds. This is the other half of the question: having picked declarative provisioning, does the language it is written in matter?
Short version: Pulumi is Terraform’s model with a real programming language in front of it. Declarative resource graph, provider plugins, state file, preview and apply. You write Python, TypeScript, Go, C# or Java instead of HCL.
Most providers are auto-bridged from the Terraform providers, so the resource surface and schema are near-identical, warts included. Error messages occasionally leak HCL-isms, which tells you exactly how thin the layer is.
The shape of it
import pulumi
import pulumi_aws as aws
bucket = aws.s3.BucketV2("assets")
aws.s3.BucketVersioningV2("assets-versioning",
bucket=bucket.id,
versioning_configuration={"status": "Enabled"})
pulumi.export("bucket_name", bucket.id)You write a program; running it registers resources with the engine over gRPC. The engine diffs the desired graph against state and calls the providers. Stacks are environments, each with its own state and config file, and pulumi config set --secret encrypts per stack.
If that looks like Terraform with different syntax, it is, and that is the point. The differences are not in the model.
The thing that will trip you up: Output[T]
Resource properties are not values. They are Output[T]: a future that also carries dependency edges and a secret flag. You cannot branch on one or interpolate it into an f-string.
# wrong - Output is not a str
url = f"https://{bucket.bucket_domain_name}"
# right
url = pulumi.Output.concat("https://", bucket.bucket_domain_name)
# multiple inputs
conn = pulumi.Output.all(db.address, db.port).apply(
lambda a: f"postgres://{a[0]}:{a[1]}/app")During preview, unresolved values are unknown, so anything computed inside .apply() is invisible in the plan. Creating resources inside an apply is worse again: you lose preview fidelity and dependency clarity.
This is the single biggest source of Pulumi frustration, and it is the tax for having a real language. HCL avoids it by not letting you write the expression in the first place.
What is genuinely better
The Automation API. Embed the engine as a library and drive stacks programmatically, with no CLI and no shelling out.
from pulumi import automation as auto
stack = auto.create_or_select_stack(
stack_name=f"tenant-{clinic_id}",
project_name="tenant-infra",
program=lambda: define_tenant(clinic_id))
up = stack.up(on_output=print)For a SaaS that provisions per-tenant infrastructure, this is the feature. Per-tenant stacks driven from a management command or a background task, with real error handling, is a different class of thing from templating HCL and shelling out to terraform apply.
Testing. Mock the provider layer and unit-test your infrastructure logic: assert on tags, naming conventions, policy compliance. Terraform has nothing comparable that does not involve a plan round-trip.
Components. ComponentResource subclasses are real classes with constructors, inheritance and typed arguments, and they can be packaged and consumed from another language. Substantially better than modules for abstraction.
Loops and conditionals. No count or for_each gymnastics. You write a loop.
What is at parity, or worse
State backends work the same way: Pulumi Cloud by default, but s3://, gs://, azblob:// and file:// all work. Self-hosting costs you drift detection, RBAC, deployments and the console, which is a real trade rather than a free one.
The ecosystem is smaller. When the bridged provider misbehaves, there are fewer people who have hit it before you, and the search results are about the Terraform provider underneath.
Choosing
For a home lab, Terraform remains the right call: the ecosystem is bigger, the examples are everywhere, and there is no application driving provisioning. The Automation API argument only starts paying once infrastructure is something your product creates rather than something you create for your product.
Takeaway
Do not switch for the language. HCL is limited, but the limits are also what keeps a plan readable, and Output[T] gives back much of the awkwardness you thought you were escaping.
Switch for the Automation API if you provision from inside an application, or for testability if your infrastructure logic has enough branching to be worth asserting on. Otherwise this is a preference, not an upgrade. Fuller notes are in Web Development.