Terraform Bootstrap Design: WIF, Artifact Registry, and GitHub Actions CI/CD

A common way to push Docker images from GitHub Actions to GCP is to create a service account, download a key JSON, and store it in GitHub Secrets. It works, but the key is a long-lived credential. If it leaks, you are in trouble.

In this post, I walk through the bootstrap layer I built with Terraform: WIF setup, Artifact Registry, least-privilege service account, and the GitHub Actions workflow that ties everything together โ€” with no service account key at all. I use OpenTofu in practice (the open-source Terraform fork), and the syntax is fully compatible with Terraform.

Foundation Tier Versus Per-Environment Tier

A common Terraform structure is to split resources by change frequency and sharing scope.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
terraform/
โ”œโ”€โ”€ bootstrap/          # one-time setup, shared across environments
โ”‚   โ”œโ”€โ”€ main.tf
โ”‚   โ”œโ”€โ”€ variables.tf
โ”‚   โ”œโ”€โ”€ outputs.tf
โ”‚   โ””โ”€โ”€ backend.tf
โ”œโ”€โ”€ environments/
โ”‚   โ””โ”€โ”€ dev/            # environment-specific resources
โ””โ”€โ”€ modules/
    โ””โ”€โ”€ vpc/

What belongs in bootstrap: resources created once, changed rarely, and shared across the project. A WIF pool is per-project, Artifact Registry is shared across environments, and the CI service account is shared by pipelines. These belong in bootstrap.

What belongs in environments: resources that vary by dev/prod, such as VPC, subnets, and GKE clusters.

One special bootstrap concern is state location. If your GCS state bucket already exists, you can use a separate prefix in the same bucket.

1
2
3
4
5
6
7
# bootstrap/backend.tf
terraform {
  backend "gcs" {
    bucket = "<project-id>-tfstate"
    prefix = "bootstrap"
  }
}

The Federated-Auth Trust Chain

WIF solves this question: how does GitHub Actions prove to GCP that the request really comes from my repository context?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
GitHub Actions executes
  -> GitHub issues a short-lived OIDC token (JWT)
     contains: iss, sub, repository, ref, actor, exp (minutes away)
          |
          v
GCP Workload Identity Pool
  -> validates: is iss from GitHub?
  -> checks attribute_condition: is repository == allowed repo?
          |
          v
impersonate Service Account
  -> SA has only artifactregistry.writer
  -> token expires in minutes, no key ever exists

No key exists anywhere. Even if a token is intercepted, it expires in minutes.

IaC Configuration

Cloud Platform Services to Enable

This is the most commonly missed part. GCP services are disabled by default, so you must enable them explicitly.

1
2
3
4
5
6
7
resource "google_project_service" "artifact_registry" {
  service = "artifactregistry.googleapis.com"
}

resource "google_project_service" "iam_credentials" {
  service = "iamcredentials.googleapis.com"
}

iamcredentials.googleapis.com is required for WIF service account impersonation. If you skip it, CI often fails mid-run with a 403. Use depends_on to enforce creation order:

1
2
3
4
5
6
resource "google_artifact_registry_repository" "go_api" {
  depends_on    = [google_project_service.artifact_registry]
  repository_id = "go-api"
  format        = "DOCKER"
  location      = var.region
}

depends_on is Terraformโ€™s explicit dependency declaration. Terraform can usually infer order from references, but google_artifact_registry_repository does not directly reference google_project_service, so you must declare the dependency.

CI Identity and Narrow Permissions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
resource "google_service_account" "github_actions" {
  account_id   = "github-actions-ci"
  display_name = "GitHub Actions CI"
}

resource "google_project_iam_member" "go_devops" {
  project = var.project_id
  role    = "roles/artifactregistry.writer"
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}

Grant only roles/artifactregistry.writer. Do not grant broad roles like Editor or Owner. Even if CI is compromised, the blast radius is limited to image push operations.

Federated-Auth Identity Group and Issuer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
resource "google_iam_workload_identity_pool" "github" {
  workload_identity_pool_id = "github-actions-pool"
  display_name              = "GitHub Actions Pool"
}

resource "google_iam_workload_identity_pool_provider" "github" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-actions-oidc"
  display_name                       = "GitHub Actions OIDC"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
  }

  attribute_condition = "attribute.repository == '${var.github_repository}'"

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

The two sides of attribute_mapping mean different things:

  • left side: the name defined on GCP side (attribute.repository)
  • right side: the original claim inside GitHub JWT (assertion.repository)

assertion is the JWT content issued by GitHub. attribute_mapping maps JWT claims into attributes GCP can use in conditions.

attribute_condition limits access to a specific repository. Without it, any GitHub repository could use the WIF pool.

CI Identity Assume-Role Grant

1
2
3
4
5
resource "google_service_account_iam_member" "wif_devops" {
  service_account_id = google_service_account.github_actions.id
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_repository}"
}

This binding allows a WIF principal from the allowed repository to impersonate the service account. principalSet means a set of principals that match a condition, not a single principal.

Outputs

CI workflow needs two outputs:

1
2
3
4
5
6
7
output "workload_identity_provider" {
  value = google_iam_workload_identity_pool_provider.github.name
}

output "service_account_email" {
  value = google_service_account.github_actions.email
}

name and email are fields from Attributes Reference. They are returned by GCP after resource creation, not arguments you pass in.

CI Platform Pipeline

After bootstrap apply, store those two outputs in GitHub repository Variables (not Secrets, because these two are not secret values):

  • GCP_WORKLOAD_IDENTITY_PROVIDER
  • GCP_SERVICE_ACCOUNT
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
deploy:
  needs: build
  runs-on: ubuntu-latest
  permissions:
    contents: read
    id-token: write
  env:
    GCP_REGION: us-east1
    GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
  steps:
    - uses: actions/checkout@v4
    - uses: google-github-actions/auth@v2
      with:
        workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
        service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
    - uses: google-github-actions/setup-gcloud@v2
    - name: Build and push to Artifact Registry
      run: |
        gcloud auth configure-docker ${GCP_REGION}-docker.pkg.dev --quiet
        docker build -t ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${{ github.sha }} .
        docker push ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${{ github.sha }}

Grant id-token: write only to jobs that need GCP auth. Do not put it at top-level workflow scope. Test/build jobs without GCP auth should not get that permission.

Variables defined in env are used in run: steps as $VAR_NAME, not ${{ }} syntax. ${{ }} is GitHub Actions expression syntax for contexts like vars., secrets., and github..

Inputs Versus Computed Outputs

This distinction matters when writing Terraform modules:

  • Arguments (Argument Reference): values you pass into a resource, such as account_id and display_name
  • Attributes (Attributes Reference): values available after creation, including both your inputs and provider-generated values

email is not an argument of google_service_account, so you do not set it directly. But after apply, it exists and can be referenced as google_service_account.github_actions.email. You can always verify this in each Terraform Registry resource page under Attributes Reference.

References