Structuring Pulumi Stacks per Environment

Structuring one Pulumi stack per environment keeps dev, staging, and prod in a single codebase while giving each its own configuration, state file, and blast radius. This task is part of the broader Pulumi Stack Architecture guidance within Pulumi Patterns & Provider Management, and it shows how stack naming, Pulumi.<stack>.yaml config, shared component code, and StackReference combine so that promoting a change from dev to prod is a config switch rather than a code fork.

Why per-environment stacks matter

The alternative — copying a project per environment, or branching code to deploy prod — guarantees drift. The dev and prod definitions diverge, a fix lands in one but not the other, and the differences hide in source control rather than in declared configuration. A single program parameterized by stack keeps the resource graph identical across environments and confines every difference (instance sizes, CIDR ranges, replica counts) to typed configuration. The state-level isolation that backs this — separate state files, locking, and encryption per environment — is covered in Managing IaC State for Python Projects.

It helps to be concrete about what a stack actually is, because the word carries more weight than it looks. A stack is an instance of a project: the pair <project>/<stack> identifies exactly one state file, and on a managed backend the fully-qualified <org>/<project>/<stack> triple is the addressable name. Everything environment-shaped hangs off that identity — the config file Pulumi.<stack>.yaml, the encryption key that protects its secrets, the resource URNs recorded in its checkpoint, and the lock taken while an update runs. Two stacks of the same project share nothing except the Python source they both execute.

That last point is the one worth internalising. The program is a function; the stack is its argument. pulumi up --stack dev and pulumi up --stack prod execute identical bytecode against different configuration and different state, which is what makes a promotion reviewable: the diff between environments is entirely visible in two YAML files rather than distributed across branches. It also sets the boundary of what per-environment config can express. A value that changes the shape of the resource graph — an if env == "prod" that adds a read replica — is legal Python but reintroduces the divergence you split the config out to avoid, because the prod path is now untested by every dev deploy.

Why per environment stacks matter Why per environment stacks matter: Why per environment st with 4 facets. Why per environment st Definition typed Python Provider cloud API State recorded facts Outcome reproducible infra
Why per environment stacks matter: how Definition, Provider, State relate in this pattern.

Prerequisites

Prerequisites Prerequisites: layered from PATH down to AWS. PATH prod dev Python AWS
Prerequisites: the building blocks this section assembles.
  • Python 3.9+ and pulumi >= 3.0 with the CLI on your PATH (pulumi version).
  • pulumi-aws >= 6.0 pinned in your lockfile (the example uses AWS, but the pattern is provider-agnostic).
  • A configured state backend (Pulumi Cloud or self-managed S3/GCS) reachable from CI.
  • IAM credentials or OIDC federation scoped so that the prod stack cannot be deployed with dev permissions.

Implementation

1. Create one stack per environment

Implementation Implementation: 1. Create one stack then 2. Read config into then 3. Drive shared then 4. Share data 1. Create onestack 2. Read configinto 3. Drive shared 4. Share data
Implementation: the stages run left to right — 1. Create one stack, 2. Read config into, 3. Drive shared, 4. Share data.

Each pulumi stack init creates an isolated state file and a matching Pulumi.<stack>.yaml. Name stacks consistently so pipelines can map a branch or input to a stack.

# CLI: run once per environment from the project root
pulumi stack init dev
pulumi stack init staging
pulumi stack init prod
# State implication: each command creates a separate state file; they never share resources.
pulumi stack ls

Set environment-specific values into each stack's config file rather than into code:

# CLI: writes into Pulumi.dev.yaml / Pulumi.prod.yaml respectively
pulumi config set aws:region us-east-1 --stack dev
pulumi config set network:cidr 10.10.0.0/16 --stack dev
pulumi config set network:cidr 10.0.0.0/16  --stack prod
pulumi config set --secret db:password "$(openssl rand -base64 24)" --stack prod
# Provider note: --secret encrypts the value in Pulumi.prod.yaml using the backend's key.

Two details in those commands repay attention. The network: prefix is a config namespace, not decoration: pulumi.Config("network") reads keys under it, while an unprefixed key belongs to the project's own namespace. Provider settings such as aws:region use the provider's namespace, which is why they configure the provider without any code reading them. Keeping your own values in a named namespace stops a future provider key from colliding with one of yours.

The second is the secrets provider, which is chosen at stack init time and cannot be changed afterwards without re-encrypting every secret:

# CLI: give production its own KMS key rather than the default passphrase
pulumi stack init prod \
  --secrets-provider "awskms://alias/pulumi-prod?region=us-east-1"
# State implication: the chosen provider is recorded in Pulumi.prod.yaml as
# encryptedkey/secretsprovider; changing it later requires `pulumi stack
# change-secrets-provider` and rewrites every encrypted value in the file.

A per-environment key is worth the extra step for production, because it makes "who can decrypt prod secrets" an IAM question with an audit trail rather than a question about who has the passphrase.

2. Read config into a typed object

Resolve the stack's config once into a validated dataclass so the rest of the program is environment-agnostic. The current environment is always available through pulumi.get_stack().

# __main__.py
# CLI: pulumi up --stack dev
from dataclasses import dataclass
import pulumi


@dataclass(frozen=True)
class EnvConfig:
    environment: str
    cidr_block: str
    instance_type: str
    min_size: int


def load_env_config() -> EnvConfig:
    cfg = pulumi.Config()
    net = pulumi.Config("network")
    # State implication: get_stack() ties this run to one isolated state file.
    return EnvConfig(
        environment=pulumi.get_stack(),
        cidr_block=net.require("cidr"),
        instance_type=cfg.get("instanceType") or "t3.micro",
        min_size=cfg.get_int("minSize") or 1,
    )


ENV = load_env_config()

Loading once, at module top level, is deliberate. Every subsequent line of the program reads ENV, so a missing key fails before the first resource is registered rather than halfway through an update — and the failure carries Pulumi's own message, Missing required configuration variable 'network:cidr', followed by the exact pulumi config set command that fixes it. Scattering pulumi.Config() calls through the program loses that property and makes the environment contract impossible to read in one place.

Where a value has real structure — a list of CIDRs, a map of alarm thresholds — reach for require_object and validate it into a typed shape rather than parsing strings:

# config.py (continued): structured config with validation
# CLI: pulumi config set --path network:azs '["us-east-1a","us-east-1b"]' --stack prod
from dataclasses import dataclass
import pulumi


@dataclass(frozen=True)
class Scaling:
    min_size: int
    max_size: int

    def __post_init__(self) -> None:
        if self.max_size < self.min_size:
            raise ValueError(f"max_size {self.max_size} < min_size {self.min_size}")


def load_scaling() -> Scaling:
    cfg = pulumi.Config()
    # Provider note: require_object parses the YAML value; a type mismatch
    # raises pulumi.ConfigTypeError before any provider call is made.
    raw: dict[str, int] = cfg.require_object("scaling")
    return Scaling(min_size=raw["minSize"], max_size=raw["maxSize"])

The validation belongs here rather than in the cloud provider's error path. A max_size below min_size is rejected by the AWS API too, but only after the preview has run, the update has started, and several other resources have already been created.

3. Drive shared component code from the config

The program builds the same resource graph for every environment; only the typed config differs. Keeping resource definitions in a reusable component — see Building a Reusable VPC Component in Pulumi (Python) — means dev and prod cannot diverge in structure.

# __main__.py (continued)
# CLI: pulumi up --stack prod
import pulumi_aws as aws

vpc = aws.ec2.Vpc(
    f"{ENV.environment}-vpc",
    cidr_block=ENV.cidr_block,
    enable_dns_hostnames=True,
    # Provider note: tagging by stack makes drift detection and cost reports per-env.
    tags={"Name": f"{ENV.environment}-vpc", "Environment": ENV.environment},
)

asg = aws.autoscaling.Group(
    f"{ENV.environment}-asg",
    min_size=ENV.min_size,
    max_size=ENV.min_size * 3,
    vpc_zone_identifiers=[],  # populated from subnets created by the component
    launch_template=aws.autoscaling.GroupLaunchTemplateArgs(version="$Latest", id="lt-placeholder"),
)

pulumi.export("vpc_id", vpc.id)

The f"{ENV.environment}-vpc" prefix is a habit worth questioning rather than copying. Pulumi's logical name only has to be unique within the stack, and the stack is already per-environment, so the prefix buys nothing at the logical level — its real value is in the physical name Pulumi auto-generates, which embeds the logical name and makes a resource identifiable in the console without cross-referencing. What it costs is mobility: because the logical name is part of the URN, a resource named after the environment cannot be moved between stacks without an alias. Pick one convention and apply it uniformly before the first production apply.

The Environment tag is the more load-bearing of the two. Tags survive into cost reports, drift-detection queries and IAM conditions, so a tag applied consistently at the stack level is what lets you answer "what does staging cost" or "deny deletion of anything tagged prod" without maintaining a separate inventory.

4. Share data between environment-paired stacks with StackReference

When a prod-app stack needs outputs from a prod-network stack, resolve them by fully-qualified name rather than copying IDs. The mechanics and failure modes are detailed in Handling Pulumi Stack Outputs and Cross-Stack References in Python.

# app/__main__.py
# CLI: pulumi up --stack prod-app
import pulumi

env = pulumi.get_stack().split("-")[0]  # "prod" from "prod-app"
# State implication: reads the matching environment's network state read-only.
network = pulumi.StackReference(f"myorg/network/{env}")
vpc_id = network.get_output("vpc_id")

Derive the reference from get_stack() rather than reading a stack name out of config. A hard-coded myorg/network/prod in a file that every environment executes is one careless merge away from wiring the dev application to production networking — and because the reference is read-only, nothing fails; the dev stack simply launches instances in the prod VPC. Deriving the name means the mistake is impossible to express.

Prefer require_output over get_output for values the program cannot proceed without. get_output yields None for a missing key, which surfaces much later as an opaque provider error about a null subnet id; require_output fails immediately and names both the output and the stack it looked in.

Verification

Confirm each stack carries its own configuration and produces its own state:

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# CLI: prove the environments are isolated, not shared
pulumi config --stack prod          # shows prod CIDR and secret refs only
pulumi stack output vpc_id --stack dev
pulumi preview --stack staging --diff   # should plan against staging state alone

A minimal test asserts the config loader maps each stack to the right values without provisioning anything:

# tests/test_env_config.py
# CLI: pytest tests/test_env_config.py -q
from unittest.mock import patch


def test_prod_uses_larger_cidr() -> None:
    with patch("pulumi.get_stack", return_value="prod"):
        from __main__ import load_env_config  # type: ignore
        # State implication: pure config resolution, no resource is created.
        cfg = load_env_config()
        assert cfg.environment == "prod"

The check that catches real incidents, though, is comparative rather than per-stack: assert that the environments differ only where they are supposed to. Dumping each stack's config and diffing the key sets finds the missing prod key that a get-with-default would otherwise paper over.

# CLI: the key sets must match even though the values differ
diff <(pulumi config --stack dev --json | jq -r 'keys[]') \
     <(pulumi config --stack prod --json | jq -r 'keys[]')
# Empty output means every environment declares the same knobs.
pulumi stack ls --json | jq -r '.[] | "\(.name)\t\(.lastUpdate)\t\(.resourceCount)"'

A stack with a resource count far below its siblings is usually a partially-applied environment rather than a smaller one, and a lastUpdate weeks behind the others means the environment has stopped being a rehearsal for production.

Gotchas & Edge Cases

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks pulumi.get_sta watch this boundary config.require watch this boundary config.get watch this boundary require watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

Stack names leak into resource names — rename carefully. Because resources are named with pulumi.get_stack(), renaming a stack (pulumi stack rename) changes derived resource names and forces replacements. Decide the naming scheme before the first pulumi up against a real account.

config.require fails loudly, config.get fails silently. Use require for values that must exist per environment (region, CIDR). A get that falls back to a default will happily deploy prod with a dev-sized default if the prod config key is missing.

Secrets are per-stack, not shared. A --secret value set on dev is not visible to prod. Set each environment's secrets explicitly, and never commit an unencrypted fallback into code as a default.

The selected stack is sticky. pulumi stack select writes the choice to the workspace, so a later pulumi up with no --stack targets whatever was selected last — including in a CI runner that reused a cached workspace. Pass --stack explicitly on every command in automation, and treat a bare pulumi up in a pipeline definition as a defect.

Branching on the environment inside the program. if pulumi.get_stack() == "prod": compiles and works, and it means the production path has never executed in any other environment. Where behaviour genuinely must differ, express it as a config value with a default — enable_multi_az, say — so the branch is data every environment sets rather than a code path only prod takes.

A StackReference to a stack that has not been deployed. The reference resolves against the last checkpoint, so pointing at a freshly initialised stack yields no outputs at all and require_output fails with a message naming the output and the stack. Deploy the producing stack first; there is no ordering Pulumi can infer across state boundaries.

Deleting a stack is not the same as destroying its resources. pulumi stack rm removes the state file and, with it, any record of what the stack created — the cloud resources keep running and keep billing, now unmanaged. Always pulumi destroy --stack <name> first, confirm the resource count is zero, then remove the stack.

Operational Notes

The right number of stacks is a function of blast radius, not aesthetics. Give each environment its own stack so a dev apply can never touch prod state, and split a subsystem into its own stack when it changes on a different cadence or needs different permissions — a rarely-touched network layer beneath a frequently-deployed application layer, for example.

Stack boundaries Stack boundaries: layered from network stack down to shared config via StackReference. network stack data stack app stack (dev/stg/prod) shared config via StackReference
Split stacks by blast radius and change cadence, wiring them together with stack references.

Wire the pieces together with stack outputs and StackReference rather than copying values between configs, so there is a single source of truth for shared identifiers like VPC ids. Keep per-environment differences in per-stack config files with secrets encrypted, and drive all environments from the same program so dev, staging, and prod stay structurally identical and only their inputs differ.

The credentials each stack deploys with should differ as sharply as its config does. One role per environment, assumed by the pipeline only for that environment's job, means a compromised dev pipeline cannot reach production even if it somehow selects the prod stack — the deploy fails on authorization rather than succeeding against the wrong account. This is the operational half of the isolation the separate state files provide; without it the state boundary is a convention rather than a control.

Give production a second brake at the resource level for things that must not be replaced by accident:

# __main__.py (continued): make destructive prod changes require a deliberate step
# CLI: pulumi up --stack prod
import pulumi
import pulumi_aws as aws

is_prod: bool = ENV.environment == "prod"

db = aws.rds.Instance(
    f"{ENV.environment}-db",
    instance_class=ENV.instance_type,
    allocated_storage=100,
    engine="postgres",
    skip_final_snapshot=not is_prod,
    opts=pulumi.ResourceOptions(
        protect=is_prod,
        # State implication: with protect set, any plan that would delete or
        # replace this resource aborts the whole update until the flag is
        # cleared with `pulumi state unprotect`.
    ),
)

Note that this is a deliberate exception to the "no branching on the environment" rule above: protect and skip_final_snapshot change the safety envelope, not the resource graph, so every environment still creates the same database.

Promotion, finally, should move a version rather than a diff. The pipeline runs pulumi preview --stack staging on the pull request, applies on merge, and applies the same commit to production behind an approval gate — never a separate branch and never a hand-run command. Add a scheduled pulumi refresh --stack prod --diff on top of that so drift introduced outside the pipeline is found on a Tuesday morning rather than during the next deploy; the techniques for acting on what it finds are in detecting and remediating state drift in Python IaC.

FAQ

One stack per environment or one per resource group?

Split by blast radius: give each environment its own stack, and split further when a subsystem changes on a different cadence or needs separate permissions.

How do stacks share values?

Through stack outputs and StackReference, which let a consuming stack read another's outputs without duplicating configuration.

Where does per-environment config live?

In per-stack config files (Pulumi.<stack>.yaml), with secrets encrypted, so the same program produces dev, staging, and prod from different inputs.

Can I rename a stack after it has been deployed?

pulumi stack rename changes the stack's name and its state file's identity, but it does not rewrite resource URNs. Anything whose logical name was derived from pulumi.get_stack() keeps its old name in state and gets a new one from the code, so the next preview plans replacements. Rename before the first real apply, or accept the rebuild.

Should each environment live in its own cloud account?

For production, yes. Separate accounts or subscriptions give you a hard quota boundary, a clean cost split, and a blast radius that no IAM mistake can cross. Stacks and accounts are orthogonal, so the pattern here is unchanged — only the credentials each stack deploys with differ.

How do I copy config from one stack to another?

pulumi config cp --dest staging copies plain values from the currently selected stack. Secrets are not copied in plaintext: each target stack encrypts with its own key, so re-set them explicitly. Copying is a bootstrap convenience, not a sync mechanism — after the first apply, each stack's config is independent.