Promote Pulumi stacks with Pulumi Deployments

Preview pull requests, promote dev and staging changes, and keep production behind approval using Pulumi Deployments with OIDC and Pulumi ESC-oriented configuration.

Switch variant

Choose a different platform.

Download blueprint

Get this Pulumi Deployments blueprint project as a zip. Switch Pulumi language here to keep the download aligned with the install commands and blueprint program on the page.

Download the TypeScript blueprint with the matching Pulumi program, dependency files, and README.

Download TypeScript blueprint

Download the Python blueprint with the matching Pulumi program, dependency files, and README.

Download Python blueprint

Download the Go blueprint with the matching Pulumi program, dependency files, and README.

Download Go blueprint

This guide gives you a promotion pipeline for Pulumi stacks. It keeps one application infrastructure project in infrastructure/, creates dev, staging, and production stacks, and shows how to move changes from review to production without long-lived cloud keys.

Use it when your team wants a small, auditable path from pull request preview to production approval before layering on policy packs, drift detection, or service-specific release automation.

Architecture

The blueprint separates three concerns:

  • infrastructure/ holds the Pulumi program your team promotes through environments.
  • dev, staging, and production stacks carry environment-specific config and state.
  • Pulumi Deployments runs previews and updates with credentials supplied by Pulumi ESC or OIDC instead of static cloud keys.

This starter stays cloud-neutral. Replace the sample stack with your real resources after the promotion mechanics work.

Pulumi Deployments variant

This variant includes a separate deployment-admin/ Pulumi program. That program manages pulumiservice.DeploymentSettings resources for the dev, staging, and production stacks instead of letting each application stack define its own deployment settings.

The deployment settings use Git source context, VCS path filters for infrastructure/**, pull request previews, and push-to-deploy for dev and staging. Production is configured without automatic commit deploys; trigger it with click-to-deploy or the Deployments API after staging succeeds so your organization can attach its own approval policy.

DeploymentSettings examples

import * as pulumi from "@pulumi/pulumi";
import * as pulumiservice from "@pulumi/pulumiservice";

const config = new pulumi.Config();
const organization = config.require("organization");
const repository = config.require("repository");
const project = config.get("project") ?? "promotion-app";
const repoUrl = config.get("repoUrl") ?? `https://github.com/${repository}.git`;

const stacks = [
    { name: "dev", branch: "main", deployCommits: true },
    { name: "staging", branch: "main", deployCommits: true },
    { name: "production", branch: "main", deployCommits: false },
];

for (const stack of stacks) {
    new pulumiservice.DeploymentSettings(`${stack.name}-settings`, {
        organization,
        project,
        stack: stack.name,
        sourceContext: {
            git: {
                branch: stack.branch,
                repoDir: "infrastructure",
                repoUrl,
            },
        },
        vcs: {
            provider: "GitHub",
            repository,
            paths: ["infrastructure/**"],
            previewPullRequests: true,
            deployCommits: stack.deployCommits,
        },
        operationContext: {
            options: {
                skipIntermediateDeployments: true,
            },
        },
    });
}
import pulumi
import pulumi_pulumiservice as pulumiservice

config = pulumi.Config()
organization = config.require("organization")
repository = config.require("repository")
project = config.get("project") or "promotion-app"
repo_url = config.get("repoUrl") or f"https://github.com/{repository}.git"

stacks = [
    {"name": "dev", "branch": "main", "deploy_commits": True},
    {"name": "staging", "branch": "main", "deploy_commits": True},
    {"name": "production", "branch": "main", "deploy_commits": False},
]

for stack in stacks:
    pulumiservice.DeploymentSettings(f"{stack['name']}-settings",
        organization=organization,
        project=project,
        stack=stack["name"],
        source_context={
            "git": {
                "branch": stack["branch"],
                "repo_dir": "infrastructure",
                "repo_url": repo_url,
            },
        },
        vcs={
            "provider": "GitHub",
            "repository": repository,
            "paths": ["infrastructure/**"],
            "preview_pull_requests": True,
            "deploy_commits": stack["deploy_commits"],
        },
        operation_context={
            "options": {
                "skip_intermediate_deployments": True,
            },
        })
package main

import (
    "fmt"

    "github.com/pulumi/pulumi-pulumiservice/sdk/go/pulumiservice"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)

type stackSettings struct {
    name          string
    branch        string
    deployCommits bool
}

func main() {
    pulumi.Run(func(ctx *pulumi.Context) error {
        cfg := config.New(ctx, "")
        organization := cfg.Require("organization")
        repository := cfg.Require("repository")
        project := cfg.Get("project")
        if project == "" {
            project = "promotion-app"
        }
        repoURL := cfg.Get("repoUrl")
        if repoURL == "" {
            repoURL = fmt.Sprintf("https://github.com/%s.git", repository)
        }

        stacks := []stackSettings{
            {name: "dev", branch: "main", deployCommits: true},
            {name: "staging", branch: "main", deployCommits: true},
            {name: "production", branch: "main", deployCommits: false},
        }

        for _, stack := range stacks {
            _, err := pulumiservice.NewDeploymentSettings(ctx, stack.name+"-settings", &pulumiservice.DeploymentSettingsArgs{
                Organization: pulumi.String(organization),
                Project:      pulumi.String(project),
                Stack:        pulumi.String(stack.name),
                SourceContext: &pulumiservice.DeploymentSettingsSourceContextArgs{
                    Git: &pulumiservice.DeploymentSettingsGitSourceArgs{
                        Branch: pulumi.String(stack.branch),
                        RepoDir: pulumi.String("infrastructure"),
                        RepoUrl: pulumi.String(repoURL),
                    },
                },
                Vcs: &pulumiservice.DeploymentSettingsVcsArgs{
                    Provider:            pulumi.String("GitHub"),
                    Repository:          pulumi.String(repository),
                    Paths:               pulumi.StringArray{pulumi.String("infrastructure/**")},
                    PreviewPullRequests: pulumi.Bool(true),
                    DeployCommits:       pulumi.Bool(stack.deployCommits),
                },
                OperationContext: &pulumiservice.DeploymentSettingsOperationContextArgs{
                    Options: &pulumiservice.OperationContextOptionsArgs{
                        SkipIntermediateDeployments: pulumi.Bool(true),
                    },
                },
            })
            if err != nil {
                return err
            }
        }

        return nil
    })
}

Prerequisites

You need:

  • a Pulumi Cloud organization and the Pulumi CLI
    • Python 3.11 or newer and a virtual environment tool
    • Go 1.23 or newer
  • a GitHub repository that contains this starter
  • Pulumi ESC environments or cloud OIDC roles for each stack
  • three Pulumi stacks named dev, staging, and production

The starter uses example organization and repository setting names. Set your own values as Pulumi config or CI environment values before your first run.

Download the starter

Download the starter for the language you want to use:

  • infrastructure/index.ts as the sample Pulumi stack

  • language-specific project files for the Pulumi program

  • platform-specific CI/CD files for the selected setup

  • infrastructure/__main__.py as the sample Pulumi stack

  • language-specific project files for the Pulumi program

  • platform-specific CI/CD files for the selected setup

  • infrastructure/main.go as the sample Pulumi stack

  • language-specific project files for the Pulumi program

  • platform-specific CI/CD files for the selected setup

The archive includes deployment settings managed as code by a separate admin stack plus the sample Pulumi program under infrastructure/.

Quickstart

Unzip the starter, install dependencies, and create the three stacks:

cd pulumi-cicd-promotion-pulumi-deployments
cd infrastructure
npm install
pulumi stack init dev
pulumi stack init staging
pulumi stack init production
cd pulumi-cicd-promotion-pulumi-deployments
python3 -m venv .venv
source .venv/bin/activate
cd infrastructure
pip install -r requirements.txt
pulumi stack init dev
pulumi stack init staging
pulumi stack init production
cd pulumi-cicd-promotion-pulumi-deployments
cd infrastructure
go mod tidy
pulumi stack init dev
pulumi stack init staging
pulumi stack init production

Attach each stack to an ESC environment or OIDC-backed cloud configuration before running the pipeline. Keep stack-specific settings in Pulumi config, GitHub environments, or Pulumi ESC.

Promotion flow

  1. Open a pull request and review the pulumi preview result before merge.
  2. Merge to main to update dev.
  3. Let the pipeline promote the same commit to staging.
  4. Release to production only after click-to-deploy or API-triggered production deployment after staging is satisfied.

This shape keeps review fast while making the production step explicit.

Credential model

Use OIDC and Pulumi ESC for cloud access:

  • grant the runner id-token: write when using GitHub Actions
  • configure cloud trust policies outside the starter, scoped to the repository, branch, and environment
  • import cloud credentials through Pulumi ESC or deployment OIDC settings
  • avoid static access keys, checked-in tokens, and repository-wide secrets with broad permissions

The starter references environment variables and Pulumi config names only. It does not include real tokens or cloud key material.

Sample Pulumi stack

This small stack stands in for your real application infrastructure. The CI/CD wiring targets the same infrastructure/ project for dev, staging, and production.

import * as pulumi from "@pulumi/pulumi";
import * as random from "@pulumi/random";

const stack = pulumi.getStack();
const config = new pulumi.Config();
const serviceName = config.get("serviceName") ?? "promotion-demo";

const suffix = new random.RandomPet("release-suffix", {
    prefix: `${serviceName}-${stack}`,
    length: 2,
});

export const environment = stack;
export const releaseName = suffix.id;
import pulumi
import pulumi_random as random

stack = pulumi.get_stack()
config = pulumi.Config()
service_name = config.get("serviceName") or "promotion-demo"

suffix = random.RandomPet("release-suffix",
    prefix=f"{service_name}-{stack}",
    length=2)

pulumi.export("environment", stack)
pulumi.export("releaseName", suffix.id)
package main

import (
    "fmt"

    "github.com/pulumi/pulumi-random/sdk/v4/go/random"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)

func Program(ctx *pulumi.Context) error {
        cfg := config.New(ctx, "")
        serviceName := cfg.Get("serviceName")
        if serviceName == "" {
            serviceName = "promotion-demo"
        }

        suffix, err := random.NewRandomPet(ctx, "release-suffix", &random.RandomPetArgs{
            Prefix: pulumi.String(fmt.Sprintf("%s-%s", serviceName, ctx.Stack())),
            Length: pulumi.Int(2),
        })
        if err != nil {
            return err
        }

        ctx.Export("environment", pulumi.String(ctx.Stack()))
        ctx.Export("releaseName", suffix.ID())
        return nil
}

func main() {
    pulumi.Run(Program)
}

Next steps

Replace the sample stack with your real infrastructure, then add policy checks, drift detection, and stack tags that match your governance model. If your production process requires a change record or release train, make that system trigger the final production deployment rather than auto-deploying every merge.

Frequently asked questions

Which stacks does this blueprint assume?
It assumes one Pulumi project with dev, staging, and production stacks. You can rename the stacks, but keep the promotion order and approval gate explicit.
Where do cloud credentials live?
The workflows and deployment settings are written for short-lived credentials from OIDC and Pulumi ESC. Do not store static cloud keys in the repository or workflow files.
Why is production different from staging?
Production changes need a deliberate human or policy-controlled release step. GitHub Actions uses a protected environment named production, while Pulumi Deployments leaves production as click-to-deploy or API-triggered because approval policy is organization-specific.
Can I adapt this to a monorepo?
Yes. Keep the Pulumi project under a stable directory such as infrastructure/, then use path filters so unrelated application changes do not trigger infrastructure deployments.
Does this create cloud resources?
The starter uses a tiny Pulumi program so the CI/CD shape is easy to test. Replace it with your real stack once the promotion flow works in your organization.