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.0with the CLI on yourPATH(pulumi version). pulumi-aws >= 6.0pinned 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
prodstack cannot be deployed withdevpermissions.
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.
Related
- Handling Pulumi Stack Outputs and Cross-Stack References in Python — pass data between environment-paired stacks with
StackReference. - Pulumi Stack Architecture — the parent guide on project layout, provider lifecycle, and state boundaries.
- Building a Reusable VPC Component in Pulumi (Python) — the shared component this pattern drives from per-environment config.