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.

Prerequisites

  • 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

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.

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()

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)

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")

Verification

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

# 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"

Gotchas & Edge Cases

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.