Pulumi CI/CD Integration

Hard 30 min read

CI/CD Overview

Why CI/CD for Infrastructure?

The Problem: Manual infrastructure changes are error-prone, hard to audit, and create bottlenecks. Different team members make conflicting changes without visibility.

The Solution: Pulumi integrates natively with CI/CD pipelines, enabling preview-on-PR and deploy-on-merge workflows with full audit trails.

Real Impact: Teams with automated IaC pipelines deploy 10x more frequently with 95% fewer change-related incidents.

Real-World Analogy

Think of CI/CD for infrastructure as a quality-controlled assembly line:

  • PR Preview = Quality inspector checking the blueprint before production
  • Review Stacks = Building a prototype to test before mass production
  • Deploy on Merge = Green-lighting the blueprint for production deployment
  • Automation API = Robot arms that build exactly what the blueprint specifies

CI/CD Pipeline Patterns

Preview on PR

Automatically run pulumi preview on every pull request to show exactly what changes will be made.

Deploy on Merge

Automatically deploy to staging or production when PRs are merged to the main branch.

Review Stacks

Spin up ephemeral environments per PR for testing, then tear them down when the PR is closed.

Automation API

Embed Pulumi inside custom applications for programmatic infrastructure management without the CLI.

CI/CD Pipeline Flow with Pulumi
Pull Request Code Change Preview pulumi preview + Unit Tests + Policy Check Review Code + Infra Merge to main Deploy pulumi up Staging Production Review Stack (ephemeral)

GitHub Actions

Preview on Pull Request

.github/workflows/preview.yml
name: Pulumi Preview
on:
  pull_request:
    branches: [main]

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - uses: pulumi/actions@v5
        with:
          command: preview
          stack-name: org/project/staging
          comment-on-pr: true
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Deploy on Merge

.github/workflows/deploy.yml
name: Pulumi Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - uses: pulumi/actions@v5
        with:
          command: up
          stack-name: org/project/production
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

GitLab CI

.gitlab-ci.yml
stages:
  - preview
  - deploy

preview:
  stage: preview
  image: pulumi/pulumi-nodejs:latest
  script:
    - npm ci
    - pulumi login
    - pulumi stack select staging
    - pulumi preview --diff
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

deploy:
  stage: deploy
  image: pulumi/pulumi-nodejs:latest
  script:
    - npm ci
    - pulumi login
    - pulumi stack select production
    - pulumi up --yes
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Automation API

deploy-server.ts
import { LocalWorkspace } from "@pulumi/pulumi/automation";
import express from "express";

const app = express();

app.post("/deploy/:env", async (req, res) => {
    const { env } = req.params;

    const stack = await LocalWorkspace.createOrSelectStack({
        stackName: env,
        workDir: "./infra",
    });

    // Set config based on environment
    await stack.setConfig("app:replicas", {
        value: env === "production" ? "3" : "1",
    });

    const result = await stack.up({ onOutput: console.log });

    res.json({
        status: result.summary.result,
        outputs: result.outputs,
    });
});

app.listen(3000);

Review Stacks

.github/workflows/review-stack.yml
name: Review Stack
on:
  pull_request:
    types: [opened, synchronize, closed]

jobs:
  review:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - uses: pulumi/actions@v5
        with:
          command: up
          stack-name: review-${{ github.event.number }}
          comment-on-pr: true

  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - uses: pulumi/actions@v5
        with:
          command: destroy
          stack-name: review-${{ github.event.number }}
      - run: pulumi stack rm review-${{ github.event.number }} --yes

Quick Reference

CI/CD PlatformIntegrationKey Features
GitHub Actionspulumi/actions@v5PR comments, OIDC auth, matrix builds
GitLab CIDocker imageMR integration, environments, artifacts
JenkinsShell stepsPipeline DSL, shared libraries
CircleCIOrbParallelism, caching, approval gates
Azure DevOpsTask extensionAzure auth, release gates

CI/CD Best Practices

  • Always run pulumi preview on PRs and post the diff as a comment
  • Use OIDC tokens instead of long-lived credentials when possible
  • Implement review stacks for testing infrastructure changes in isolation
  • Pin Pulumi CLI and provider versions for reproducible builds
  • Use --expect-no-changes in drift detection workflows