Tag-Based Deployment Pipeline

Cloud & Infrastructure intermediate Building with AI ~18 min read

Overview

Deploying infrastructure changes safely requires a mechanism that is both explicit and auditable. Rather than deploying on every merge to main, a tag-based deployment pipeline triggers infrastructure updates only when a human creates a Git tag matching a specific pattern — such as infra-staging-v1.2.3 or infra-prod-v2.0.0. The tag encodes the target environment and the semantic version, giving operators fine-grained control over what gets deployed and where.

In a typical cloud infrastructure setup, teams use this pattern with Pulumi as the infrastructure-as-code engine. Pull requests trigger pulumi preview to show what would change; merging to main does nothing by itself. Only when a release engineer creates an environment-specific tag does the GitHub Actions workflow run pulumi up against the corresponding stack. This separation of “what changed” from “when to deploy” prevents accidental production deployments and creates a clear audit trail in the Git tag history.

Workload Identity Federation eliminates the need for service account key files by letting GitHub Actions authenticate to GCP using short-lived OIDC tokens. The workflow exchanges its GitHub-issued JWT for GCP credentials scoped to only the permissions needed for that specific Pulumi stack, implementing least-privilege access without any stored secrets.

Core Concepts

Tag Pattern Convention

Tags follow the pattern infra-{environment}-v{major}.{minor}.{patch}. The environment segment (staging, prod, dev) determines which Pulumi stack is targeted. The semver portion communicates the nature of the change: patch for configuration tweaks, minor for new resources, major for breaking changes that may require downtime. GitHub Actions workflows use tag pattern matching (on.push.tags: ['infra-*-v*']) to trigger only on infrastructure releases.

Preview on PR, Deploy on Tag

Every pull request that touches infrastructure code triggers pulumi preview, generating a diff of planned changes as a PR comment. Reviewers can inspect exactly which resources will be created, updated, or destroyed before approving. Deployment only happens when a tag is pushed — creating an intentional separation between code review (PR) and deployment authorization (tag). This two-phase approach prevents “merge and pray” deployments.

Workload Identity Federation

Instead of storing GCP service account keys as GitHub secrets, Workload Identity Federation (WIF) allows GitHub Actions to authenticate using OIDC. GitHub’s OIDC provider issues a JWT for each workflow run, which is exchanged for short-lived GCP credentials via a WIF pool and provider. The trust relationship is configured to accept tokens only from specific repositories and branches/tags, preventing unauthorized workflows from obtaining GCP access.

Environment-Specific Pulumi Stacks

Each deployment environment (dev, staging, prod) has its own Pulumi stack with isolated state, configuration values, and resource naming. The tag-based pipeline extracts the environment from the tag pattern and selects the corresponding stack. This ensures that a infra-staging-v1.0.0 tag can never accidentally deploy to production, even if the workflow configuration has a bug — the environment is derived from the tag itself, not from a workflow input.

How It Works

GitHub Actions Workflow

The deployment workflow triggers on tag pushes matching the infrastructure pattern. It parses the tag to extract the environment, authenticates to GCP via Workload Identity Federation, and runs Pulumi against the correct stack.

name: Infrastructure Deploy

on:
  push:
    tags:
      - 'infra-*-v*'

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Parse tag
        id: tag
        run: |
          TAG="${GITHUB_REF#refs/tags/}"
          ENV=$(echo "$TAG" | sed -E 's/^infra-([a-z]+)-v.*/\1/')
          VERSION=$(echo "$TAG" | sed -E 's/^infra-[a-z]+-v//')
          echo "environment=$ENV" >> "$GITHUB_OUTPUT"
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: >-
            projects/123456/locations/global/workloadIdentityPools/github-pool/providers/github-provider
          service_account: pulumi-deploy@myproject-infra.iam.gserviceaccount.com

      - uses: pulumi/actions@v5
        with:
          command: up
          stack-name: myproject/${{ steps.tag.outputs.environment }}
          work-dir: infra/
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

Preview Workflow for Pull Requests

A separate workflow runs on PRs that modify infrastructure code, posting the preview output as a comment for reviewers.

name: Infrastructure Preview

on:
  pull_request:
    paths:
      - 'infra/**'
      - 'Pulumi*.yaml'

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  preview:
    runs-on: self-hosted
    strategy:
      matrix:
        environment: [staging, prod]
    steps:
      - uses: actions/checkout@v4

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: >-
            projects/123456/locations/global/workloadIdentityPools/github-pool/providers/github-provider
          service_account: pulumi-preview@myproject-infra.iam.gserviceaccount.com

      - uses: pulumi/actions@v5
        with:
          command: preview
          stack-name: myproject/${{ matrix.environment }}
          work-dir: infra/
          comment-on-pr: true
          comment-on-pr-number: ${{ github.event.pull_request.number }}
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

Pulumi Stack Configuration

Each environment stack has its own configuration file with environment-specific values. Pulumi encrypts sensitive values using its built-in secrets provider.

// infra/index.ts
import * as pulumi from "@pulumi/pulumi";
import * as gcp from "@pulumi/gcp";

const config = new pulumi.Config();
const environment = pulumi.getStack();
const project = config.require("gcpProject");
const region = config.get("region") || "us-central1";

const cluster = new gcp.container.Cluster(`myproject-${environment}`, {
  location: region,
  initialNodeCount: 1,
  removeDefaultNodePool: true,
  workloadIdentityConfig: {
    workloadPool: `${project}.svc.id.goog`,
  },
  releaseChannel: {
    channel: environment === "prod" ? "STABLE" : "RAPID",
  },
});

const nodePool = new gcp.container.NodePool(`myproject-${environment}-pool`, {
  cluster: cluster.name,
  location: region,
  nodeConfig: {
    machineType: config.get("machineType") || "e2-standard-4",
    oauthScopes: ["https://www.googleapis.com/auth/cloud-platform"],
    workloadMetadataConfig: {
      mode: "GKE_METADATA",
    },
  },
  autoscaling: {
    minNodeCount: config.getNumber("minNodes") || 1,
    maxNodeCount: config.getNumber("maxNodes") || 10,
  },
});

Practical Applications

An infrastructure repository manages all GKE clusters, Cloud SQL instances, networking, and IAM for an organization’s services using this tag-based approach. A typical deployment flow involves a developer opening a PR with infrastructure changes, reviewing the pulumi preview output across both staging and production stacks, merging after approval, then creating a staging tag to deploy and validate before creating the production tag.

Another repository extends this pattern for application-level deployments, using tags like app-staging-v1.2.3 to trigger Cloud Run deployments. The same Workload Identity Federation configuration is reused, reducing the number of service accounts and secrets to manage across repositories.

Implementation Patterns

Tag Protection Rules

GitHub tag protection rules restrict who can create tags matching the infra-* pattern. Combined with branch protection on main, this ensures that only authorized team members can trigger deployments, and only from reviewed code that has been merged.

Rollback via Tag

Rolling back is as simple as creating a new tag pointing to a previous commit. Because the tag determines both the code version and the deployment trigger, git tag infra-prod-v1.1.1 abc123 && git push origin infra-prod-v1.1.1 redeploys the infrastructure state from commit abc123. The Pulumi state file tracks actual cloud state, so the deployment correctly converges from the current state to the desired state defined at that commit.

Staged Rollouts

The environment-in-tag convention naturally supports staged rollouts: deploy to dev first, validate, then staging, then production. Each environment tag is an independent deployment event, and the team can pause the rollout at any stage if issues are discovered.

Common Pitfalls

Further Reading