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.
Use separate environment programs while differences are important enough to review directly. Consolidate into shared stacks when the common shape is stable and duplicate change has become the larger risk.
Review visibility matters more than DRY. Choose the structure that exposes environment behavior with the least hidden state.
Separate directories preserve local truth
A directory per environment gives dev, stg, and prd their own entry points. A reviewer can open production and see what it creates, how it’s protected, and which integrations exist there without evaluating conditionals for every other stack.
This is useful when environments are intentionally asymmetric:
- development omits expensive persistent services
- staging exists only for selected products
- production has stricter recovery, deletion, or networking policy
- one environment integrates with a real external system while another uses a stub
- environments are created or retired independently
Some duplication is the price of making those differences explicit. For a small estate, that price is often lower than a generalized program filled with if (stack === 'prod') branches and configuration switches.
Separate code also narrows blast radius. A production change can remain a production change instead of modifying a shared function consumed by every stack.
Shared stacks preserve invariants
One Pulumi program with several stacks becomes useful when environments are meant to instantiate the same platform shape with different values.
The common model may include:
- the same service and identity relationships
- the same networking topology
- the same observability and policy baseline
- the same database lifecycle
- the same deployment sequence
- environment variance limited mostly to size, names, domains, retention, and counts
At that point, separate programs create another risk: one fix lands in production but not staging, an IAM rule diverges, or a new resource is copied with slightly different defaults. Review becomes manual comparison across directories.
Shared stacks centralize the invariant. Stack config supplies the values. A change to the platform shape is made once and previewed against every affected stack.
Count semantic branches, not repeated lines
A shared program is healthy when differences can be stated as a small set of typed inputs. It’s unhealthy when config and code jointly form an environment rule engine.
Watch for:
- Boolean options that create materially different topologies
- resources present only in one stack through scattered conditionals
- stack names used deep inside shared components
- defaults that silently weaken non-production environments
- arrays of overrides whose meaning depends on caller history
- a preview that can’t be understood without knowing every branch
The line count may be lower while the cognitive load is higher. That’s compression, not simplification.
Likewise, separate directories are failing when every meaningful change must be copied into several files and reviewers regularly miss one. Visible duplication has stopped buying local clarity and started manufacturing drift.
Cloud cost affects the environment model
Don’t create full infrastructure parity solely because shared-stack code makes it easy.
A small company may need a production database, ephemeral development resources, and no permanent staging database. The environment contract should reflect the testing and release requirement, not symmetry for its own sake.
Code architecture and deployed topology are separate decisions. Shared modules can support an intentionally smaller development program. A shared stack can take a size of zero only when zero remains a legible, supported state rather than a forest of skipped resources.
Repository symmetry isn’t worth paying for infrastructure the release process doesn’t need.
Migration in either direction should be mechanical
To consolidate separate directories:
- identify behavior that is genuinely invariant
- extract small shared components without changing resources
- align naming and configuration contracts
- move one environment into the shared program
- verify previews preserve resource identity
- move the remaining environments one at a time
Don’t combine consolidation with broad resource replacement. Use aliases, imports, or state moves where identities change.
To split a shared program, first isolate stack-specific branches into named boundaries. Create separate entry points that call the same components, then allow them to diverge deliberately. Preserve state ownership during the move.
The infrastructure result should remain stable while the code representation changes.
Review every environment affected by shared code
A shared component change has a multi-stack blast radius even when the pull request was motivated by one environment. CI should compile and preview every relevant stack, summarize replacements and deletes, and make stack-specific differences visible.
Separate directories need a different guardrail: automation that detects versions or policies intended to stay aligned. Explicit code doesn’t excuse accidental drift.
How we choose
Choose a directory per environment when there are few environments, their topologies differ meaningfully, and local review matters more than centralization.
Choose shared stacks when environments implement the same platform contract, common changes are frequent, and duplicate maintenance creates measurable drift or review cost.
Revisit the choice when branches or copying start dominating changes. The representation should follow the real invariant. DRY code is useful only after the environments have agreed on what they are.
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.
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.
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.
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.