Engineering13 min read

Pulumi on GCP at organisation scale: the parts nobody writes about

Terraform owns the landing-zone literature and Pulumi does not, so the GCP-at-org-scale questions go unanswered. Factory components, state layout, ephemeral per-PR stacks, and the mistake I made first.

There is a lot written about landing zones on Google Cloud. Almost all of it is Terraform.

That is not a complaint about Terraform — it earned that literature. But it means that if you are building a GCP organisation in Pulumi, the questions that actually matter at that scale have no answers to copy. Search for “Pulumi GCP” and you get the provider registry and a hundred variations of “here is how to create a bucket”. Search for how to lay out state across an organisation, where component boundaries should fall, or how to give twenty teams a service that is correct by default, and the results thin out to nothing.

I have built this twice now. Here is what I got wrong and what I would do again.

First: why Pulumi at all, if not “we know TypeScript”

That answer is the weakest one, and it is the one everybody gives. Knowing a language is a reason not to be slowed down; it is not a reason to change tools. Four things are, and all four only start mattering at organisation scale — which is exactly why the single-resource examples never surface them.

You can derive a narrow interface from a wide one. A Terraform module’s interface is its variable list, and that list only grows. A component is a class, so a specialised component can extend a general one and expose three arguments instead of thirty. This is the one everything else follows from.

You can assert on the resource graph in your own test runner. setMocks() gives you the graph a component produces, in-process, with no cloud. “No component anywhere creates a service-account key” becomes a test rather than a review convention. Terraform can do mocked tests too since 1.7 — the difference is ergonomic, and it is the difference between writing two of these and writing twenty.

You can create and destroy stacks from code. The Automation API makes per-PR environments a program rather than a pile of CI YAML parsing CLI output.

You can use the rest of your language. Not string interpolation — actual data structures, npm packages, and the shared library your services already import, so the platform code and the product code cannot drift in their idea of what a service is called.

What is NOT on that list matters just as much. Not validation: Terraform has had variable validation since 0.13, precondition/postcondition since 1.2 and check blocks since 1.5, and they are good. Not the ecosystem, which Terraform wins comfortably. Not hiring. If none of the four above is a live problem for you, Terraform is the lower-risk answer and I would not try to talk you out of it.

The mistake: writing Terraform in TypeScript

The first landing zone I built in Pulumi was, structurally, a Terraform configuration that happened to compile.

I had resources grouped in files by type. I had a big configuration object threaded through everything. Where Terraform would have had a module with twenty-odd variables, I had a function taking an options interface with twenty-odd fields. It worked. It was also pointless — I had taken on Pulumi’s costs (a language runtime, a dependency tree, a team that has to read TypeScript) and collected none of its benefits.

The realisation, which took longer than it should have: a Pulumi program is a program. The leverage is not that you can template resources more expressively. It is that you can build abstractions — real ones, with types, defaults, invariants and tests — and then the thing a team consumes is not a module with forty knobs but a component with three.

Once I stopped translating and started designing, the shape changed completely.

Component boundaries: what a service actually is

The unit that matters is not “a Cloud Run service”. It is everything that has to exist for a Cloud Run service to be operable, which is considerably more:

  • The service itself.
  • A dedicated service account, with only the permissions this workload uses.
  • Its secrets, and the IAM bindings that let only this identity read them.
  • Log-based metrics and the alerting policies over them.
  • A budget or spend guard scoped to it.
  • The uptime check, if it serves traffic.
  • The DNS or load-balancer binding, if it is public.

In Terraform that is a module. In Pulumi it is a ComponentResource. Both group resources, both take inputs, both produce outputs — and if that were the whole story there would be no reason to switch.

The difference is composition. A Terraform module’s only interface is its variable list, and that list only ever grows: every new case the module has to serve adds another variable, and the only way to offer a narrower interface is to write a second module that wraps the first. A ComponentResource is a class — Pulumi’s own words are “You author a component by extending the ComponentResource class” — and once something is an ordinary class, the language’s ordinary tools apply. A base component does the general thing; a subclass fixes most of its arguments and exposes three. Inheritance, generics, composition. The narrow interface is derived from the wide one rather than duplicated alongside it.

That is the structural difference. Everything below follows from it.

The properties I would insist on in any factory component:

Correct by default, not correct by configuration. A team asking for a service should get observability, a least-privilege identity, a spend guard and secret wiring without asking for them and without knowing they exist. The moment the right thing requires remembering to pass enableMonitoring: true, you have built documentation rather than a platform.

Escape hatches that are visible. Every abstraction is wrong for someone eventually. The answer is not to add another boolean — it is to let the component expose its underlying resources so a caller can reach past it, and to make reaching past it obvious in review. An abstraction with no escape hatch gets forked; an abstraction with an invisible one gets abused.

Invariants enforced at synthesis. If a service must never be deployed without a spend ceiling, the component refuses to construct. I originally wrote that this is something you cannot do in HCL, and that is wrong — Terraform has variable validation, precondition and postcondition, and check blocks, and they are good. The honest difference is narrower: a Terraform validation checks a value, whereas a constructor can enforce a relationship between several inputs, consult something it derived earlier, or refuse based on a rule expressed as ordinary code. You reach the ceiling of validation blocks considerably sooner than the ceiling of a constructor. Both beat a policy check in CI that someone can skip.

Tests, and this is the one I would not give up. pulumi.runtime.setMocks() replaces the engine with in-process mocks, so you can construct a component and assert on the resource graph it produces without touching a cloud. Pulumi’s own framing: “Because mocks don’t do any real work, unit tests run fast. They’re also deterministic, since nothing about them depends on the behavior of an external system.” “Every service gets a dedicated service account”, “no component anywhere creates a service-account key”, “the alert policy references the metric this component created” — these are ordinary unit tests, in the same vitest run as everything else, and they fail in the editor.

I want to be careful here, because the obvious version of this claim is out of date. terraform test defaults to command = apply and creates real infrastructure — but command = plan avoids that, and Terraform 1.7 added mock_provider, so mocked, infrastructure-free tests are entirely possible in HCL now. The remaining difference is ergonomic rather than categorical: Pulumi’s tests are ordinary tests in your language’s own runner, sharing helpers and fixtures with the rest of your code, while Terraform’s run through its own test harness in its own file format. That matters for whether you write twelve of them or two, not for whether you can.

State layout: the blast radius is the design

The question nobody answers, and the one that hurts most at organisation scale.

First, the term, because I used it above without defining it. A stack’s blast radius is the set of resources a single pulumi up is able to change or destroy. Not what it will change today — what it could, if a refactor went wrong, a provider upgraded, or someone deleted the wrong twenty lines. Every resource in the stack is inside it; everything in another stack is not.

That makes blast radius the thing you are actually choosing when you draw a stack boundary, and the rule that falls out of it is simple: match the blast radius to how often the thing changes. Code that changes daily should sit in a stack that can only break a little. Code that could break everything should sit in a stack that nobody touches without a reason. Getting this backwards is how you end up with a pulumi up so frightening that people stop running it — and once they are clicking in the console instead, your state no longer describes reality and the whole exercise has failed.

The naive layout is one stack per environment: dev, staging, prod. It works right up until pulumi up on prod has to plan four hundred resources to change one alert threshold, at which point people stop running it, and then they start clicking in the console, and then your state does not describe reality any more.

One stack per environment per concern is the layout I would use again. Concretely:

  • Organisation. Folders, org policies, the projects themselves, billing association. Changes rarely, reviewed carefully, blast radius enormous.
  • Platform, per environment. Shared networking, the observability project, shared secrets, the artefact registry. Changes occasionally.
  • Workloads, per environment. The services. Changes constantly.

Three layers, each with a pulumi up whose blast radius matches how often it runs. The workloads stack changes daily and can only break workloads. The organisation stack changes quarterly and everybody reviews it.

They connect through stack references rather than shared configuration. The workloads stack reads the network and the project ID from the platform stack’s outputs. This is better than a shared config file in a way that is not obvious until you have been bitten: a stack reference is a real dependency, so you cannot delete an output that something consumes without the consumer failing loudly. A config value you can delete, and find out later.

Two things I would tell myself earlier:

Be deliberate about what you export. Every output is API surface. Exporting a whole resource because it is convenient means you can never restructure it. Export the identifiers and the URLs, not the objects.

Put state where you can audit it, and lock it. GCS backend, versioning on, in a project separate from anything it describes. Not because Pulumi Cloud is bad — it is good — but because on a GCP estate the fewer identity boundaries you cross the fewer things you have to explain when someone asks who can read your state, and state contains more than people expect.

Ephemeral per-PR stacks

This is the feature that changes how a team works, and the reason I would pick Pulumi again.

Because a stack is a first-class thing with a name, and because your program is a program, you can create a full environment per pull request: pr-482, with its own project or its own namespaced resources, spun up when the PR opens and destroyed when it merges.

The mechanism that makes this pleasant rather than a pile of shell is the Automation API — “a programmatic interface for running Pulumi programs without the Pulumi CLI”, which encapsulates up, preview, destroy and stack init as function calls. Your CI step is a TypeScript file that creates a stack, runs it, and reports the URL, with real error handling and real types, rather than a YAML block parsing CLI output. Pulumi documents this for integration testing and CI/CD; per-PR environments are a thing you build with it rather than a feature it ships. Not a shared “dev” environment that three people are fighting over. A real one, per change.

The effect is that “does this actually work” stops being a question you answer after merge. The engineer who wrote the change gets a URL.

What makes it survivable is not clever naming — it is destruction, and specifically destruction that does not depend on the pull request behaving. The obvious implementation destroys the stack when the PR closes. The obvious implementation leaks, because CI dies halfway, PRs get force-closed, someone renames a branch. What you need is a scheduled job that destroys any sandbox stack older than a threshold regardless of what the PR did, and it should not be polite about it.

And the spend guard belongs in the factory component, not in the convention. If every service created in a sandbox context gets a hard budget by construction, the worst case is a bill you can absorb. If it is a convention, the worst case is discovered on the invoice.

The two things that are genuinely awkward, so you know before you start: anything with a slow create-and-destroy cycle (managed databases, some load-balancer components) makes per-PR environments painful, and you will end up sharing those and namespacing within them. And per-PR quota is a real constraint on GCP — project creation is rate-limited, and you will hit it before you expect to.

What I would tell someone starting

Do not port your Terraform. I know. But a translation gives you Pulumi’s costs and Terraform’s design, which is the worst available combination. Build the component you will use twenty times, get its defaults right, and grow the estate into it.

Split state by blast radius before you need to. It is a cheap decision now and an expensive migration later.

And write the tests. Not for coverage — for the invariants. “No component ever creates a service-account key” is the kind of rule that is a paragraph in a README everywhere else, and an assertion that runs in eight milliseconds here. That difference is most of why I keep choosing this.


This is what worked for me on two estates of a particular shape — a few dozen services, a small team, GCP-native, TypeScript already in the building. It is not a universal argument for Pulumi. If your team is happy in HCL and your estate is mostly plumbing, Terraform is the lower-risk answer and I would not try to talk you out of it.

FAQ

Should I use Pulumi or Terraform on GCP?

If your team is comfortable in HCL and your estate is mostly declarative plumbing, Terraform is the lower-risk answer and the literature is on your side. Pulumi earns its place when you need abstraction — when you are building the same shape of service twenty times and want that shape to be a typed component with tests rather than a module with forty variables. That is a real difference and it is the only one I would change tools for.

Does Pulumi's TypeScript SDK handle GCP org-level resources well?

Yes, and that is not the hard part. The provider coverage is fine. The hard part is that almost every published example is one resource in one stack, so there is very little guidance on how to lay out state, where component boundaries should fall, or how to keep a `pulumi up` from having an unacceptable blast radius. Those are design questions, not SDK questions, and they are the ones that bite at organisation scale.

How do you keep ephemeral per-PR stacks from costing a fortune?

Destroy them, aggressively, and make the destruction independent of the pull request being tidy. A scheduled job that destroys any sandbox stack older than a threshold catches the ones where CI died halfway. Put a hard spend guard in the factory component itself rather than trusting the convention, because the convention is what fails at 2am on a Friday.

#pulumi#gcp#infrastructure-as-code#landing-zone#typescript