Skip to main content
  1. Docs
  2. Infrastructure as Code
  3. Guides
  4. Continuous Delivery
  5. GitLab CI/CD

Using GitLab CI/CD with Pulumi

    GitLab CI/CD is the CI/CD service built into GitLab. It runs pipelines defined in a .gitlab-ci.yml file at the root of your repository, triggered by events such as merge requests, pushes, and tags.

    You run Pulumi in a pipeline by invoking the Pulumi CLI directly. The official pulumi/pulumi container images ship the CLI together with a language runtime, so a job can run pulumi preview or pulumi up with no install step. Because the pipeline runs 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.

    Prerequisites

    Before you begin, make sure you have:

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

    Authenticate with Pulumi Cloud

    Give your pipeline a Pulumi Cloud identity in one of two ways. Choose one — you don’t need both:

    • A stored access token — a long-lived Pulumi access token kept as a CI/CD variable. Simplest to set up.
    • OIDC token exchange — no stored secret; the pipeline exchanges a short-lived OIDC token for a Pulumi access token at runtime. Recommended where you can use it.

    Whichever you choose, 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 pipeline or a developer’s machine, a single environment definition works in both places — you don’t store separate cloud provider keys as CI/CD variables.

    Authenticate with a stored access token

    Your pipeline 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 pipeline’s identity isn’t tied to an individual.

    Add the token as a CI/CD variable named PULUMI_ACCESS_TOKEN under your project’s Settings > CI/CD > Variables. Mark it Masked so it doesn’t appear in job logs. The Pulumi CLI reads the variable from the environment automatically — no explicit pulumi login is required.

    Authenticate without a stored token using OIDC

    You can remove the static token entirely. GitLab CI/CD can issue a short-lived OpenID Connect (OIDC) id_token for a job. Register GitLab as a trusted OIDC issuer in Pulumi Cloud, and the job exchanges that id_token for a short-lived Pulumi access token at runtime — no long-lived credential is stored as a CI/CD variable.

    The trust flows inbound: GitLab issues the id_token, and pulumi login --oidc-token exchanges it with Pulumi Cloud for an access token. A job requests the token with the id_tokens keyword and logs in before running Pulumi. Apply this by adding the id_tokens block and the pulumi login step to the .pulumi hidden job in the workflow below:

    variables:
      PULUMI_ORG: acme
    
    # This replaces the `.pulumi` hidden job in the workflow below.
    .pulumi:
      id_tokens:
        PULUMI_OIDC_TOKEN:
          aud: urn:pulumi:org:$PULUMI_ORG
      before_script:
        - pulumi login --oidc-token "$PULUMI_OIDC_TOKEN" --oidc-org "$PULUMI_ORG"
        - cd infra
        - npm ci # replace with your language's dependency-install command
    

    With OIDC, the pipeline needs no PULUMI_ACCESS_TOKEN CI/CD variable. For the full setup — registering the issuer and writing the authorization policy that controls which projects and branches may exchange a token — see Configuring OpenID Connect for GitLab and the central OIDC issuers reference.

    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. A single .gitlab-ci.yml covers the whole flow with three jobs:

    • preview runs pulumi preview on every merge request, surfacing the proposed changes for review.
    • deploy-staging runs pulumi up against the staging stack when changes land on main.
    • deploy-production runs pulumi up against the production stack when a release-* tag is pushed.

    GitLab rules decide which jobs run for a given pipeline. The examples assume a Pulumi program in an infra/ directory and stacks named acme/website/staging and acme/website/production. A hidden .pulumi job, reused through extends, holds the steps the three jobs share; only the image and the dependency-install command differ between languages:

    # .gitlab-ci.yml
    stages:
      - preview
      - deploy
    
    default:
      image:
        name: pulumi/pulumi-nodejs:latest
        entrypoint: [""]
    
    variables:
      PULUMI_STACK_STAGING: acme/website/staging
      PULUMI_STACK_PRODUCTION: acme/website/production
    
    # Shared setup: enter the program directory and install dependencies.
    .pulumi:
      before_script:
        - cd infra
        - npm ci
    
    # Merge request: preview the proposed changes.
    preview:
      extends: .pulumi
      stage: preview
      rules:
        - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      script:
        - pulumi preview --stack "$PULUMI_STACK_STAGING"
    
    # Push to main: deploy to the staging environment.
    deploy-staging:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_BRANCH == "main"
      environment:
        name: staging
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_STAGING"
    
    # Release tag: promote to production.
    deploy-production:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_TAG =~ /^release-/
      environment:
        name: production
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_PRODUCTION"
    
    # .gitlab-ci.yml
    stages:
      - preview
      - deploy
    
    default:
      image:
        name: pulumi/pulumi-python:latest
        entrypoint: [""]
    
    variables:
      PULUMI_STACK_STAGING: acme/website/staging
      PULUMI_STACK_PRODUCTION: acme/website/production
    
    # Shared setup: enter the program directory and install dependencies.
    .pulumi:
      before_script:
        - cd infra
        - pip install -r requirements.txt
    
    # Merge request: preview the proposed changes.
    preview:
      extends: .pulumi
      stage: preview
      rules:
        - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      script:
        - pulumi preview --stack "$PULUMI_STACK_STAGING"
    
    # Push to main: deploy to the staging environment.
    deploy-staging:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_BRANCH == "main"
      environment:
        name: staging
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_STAGING"
    
    # Release tag: promote to production.
    deploy-production:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_TAG =~ /^release-/
      environment:
        name: production
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_PRODUCTION"
    
    # .gitlab-ci.yml
    stages:
      - preview
      - deploy
    
    default:
      image:
        name: pulumi/pulumi-go:latest
        entrypoint: [""]
    
    variables:
      PULUMI_STACK_STAGING: acme/website/staging
      PULUMI_STACK_PRODUCTION: acme/website/production
    
    # Shared setup: enter the program directory and install dependencies.
    .pulumi:
      before_script:
        - cd infra
        - go mod download
    
    # Merge request: preview the proposed changes.
    preview:
      extends: .pulumi
      stage: preview
      rules:
        - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      script:
        - pulumi preview --stack "$PULUMI_STACK_STAGING"
    
    # Push to main: deploy to the staging environment.
    deploy-staging:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_BRANCH == "main"
      environment:
        name: staging
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_STAGING"
    
    # Release tag: promote to production.
    deploy-production:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_TAG =~ /^release-/
      environment:
        name: production
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_PRODUCTION"
    
    # .gitlab-ci.yml
    stages:
      - preview
      - deploy
    
    default:
      image:
        name: pulumi/pulumi-dotnet:latest
        entrypoint: [""]
    
    variables:
      PULUMI_STACK_STAGING: acme/website/staging
      PULUMI_STACK_PRODUCTION: acme/website/production
    
    # Shared setup: enter the program directory.
    # The .NET runtime restores and builds the project during the Pulumi run.
    .pulumi:
      before_script:
        - cd infra
    
    # Merge request: preview the proposed changes.
    preview:
      extends: .pulumi
      stage: preview
      rules:
        - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      script:
        - pulumi preview --stack "$PULUMI_STACK_STAGING"
    
    # Push to main: deploy to the staging environment.
    deploy-staging:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_BRANCH == "main"
      environment:
        name: staging
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_STAGING"
    
    # Release tag: promote to production.
    deploy-production:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_TAG =~ /^release-/
      environment:
        name: production
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_PRODUCTION"
    
    # .gitlab-ci.yml
    stages:
      - preview
      - deploy
    
    default:
      image:
        name: pulumi/pulumi-java:latest
        entrypoint: [""]
    
    variables:
      PULUMI_STACK_STAGING: acme/website/staging
      PULUMI_STACK_PRODUCTION: acme/website/production
    
    # Shared setup: enter the program directory.
    # The Java runtime resolves and builds the project during the Pulumi run.
    .pulumi:
      before_script:
        - cd infra
    
    # Merge request: preview the proposed changes.
    preview:
      extends: .pulumi
      stage: preview
      rules:
        - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      script:
        - pulumi preview --stack "$PULUMI_STACK_STAGING"
    
    # Push to main: deploy to the staging environment.
    deploy-staging:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_BRANCH == "main"
      environment:
        name: staging
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_STAGING"
    
    # Release tag: promote to production.
    deploy-production:
      extends: .pulumi
      stage: deploy
      rules:
        - if: $CI_COMMIT_TAG =~ /^release-/
      environment:
        name: production
      script:
        - pulumi up --yes --stack "$PULUMI_STACK_PRODUCTION"
    

    The pulumi up --yes flag applies changes without an interactive confirmation prompt, which is required in a non-interactive pipeline. The environment keyword records each deployment against a GitLab environment, giving you a deployment history and a one-click rollback in the GitLab UI.

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

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

    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 job with a Review Stack, which provisions an ephemeral stack for the merge request and destroys it when the merge request closes.

    The Pulumi CLI automatically detects when it runs inside GitLab CI/CD and records the build and commit metadata. Each update in Pulumi Cloud then links back to the pipeline and merge request that triggered it — no extra configuration required.

    Report results to GitLab

    When a pipeline runs pulumi preview on a merge request, you’ll usually want the proposed changes summarized on the merge request itself rather than buried in the job logs. The Pulumi GitLab integration does this: connect your GitLab group to Pulumi Cloud once, and Pulumi posts a summary of resource changes — with links to the Pulumi Cloud console — as a merge request comment, along with commit status checks. It works for every project in the group regardless of which CI/CD system runs Pulumi.

    Speed up runs with caching

    GitLab CI/CD starts each job in a fresh container, so Pulumi re-downloads its plugins and policy packs every time. GitLab’s cache can only store paths inside the project directory, so point PULUMI_HOME at a directory in the workspace and cache that:

    variables:
      PULUMI_HOME: $CI_PROJECT_DIR/.pulumi
    
    cache:
      key:
        files:
          - infra/package-lock.json
      paths:
        - .pulumi/plugins
        - .pulumi/policies
    

    Keying the cache on your dependency manifest rebuilds it when dependencies change; use the file appropriate to your language — package-lock.json, requirements.txt, go.sum, the .csproj, or pom.xml.

    Serialize deployments

    When commits land faster than a pipeline finishes, deployment jobs can overlap. Running two pulumi up jobs against the same stack at once causes one to fail on an update conflict. Assign deployment jobs a resource_group so GitLab runs them one at a time:

    deploy-staging:
      # ...
      resource_group: staging
    
    deploy-production:
      # ...
      resource_group: production
    

    Jobs in the same resource group queue instead of running concurrently, while jobs in different groups — staging and production here — still run in parallel.

    Managing GitLab with Pulumi

    You can manage GitLab itself — projects, groups, branch protection rules, and CI/CD variables — as code with the GitLab provider in the Pulumi Registry. This lets you define the CI/CD variables and project settings this guide describes as part of a Pulumi program.

    Additional resources