
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.

```text
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.

```hcl
# 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?

```text
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.

```hcl
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:

```hcl
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

```hcl
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

```hcl
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

```hcl
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:

```hcl
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`

```yaml
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

- [OpenTofu Registry - google provider](https://search.opentofu.org/provider/hashicorp/google/latest)
- [Terraform Registry - google provider](https://registry.terraform.io/providers/hashicorp/google/latest/docs)
- [WIF overview](https://cloud.google.com/iam/docs/workload-identity-federation)
- [WIF with deployment pipelines](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines)
- [About security hardening with OpenID Connect](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
- [google-github-actions/auth](https://github.com/google-github-actions/auth)
- [GCP IAM Roles Reference](https://cloud.google.com/iam/docs/understanding-roles)
- [OpenTofu Documentation](https://opentofu.org/docs/)
