How we decide between directory per environment and shared stacks in Pulumi
We do not force DRY across environments by default. We keep Pulumi environments separate until shared code, shared rules, and drift risk make consolidation cheaper than duplication.
On this page
The default
We don’t force DRY across environments by default.
If there are only a few environments, the differences between them are real, and the cost of duplication is low, we usually keep them separate. That often means a directory or project per environment, with explicit code and very little shared logic across them. Not because abstraction is bad. Not because copying structure around is somehow virtuous. It’s because cross-environment abstraction has a cost, and that cost usually shows up before teams admit it’s there.
A lot of bad environment modeling starts with the same idea. One clean program. One shared set of components. One place to make changes. It sounds efficient until the environments stop being meaningfully identical. Then the shared model starts absorbing conditionals, exceptions, feature flags, and “only in prod” behavior. The code is still DRY on paper, but the system gets harder to read and easier to misread.
So the default is simple. We keep environments separate until keeping them separate is more painful than the abstraction needed to unify them.
Why we often keep environments separate
Separate environments are usually the cheaper way to work early on.
If there are only a few of them and the system is still small, a directory-per-environment structure keeps differences obvious. You can open the environment and see what it is. Review stays local. Blast radius stays readable. Nobody has to mentally run branching logic just to answer basic questions about what exists where.
That matters more than DRY in the early stages. A bit of duplication is often cheaper than a shared program that hides behavior behind stack config, naming rules, and environment-specific branches. The cost of copying is visible. The cost of abstraction usually isn’t. It shows up later as hesitant edits, subtle drift, and code nobody wants to touch because a change in one place might quietly affect three environments no one was thinking about.
Low-cost systems make this even easier to justify. On a small setup, trying to maintain full dev, stg, and prd versions of every database, service, data path, and supporting component can run up cloud costs faster than people expect. Even when each environment is minimized, you’re still paying for more instances, more storage, more network paths, more monitoring surface, more secrets, more drift potential, and more things that need to stay vaguely healthy. If the system is still small and still changing shape, we’d rather pay a little duplication in code than pay for environment architecture the system hasn’t earned yet.
DRY is not the main decision rule
DRY is useful. It just isn’t the first question here.
Across environments, the first question is not whether something can be shared. The first question is whether sharing it makes the system easier to operate. Teams blur those two questions all the time. The same test works inside a stack when deciding whether repeated Pulumi code has actually earned abstraction.
A shared abstraction is only worth it if it reduces maintenance work without making environment behavior harder to see. If the result is a cleaner repo and a more confusing deployment model, the abstraction isn’t helping. It’s just moving complexity out of sight.
That’s why we don’t treat duplication across environments as a failure by itself. Sometimes it’s simply the most honest way to model real differences. If staging behaves differently from production, the code should sometimes look different too.
Where shared stacks start paying off
Shared stacks start making sense when the environments are still meant to behave similarly and the cost of keeping them separate starts showing up in real work.
Usually that means more environments, more repeated patterns, more defaults that need to stay aligned, and more changes that should not be made in four places by hand. At that point, separate directories stop being explicit and start being repetitive. Review turns into file comparison. Drift becomes more expensive than the logic needed to prevent it.
This is where preferring Pulumi starts paying for itself in a more specific way. Once that tool choice is already made, we can use normal code to centralize the parts that really should stay aligned. Naming. IAM patterns. Environment wiring. Service defaults. Shared components. The reuse is direct instead of being faked through templating, conventions, or team memory.
That’s when a shared program with stacks starts earning its keep. Not when it looks cleaner in a diagram. When it cuts maintenance work, review overhead, and drift.
The cost of conditionals
Pulumi makes it easy to consolidate environments into one shared program. That doesn’t mean one shared program is automatically a good idea.
Conditionals have a cost. Once enough of them pile up, the stack stops reading like infrastructure and starts reading like a branching puzzle. A resource only exists in one environment. Another changes shape in production. A third uses a different policy in dev because somebody needed a shortcut six months ago. The code still runs, but now every review depends on remembering which branches are active in which stack.
That’s not an argument against shared stacks. It’s an argument against pretending they stay simple by default. Shared code is useful when it keeps the common parts common and the differences legible. It becomes a problem when too many real differences get compressed into logic that only makes sense if you already know the history behind it.
A shared Pulumi program shouldn’t feel clever. It should feel boring, predictable, and easy to reason about. If it doesn’t, the abstraction has probably gone too far.
What we’re optimizing for
We’re looking for the cheapest representation that keeps environment differences obvious and change safe.
Sometimes that’s duplication. Sometimes that’s shared code. The goal isn’t purity in either direction. The goal is to keep the system legible while it changes.
Environment behavior should be easy to inspect, review, and explain. Defaults should stay centralized once centralization actually saves work. Differences should be explicit when they’re real. Shared logic should exist where it reduces coordination cost, not where it just makes the repo look tidier.
We duplicate across environments until the duplication costs more than the abstraction.
When we use a directory per environment in Pulumi
We usually keep environments separate when the number of environments is low, the infra cost is low, the differences between them are real, and the pace of shared change is still modest.
That’s often the better choice for early systems, low-risk internal setups, small public systems, and situations where the team benefits more from explicit structure than from centralization. In those cases, a directory per environment isn’t laziness. It’s often the clearer and cheaper representation, especially when the alternative is maintaining extra environment copies of databases, services, and data flows just to preserve a cleaner abstraction story.
For small systems, environment sprawl can become a bigger cost problem than code duplication.
You can consolidate later. It’s much easier to unify explicit environments once the common shape is real than it is to unwind a shared program that started abstracting differences too early.
When we consolidate into shared stacks
We consolidate when the environments are mostly the same, the common logic is real, and the cost of keeping them separate starts showing up as drift, repeated implementation work, or wasted review effort.
Usually that means the system has grown enough that alignment matters more than local explicitness. The same defaults need to hold everywhere. The same components need to be instantiated repeatedly. The same wiring and policy rules need to stay consistent. The team needs to change shared behavior once, not four times and hope nothing was missed.
That’s when shared stacks become the better model. Not because DRY won an argument. Because keeping the environments separate has become the more expensive option.
The decision rule
We don’t separate environments just to avoid abstraction, and we don’t consolidate them just to satisfy DRY. We choose the representation that makes environment behavior easiest to understand and cheapest to maintain.
If the environments are few, low-cost, and meaningfully different, we usually keep them separate. If they’re structurally similar and drift is getting expensive, we consolidate and use shared Pulumi code.
More in this domain: Infrastructure
Browse allHow we decide between Cloud SQL connectors, Auth Proxy, and private IP
Cloud SQL connectors, the Auth Proxy, and private IP are not interchangeable secure connection options. They change identity, routing, deployment shape, and how much network plumbing the team actually owns.
IAM DB auth for Cloud SQL: when it simplifies security and when it complicates delivery
IAM DB auth can reduce password sprawl and make revocation cleaner, but it also turns database access into an identity operating model that depends on disciplined service-account boundaries.
Safe scaling defaults for Cloud Run + Postgres
Cloud Run autoscaling is not a database strategy. Safe defaults keep the application from scaling itself into a Postgres incident before the team understands the workload.
Cloud Run request timeouts don't kill your code (so your architecture has to)
A Cloud Run request timeout ends the request, not necessarily the work. If the operation can outlive its caller, the system needs explicit job semantics instead of hope.
Cloud Run scaling from zero is a feature until it isn't
Scale to zero is a good default for request-driven services, until startup delay, warm-capacity needs, or instance caps turn it into user-visible reliability behavior instead of a pricing feature.
Related patterns
When repeated Pulumi code earns abstraction and when it doesn't
We don't abstract repeated Pulumi code just because it shows up more than once. We do it when the shared shape is real, the behavior is stable enough to deserve a boundary, and the result is easier to read than the duplication it replaces.
Why we usually choose Pulumi over Terraform
Pulumi is our default when infrastructure starts behaving like software. Existing Terraform estates can still be the better decision when the migration cost is higher than the operational gain.
How we structure a directory per environment in Pulumi
When we keep Pulumi environments separate, we make the environment boundary obvious in the filesystem and keep shared logic outside it.
What goes in Pulumi stack config and what doesn't
We use Pulumi stack config for environment-specific values, not as a hiding place for infrastructure logic.