Skip to main content
  1. Docs
  2. Infrastructure as Code
  3. Guides
  4. Continuous Delivery
  5. GitHub Actions

Using GitHub Actions with Pulumi

    GitHub Actions is the CI/CD service built into GitHub. It runs workflows defined in YAML files under .github/workflows/ in your repository, triggered by events such as pull requests, pushes, and tags.

    You run Pulumi in a workflow with the Pulumi GitHub Action (pulumi/actions), an official, Pulumi-maintained action that installs the Pulumi CLI and runs Pulumi commands as a workflow step. Because it wraps the CLI, it works with a Pulumi program written in any supported language and targeting any cloud provider.

    This guide assumes Pulumi Cloud as your backend. Pulumi Cloud isn't required to run Pulumi in CI/CD — Pulumi also supports self-managed backends — but the access token, OIDC, and ESC features described here are specific to Pulumi Cloud.

    Pulumi’s GitHub Actions

    Pulumi publishes and maintains several actions on the GitHub Marketplace. This guide centers on pulumi/actions, the action that runs Pulumi commands; the others handle authentication and configuration and are covered in the sections below.

    ActionPurpose
    pulumi/actionsInstalls the Pulumi CLI and runs a Pulumi command (preview, up, destroy, and so on) as a workflow step.
    pulumi/setup-pulumiInstalls the Pulumi CLI only, for workflows that invoke pulumi commands directly rather than through pulumi/actions.
    pulumi/auth-actionsExchanges a GitHub OIDC token for a short-lived Pulumi Cloud access token, removing the need to store a token as a secret.
    pulumi/esc-actionOpens a Pulumi ESC environment and injects its environment variables — cloud credentials, secrets, and configuration — into the workflow.
    pulumi/esc-export-secrets-actionExports GitHub Actions secrets into a Pulumi ESC environment, useful when migrating existing secrets to ESC.

    Prerequisites

    Before you begin, make sure you have:

    1. A Pulumi Cloud account and organization.
    2. A GitHub repository.
    3. A Pulumi program committed to that repository. If you don’t have one yet, follow a Get started guide.

    Authenticate with Pulumi Cloud

    Your workflow authenticates to Pulumi Cloud with a single Pulumi access token, supplied through the PULUMI_ACCESS_TOKEN environment variable. Prefer an organization or team token over a personal token so the workflow’s identity isn’t tied to an individual.

    Add the token as an encrypted secret named PULUMI_ACCESS_TOKEN under your repository’s Settings > Secrets and variables > Actions. The workflow then reads it through the secrets context, as shown in the examples below.

    Pulumi ESC (Environments, Secrets, and Configuration) then supplies cloud credentials, secrets, and configuration to your Pulumi program. Because ESC delivers those values the same way whether the consumer is a workflow or a developer’s machine, a single environment definition works in both places — you don’t store separate cloud provider keys as repository secrets.

    Authenticate without a stored token using OIDC

    You can remove the static token entirely. GitHub Actions can issue a short-lived OpenID Connect (OIDC) token for a workflow job. Register GitHub Actions as a trusted OIDC issuer in Pulumi Cloud, and the pulumi/auth-actions action exchanges that OIDC token for a short-lived Pulumi access token at runtime — no long-lived credential is stored as a repository secret.

    Pair it with pulumi/esc-action to pull cloud credentials, secrets, and configuration from a Pulumi ESC environment. This is the recommended way to provide cloud credentials in GitHub Actions because it’s:

    • Provider-agnostic — works with AWS, Azure, Google Cloud, and others through the same pattern.
    • Portable — the same ESC environment works locally and in any CI/CD system, not only GitHub Actions.
    • Centralized — credential configuration lives in ESC, not scattered across individual workflows.

    A job that uses OIDC needs the id-token: write permission. Add pull-requests: write as well if the workflow comments on pull requests:

    permissions:
      id-token: write
      contents: read
      pull-requests: write # Only needed when commenting on pull requests
    

    The job below authenticates with pulumi/auth-actions, loads an ESC environment with pulumi/esc-action, and then runs Pulumi — with no PULUMI_ACCESS_TOKEN secret and no stored cloud provider keys:

        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version-file: infra/package.json
          - name: Authenticate with Pulumi Cloud
            uses: pulumi/auth-actions@v1
            with:
              organization: acme
              requested-token-type: urn:pulumi:token-type:access_token:organization
          - name: Load the ESC environment
            uses: pulumi/esc-action@v1
            with:
              environment: acme/website/ci
          - run: npm install
            working-directory: infra
          - uses: pulumi/actions@v7
            with:
              command: preview
              stack-name: acme/website/staging
              work-dir: infra
    

    For more detail, see the Pulumi ESC GitHub Action documentation.

    To configure OIDC directly between GitHub Actions and a cloud provider without ESC — for example, with aws-actions/configure-aws-credentials and a role-to-assume input — follow that provider’s GitHub Actions OIDC guide. This approach is provider-specific: each cloud requires its own trust relationship, whereas ESC configures that trust once and reuses it everywhere.

    The trunk-based development workflow

    The most common way to run Pulumi in CI/CD follows a trunk-based development model: work merges into a single main branch, and deployments flow outward from there. This guide splits that across two workflow files:

    • .github/workflows/pr.yml runs pulumi preview on every pull request, surfacing the proposed changes for review.
    • .github/workflows/main.yml runs pulumi up when changes land — to staging on a push to main, and to production on a release-* tag.

    Both files check out the repository, set up your program’s language, install dependencies, and then invoke pulumi/actions. The examples assume a Pulumi program in an infra/ directory and stacks named acme/website/staging and acme/website/production. Only the language setup and dependency-install steps differ between languages:

    # .github/workflows/pr.yml
    name: Pulumi preview
    on:
      pull_request:
    jobs:
      preview:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version-file: infra/package.json
          - run: npm install
            working-directory: infra
          - uses: pulumi/actions@v7
            with:
              command: preview
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
    # .github/workflows/main.yml
    name: Pulumi deploy
    on:
      push:
        branches:
          - main
        tags:
          - 'release-*'
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version-file: infra/package.json
          - run: npm install
            working-directory: infra
    
          # Push to main: deploy to the staging environment.
          - name: Deploy to staging
            if: github.ref == 'refs/heads/main'
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
          # Release tag: promote to production.
          - name: Deploy to production
            if: startsWith(github.ref, 'refs/tags/release-')
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/production
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
    # .github/workflows/pr.yml
    name: Pulumi preview
    on:
      pull_request:
    jobs:
      preview:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-python@v5
            with:
              python-version: '3.12'
          - run: pip install -r requirements.txt
            working-directory: infra
          - uses: pulumi/actions@v7
            with:
              command: preview
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
    # .github/workflows/main.yml
    name: Pulumi deploy
    on:
      push:
        branches:
          - main
        tags:
          - 'release-*'
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-python@v5
            with:
              python-version: '3.12'
          - run: pip install -r requirements.txt
            working-directory: infra
    
          # Push to main: deploy to the staging environment.
          - name: Deploy to staging
            if: github.ref == 'refs/heads/main'
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
          # Release tag: promote to production.
          - name: Deploy to production
            if: startsWith(github.ref, 'refs/tags/release-')
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/production
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
    # .github/workflows/pr.yml
    name: Pulumi preview
    on:
      pull_request:
    jobs:
      preview:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-go@v6
            with:
              go-version: 'stable'
          - run: go mod download
            working-directory: infra
          - uses: pulumi/actions@v7
            with:
              command: preview
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
    # .github/workflows/main.yml
    name: Pulumi deploy
    on:
      push:
        branches:
          - main
        tags:
          - 'release-*'
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-go@v6
            with:
              go-version: 'stable'
          - run: go mod download
            working-directory: infra
    
          # Push to main: deploy to the staging environment.
          - name: Deploy to staging
            if: github.ref == 'refs/heads/main'
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
          # Release tag: promote to production.
          - name: Deploy to production
            if: startsWith(github.ref, 'refs/tags/release-')
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/production
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
    # .github/workflows/pr.yml
    name: Pulumi preview
    on:
      pull_request:
    jobs:
      preview:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-dotnet@v5
            with:
              dotnet-version: '10.0.x'
          - uses: pulumi/actions@v7
            with:
              command: preview
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
    # .github/workflows/main.yml
    name: Pulumi deploy
    on:
      push:
        branches:
          - main
        tags:
          - 'release-*'
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-dotnet@v5
            with:
              dotnet-version: '10.0.x'
    
          # Push to main: deploy to the staging environment.
          - name: Deploy to staging
            if: github.ref == 'refs/heads/main'
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
          # Release tag: promote to production.
          - name: Deploy to production
            if: startsWith(github.ref, 'refs/tags/release-')
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/production
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
    # .github/workflows/pr.yml
    name: Pulumi preview
    on:
      pull_request:
    jobs:
      preview:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-java@v5
            with:
              distribution: temurin
              java-version: '21'
          - uses: pulumi/actions@v7
            with:
              command: preview
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
    # .github/workflows/main.yml
    name: Pulumi deploy
    on:
      push:
        branches:
          - main
        tags:
          - 'release-*'
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-java@v5
            with:
              distribution: temurin
              java-version: '21'
    
          # Push to main: deploy to the staging environment.
          - name: Deploy to staging
            if: github.ref == 'refs/heads/main'
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
          # Release tag: promote to production.
          - name: Deploy to production
            if: startsWith(github.ref, 'refs/tags/release-')
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/production
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    

    The pulumi/actions step runs Pulumi non-interactively, so pulumi up applies changes without a confirmation prompt. For Java and C#, the language runtime resolves and builds dependencies as part of the Pulumi run, so no separate install step is needed.

    To promote a release, push a tag that matches the release-* pattern:

    git tag release-2026-05-20
    git push origin release-2026-05-20
    

    Keeping production on its own stack and deploying it only from a tag makes each production update a single, traceable Git operation, and ensures production never deploys from an untested commit.

    To let reviewers exercise a change in a live environment, pair the preview step with a Review Stack, which provisions an ephemeral stack for the pull request and destroys it when the pull request closes.

    The Pulumi CLI automatically detects when it runs inside GitHub Actions and records the build and commit metadata. Each update in Pulumi Cloud then links back to the workflow run and pull request that triggered it — no extra configuration required.

    Report results on pull requests

    When a workflow runs pulumi preview on a pull request, you’ll usually want the proposed changes summarized on the pull request itself rather than buried in the workflow logs. There are two ways to do this.

    The Pulumi GitHub App lets Pulumi Cloud post a rich summary of resource changes — with links to the Pulumi Cloud console — directly on the pull request. Install it once on your GitHub organization and it works for every repository, regardless of which CI/CD system runs Pulumi.

    Comments from the action

    Without the GitHub App, the pulumi/actions action can post the raw CLI output itself. Set comment-on-pr: true and pass a github-token:

    - uses: pulumi/actions@v7
      with:
        command: preview
        stack-name: acme/website/staging
        work-dir: infra
        comment-on-pr: true
        github-token: ${{ secrets.GITHUB_TOKEN }}
      env:
        PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    

    For push-triggered deployments that have no pull request to comment on, set comment-on-summary: true to publish the result to the workflow run summary instead. The two inputs can be combined.

    For the action’s full set of inputs, see the pulumi/actions documentation.

    Stack outputs

    When Pulumi updates a stack, the values your program exports as stack outputs — a service endpoint, a bucket name, a connection string — become available to later steps in the workflow.

    Give the pulumi/actions step an id, and each stack output becomes a step output at steps.<id>.outputs.<name>:

    - name: Deploy
      id: pulumi
      uses: pulumi/actions@v7
      with:
        command: up
        stack-name: acme/website/staging
        work-dir: infra
      env:
        PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    - name: Use a stack output
      run: echo "Deployed to ${{ steps.pulumi.outputs.url }}"
    

    To pass an output to a downstream job, promote it to a job output and depend on the producing job with needs:

    jobs:
      deploy:
        runs-on: ubuntu-latest
        outputs:
          url: ${{ steps.pulumi.outputs.url }}
        steps:
          - uses: actions/checkout@v4
          - id: pulumi
            uses: pulumi/actions@v7
            with:
              command: up
              stack-name: acme/website/staging
              work-dir: infra
            env:
              PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
    
      integration-test:
        runs-on: ubuntu-latest
        needs: deploy
        steps:
          - run: ./run-tests.sh
            env:
              SERVICE_URL: ${{ needs.deploy.outputs.url }}
    
    Stack outputs can contain sensitive values such as passwords or private keys. Set suppress-outputs: true on the pulumi/actions step to keep output values out of the workflow logs, and store secrets as encrypted secrets rather than passing them through stack outputs when possible.

    Speed up runs with caching

    GitHub Actions starts each run on a clean runner, so Pulumi re-downloads its plugins and policy packs every time. Caching ~/.pulumi/plugins and ~/.pulumi/policies with actions/cache avoids that. Add this step before the pulumi/actions step:

    - name: Cache Pulumi plugins and policy packs
      uses: actions/cache@v4
      with:
        path: |
          ~/.pulumi/plugins
          ~/.pulumi/policies
        key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package.json') }}
        restore-keys: |
          ${{ runner.os }}-pulumi-
    

    The cache key includes a hash of your dependency manifest so the cache is rebuilt when dependencies change; use the file appropriate to your language — package.json, requirements.txt, go.sum, the .csproj, or pom.xml. The restore-keys fallback lets a run reuse a recent cache even when there’s no exact match.

    Control concurrent runs

    When pull requests stack up or commits land faster than a workflow finishes, runs accumulate. Concurrency groups bound how many run at once.

    For pull request previews, key the group to the pull request and cancel superseded runs so reviewers always see the result of the latest commit:

    concurrency:
      group: pulumi-pr-${{ github.event.pull_request.number }}
      cancel-in-progress: true
    

    For deployments, use a shared group without cancel-in-progress so updates queue instead of being dropped — canceling a deployment mid-run can leave infrastructure half-applied:

    concurrency:
      group: pulumi-deploy
    

    concurrency is a top-level workflow key, placed alongside on and jobs. You can also scope it to a single job when a workflow contains jobs with different concurrency needs.

    Managing GitHub with Pulumi

    You can manage GitHub itself — repositories, teams, branch protection rules, and Actions secrets — as code with the GitHub provider in the Pulumi Registry. This lets you define the workflow secrets and repository settings this guide describes as part of a Pulumi program.

    Additional resources

    • Continuous delivery — overview of running Pulumi in CI/CD.
    • pulumi/actions — the Pulumi GitHub Action’s full input reference.
    • Pulumi GitHub App — rich pull request comments and commit checks from Pulumi Cloud.
    • Pulumi ESC — deliver credentials, secrets, and configuration to workflows and developers consistently.
    • OIDC issuers — exchange a CI/CD system’s OIDC token for a short-lived Pulumi access token.
    • Review Stacks — ephemeral environments created automatically for each pull request.
    • CI/CD troubleshooting — diagnose common failures when running Pulumi in a pipeline.