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.
When an environment has its own Pulumi directory, that directory should be the place where its deployed shape is decided.
Shared modules can implement common resources. Stack config can provide values. Neither should make the environment entry point decorative.
Give each environment one obvious entry point
A practical layout is:
infra/
components/
cloud-run-service.ts
postgres.ts
project-iam.ts
naming.ts
environments/
dev/
Pulumi.yaml
Pulumi.dev.yaml
index.ts
stg/
Pulumi.yaml
Pulumi.stg.yaml
index.ts
prd/
Pulumi.yaml
Pulumi.prd.yaml
index.tsEach environment is a Pulumi project or otherwise has an unambiguous program and stack mapping. A command run from environments/prd shouldn’t depend on a hidden current-directory convention to decide whether it targets production.
The entry point declares the environment’s resources and major relationships. It should make production-only recovery, networking, protection, and integrations visible near the call sites.
const database = createPostgres('app', {
projectId,
region,
tier: config.require('databaseTier'),
deletionProtection: true,
backupRetentionDays: 30,
});
createAppService('web', {
projectId,
region,
database,
minInstances: 1,
publicIngress: true,
});A reviewer can see the policy without opening a generic component and discovering that it infers production from the stack name.
Share mechanics, not environment identity
Components should own repeatable resource mechanics and stable invariants: naming, service-account wiring, labels, log configuration, database attachment, or an approved IAM shape.
They shouldn’t inspect pulumi.getStack() and decide topology internally. Pass important behavior explicitly from the environment entry point.
A healthy component has a narrow contract:
createAppService(name, {
projectId,
region,
serviceAccount,
image,
minInstances,
maxInstances,
ingress,
});An unhealthy one accepts environment: "prod" and internally chooses twenty unrelated policies. The caller is shorter because the component has become a private platform nobody can review from the call site.
Keep stack config limited to values
Use stack config for region, project ID, domain, CIDR, size, counts, retention, and references to externally owned resources.
Keep structural behavior in the entry point. If production has a replica and development doesn’t, the code should show that decision. A value such as replica count can remain config when the resource pattern is common and zero is a supported, obvious state.
Validate config at startup and fail with a precise message. Don’t allow missing production values to fall through to development defaults.
Keep secrets in their owning system
Pulumi can encrypt secret configuration, but capability doesn’t settle ownership.
Use stack secrets for inputs that genuinely belong to the deployment and have no better lifecycle. Reference Secret Manager resources for application credentials already created, rotated, audited, and consumed there. Avoid reading secret payloads into Pulumi state when the infrastructure only needs to grant access to the secret.
A resource identifier belongs in config. The credential value often doesn’t.
Make cross-environment changes explicit
A shared component change affects every environment that imports it. CI should type-check and preview each one, even though the directories are separate.
An environment-local change should preview only that state boundary unless shared dependencies changed. This gives reviewers a useful signal about blast radius.
Keep provider and component versions aligned through workspace tooling or automated checks. Directory separation should expose intentional infrastructure variance, not let dependency versions decay independently.
Avoid copy-pasting low-level resource mechanics
Duplication at the environment decision layer can be honest. Copying an entire Cloud Run implementation into three entry points is not.
Extract a component after the repeated responsibility stabilizes, and keep important policy inputs visible. Aim for one implementation of mechanics and explicit declarations of environment policy rather than zero repeated lines.
Repeated Pulumi code earns abstraction when the shared boundary improves review, not when an editor shows the same block twice.
Name outputs and references deliberately
Export only values another stack, deployment, or operator genuinely consumes. Prefer stable resource identifiers over broad object exports. Document cross-stack references because they introduce ordering and state dependencies between otherwise separate environments or platform areas.
Don’t use cross-stack outputs to quietly reconnect environments that were separated for ownership. Shared resources such as DNS zones or artifact registries often deserve their own state rather than being owned by whichever environment happened to create them first.
Preserve resource identity during refactors
Moving code into or out of a component can change Pulumi resource paths. Use aliases and controlled state operations to prevent replacement. Preview every environment separately and reject unexpected deletes or creates.
A filesystem cleanup that recreates a production database isn’t cleaner infrastructure. It’s a naming mistake with a very expensive demonstration.
Check the structure against three questions
A maintainer should be able to open one directory and answer:
- What exists in this environment?
- Which important policies differ here?
- Which shared components implement the common mechanics?
If those answers require tracing stack-name branches through the shared layer, use a shared program instead or restore the decisions to the entry point. A directory per environment is worthwhile only while the filesystem boundary remains real.
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
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.
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.
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.
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.