← Back to Patterns

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.

By Ivan Richter LinkedIn

Last updated: Sep 1, 2026

4 min read

On this page

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.ts

Each 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:

  1. What exists in this environment?
  2. Which important policies differ here?
  3. 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 all

Related patterns