Building and Pushing Docker Images to Google Artifact Registry with GitHub Actions

This article walks through building and pushing Docker images to Google Artifact Registry using GitHub Actions and Workload Identity Federation — without storing service account keys. It explains each step, from GCP setup to secure, keyless CI/CD.

You may find that I have written a similar article in the past. However, that article had some bugs in the steps and did not explain the principles of Workload Identity Federation in depth. So I rewrote it, and this comparison also reflects my progress and changes over the years.

Introduction

In this article, we’ll walk through a GitHub Actions workflow that builds a Docker image and pushes it to Google Cloud Artifact Registry – all without storing any long-lived cloud credentials. Instead, the workflow uses Google Workload Identity Federation (WIF) to securely authenticate using GitHub’s OpenID Connect (OIDC) tokens, eliminating the need for static service account keys. The workflow is triggered on new version tags (e.g. 1.2.3) as well as pull requests, ensuring that release builds are pushed to Artifact Registry while pull request builds are just tested. We’ll explain each step of the workflow configuration and the underlying principles, including how Workload Identity Federation works, in a conversational but detailed way. (Note: any sensitive identifiers in the examples are replaced with placeholders.)

Google Cloud Setup Prerequisites

Before the GitHub Actions workflow can operate, there are a few one-time setup steps on Google Cloud:

Enable APIs

Make sure the Artifact Registry API (for storing container images) and IAM Service Account Credentials API (for token minting) are enabled on your Google Cloud project. The latter is necessary for GitHub Actions to mint short-lived credentials for a service account via Workload Identity Federation.

gcloud services enable \
  iam.googleapis.com \
  iamcredentials.googleapis.com \
  artifactregistry.googleapis.com

Create an Artifact Registry repository

If you haven’t already, create a Docker repository in Artifact Registry to hold your images (e.g. using gcloud artifacts repositories create). For example, you might create a repository named my-repo in region us-central1. Note the repository name and region, as they will form part of the image’s address.

gcloud artifacts repositories create my-repo \
  --repository-format=docker \
  --location=us-central1 \
  --description="Docker repo for CI/CD"

Create a Service Account

Set up a Google Cloud service account that the GitHub Actions workflow will impersonate. This service account acts as the identity for your CI/CD pipeline (often called a “robot account”). No keys are needed for this service account (we will use token-based auth via WIF instead of JSON key files).

gcloud iam service-accounts create github-ci \
  --description="Used by GitHub Actions via WIF" \
  --display-name="GitHub CI"

Grant Permissions to the Service Account

Give the service account permission to push images to Artifact Registry. For example, assign the role Artifact Registry Writer on your Artifact Registry repository (or a broader role like Artifact Registry Admin if appropriate). This ensures the service account can write (upload) Docker images to your repository.

gcloud iam service-accounts create github-ci \
  --description="Used by GitHub Actions via WIF" \
  --display-name="GitHub CI"

You may use roles/artifactregistry.admin if you need broader control.

Create a Workload Identity Pool

A WIF pool is a container for external identities, in this case identities from GitHub Actions.

gcloud iam workload-identity-pools create github-pool \
  --location="global" \
  --display-name="GitHub Actions Pool"

Create an OIDC Provider within that pool

This OIDC provider represents GitHub as an identity provider. When creating it, you’ll specify https://token.actions.githubusercontent.com as the issuer and set up attribute mapping so Google can trust certain tokens from GitHub. For example, you might map the GitHub token’s repository claim to a Google attribute. This allows you to restrict authentication to specific GitHub repositories or workflows.

gcloud iam workload-identity-pools providers create-oidc github-provider \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --display-name="GitHub Actions provider" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
  --attribute-condition="assertion.repository.startsWith('my-org/')"

my-org/ should be replaced by your account name.

This step restricts trust to GitHub repositories in the my-org/ organization only. You can omit --attribute-condition if you want broader access.

Allow GitHub to impersonate the Service Account

Bind the service account to the Workload Identity Pool provider with the Workload Identity User role. In practice, this means granting roles/iam.workloadIdentityUser on the service account to identities coming from your GitHub repo through that provider. This step links everything together – it tells Google Cloud that tokens from your GitHub repository (the principal) are allowed to impersonate (act as) the service account.

First, get the provider’s full resource name:

gcloud iam workload-identity-pools providers describe github-provider \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --format="value(name)"

It should return something like:

projects/1234567890/locations/global/workloadIdentityPools/github-pool/providers/github-provider

Then bind the Service Account to identities from GitHub:

gcloud iam service-accounts add-iam-policy-binding github-ci@my-project-id.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/1234567890/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo
This allows only the my-org/my-repo GitHub repo to impersonate the github-ci service account.

Set up GitHub Secrets or Variables

To use this config securely in your GitHub Actions workflow, store these values as repository-level secrets or variables:

Name

Value

GCP_PROJECT_ID

my-project-id

GCP_PROJECT_NUMBER

1234567890 (use gcloud projects describe)

GCP_SERVICE_ACCOUNT

github-ci

GCP_WORKLOAD_IDENTITY_POOL

github-pool

GCP_WORKLOAD_IDENTITY_PROVIDER

github-provider

These will be used in your workflow like:

workload_identity_provider: "projects/${{ env.GCP_PROJECT_NUMBER }}/locations/global/workloadIdentityPools/${{ env.GCP_WORKLOAD_IDENTITY_POOL }}/providers/${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}"
service_account: "${{ env.GCP_SERVICE_ACCOUNT }}@${{ env.GCP_PROJECT_ID }}.iam.gserviceaccount.com"

GitHub Actions Workflow Breakdown

Let’s break down the GitHub Actions YAML workflow step by step. This workflow is defined in .github/workflows/ (for example, docker-build.yml). It runs on Ubuntu and uses Docker tooling and Google’s auth action.

Trigger and Permissions: The workflow is set to run on certain events – in our case, on pushing a Git tag that matches a semantic version (e.g. “1.2.3”) and on pull requests to the main branch. We only want to push images for release tags, not for every commit, which is why the trigger is configured this way. In the workflow’s jobs: section, we have something like:

on:
  push:
    tags: ["*.*.*"]    # e.g., 1.2.3 release tags
  pull_request:
    branches: ["main"]

Under the build job, we specify the runner and permissions:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

Giving contents: read allows the job to checkout code, and id-token: write is crucial for Workload Identity Federation. The id-token: write permission lets GitHub Actions mint an OIDC token for the workflow, which we will use to authenticate to Google Cloud. Without this, Google Cloud wouldn’t trust the job to assume the service account. Now let’s go through the steps within the job:

Step 1: Checkout and Set Up Docker Buildx

    - name: Checkout repository
      uses: actions/checkout@v4

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

Checkout pulls down the repository code so we have the Dockerfile and all source files available in the runner workspace. Setup Buildx installs and configures Docker Buildx, an extended builder for Docker that supports multi-platform builds and caching features. Buildx will allow us to build images efficiently and even for multiple architectures if needed. By the end of this step, we have our source code and a Docker build environment ready.

Step 2: Authenticate to Google Cloud (Workload Identity Federation)

Next, we authenticate the GitHub Actions runner to Google Cloud without using any secret keys:

    - name: Authenticate to Google Cloud
      id: auth
      uses: google-github-actions/auth@v2
      with:
        workload_identity_provider: "projects/${{ env.GCP_PROJECT_NUMBER }}/locations/global/workloadIdentityPools/${{ env.GCP_WORKLOAD_IDENTITY_POOL }}/providers/${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}"
        service_account: "${{ env.GCP_SERVICE_ACCOUNT }}@${{ env.GCP_PROJECT_ID }}.iam.gserviceaccount.com"
        token_format: access_token
        access_token_lifetime: 600s

This step uses Google’s official auth action to perform Workload Identity Federation login. Let’s break down the important inputs:

  • workload_identity_provider – This is the full resource name of the WIF provider we set up (as mentioned in step 6 of the GCP setup). By providing this, the action knows which identity pool/provider to target for authentication.
  • service_account – The email of the service account to impersonate (the one we created for CI). The action will request access to act as this service account via the provider.
  • token_format: access_token – By default, the auth action will fetch short-lived credentials. Specifying access_token means it will directly provide an OAuth 2.0 access token for the service account. This token will be available as steps.auth.outputs.access_token for subsequent steps. (If omitted, it might provide an ID token by default or require configuration – using an access token is convenient for Docker login.)

Under the hood, this action takes the OIDC token from GitHub and exchanges it with Google’s Security Token Service to get a Google Cloud access token for the service account. Thanks to our WIF setup, Google trusts the GitHub token (if it matches our configured repository and other conditions) and issues a short-lived token for the service account. Essentially, our GitHub Actions runner is now authenticated as the service account for the remainder of the job. We did this without any passwords or JSON keys – it’s a keyless auth flow.

Step 3: Log in to Google Artifact Registry (Docker Registry Auth)

Now that we have credentials, we need to authenticate Docker to our Artifact Registry, so we can push the image:

    - name: Login to Artifact Registry
      uses: docker/login-action@v3
      with:
        registry: "${{ env.GCP_REGION }}-docker.pkg.dev"
        username: "oauth2accesstoken"
        password: "${{ steps.auth.outputs.access_token }}"

Docker Login is necessary because Artifact Registry is a private container registry. We use the Docker login action to log in to our registry domain.

The registry is set to region-docker.pkg.dev (for example, us-central1-docker.pkg.dev if your Artifact Registry is in us-central1). All Artifact Registry Docker repositories use the format <region>-docker.pkg.dev. The username is a fixed value oauth2accesstoken when using an OAuth token for Google Artifact Registry. The password is the access token we obtained in the previous step (steps.auth.outputs.access_token). This token serves as the authentication bearer token for Docker.

By executing this step, the GitHub runner is now logged in to your Artifact Registry as the service account, and it has permission to push images (because we granted the service account that role).

(At this point, our workflow is authenticated with Google Cloud and Docker is logged in. We’re ready to build and push the Docker image.)

Step 4: Extract Docker Metadata (Tags & Labels)

Before building the image, the workflow uses a metadata action to automatically determine Docker image tags and labels based on the Git context (like the Git tag or commit):

    - name: Extract Docker metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: ${{ env.GCP_REGION }}-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.GAR_REPOSITORY }}/${{ env.IMAGE_NAME }}

This action examines the Git ref and event, and generates standardized Docker tags and labels for our image. We provide the base image name (which includes our Artifact Registry hostname, project, repository, and desired image name). The metadata action will output variables like tags and labels (and a combined JSON) that encapsulate things like: the semantic version tag (if the event is a version tag), a latest tag if applicable, the commit SHA, and standard OCI labels (like git commit, build timestamp, etc.), depending on the configuration. We haven’t specified a custom tags pattern here, so it will apply its defaults. For example, if the workflow was triggered by a tag 1.2.3, it may produce that as a tag (and possibly 1.2 or 1 if configured for semver). If it’s a push to a branch, it might produce a latest tag or no tag, and always a sha tag as fallback. The exact behavior can be tuned via inputs, but by default the Docker metadata action provides an “automatic” tagging scheme based on the GitHub context.

The key takeaway is that this step sets up the steps.meta.outputs.tags and steps.meta.outputs.labels that we will use in the build step. It simplifies managing image tags and labels derived from Git refs, so we don’t have to hard-code tag names in our workflow.

Step 5: Build and Push the Docker Image

    - name: Build and push Docker image
      uses: docker/build-push-action@v6
      with:
        context: .
        push: ${{ github.event_name != 'pull_request' }}
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

When this step runs, Docker will build the image according to the Dockerfile, tagging it as specified, and (if push: true) push those tags to the Artifact Registry repository. Because we logged in earlier, the push will be authenticated as our service account.

After this, our Artifact Registry will have a new image pushed (for example, asia-east2-docker.pkg.dev/<PROJECT_ID>/<REPO>/<IMAGE_NAME>:1.2.3). If the workflow was triggered by tag 1.2.3, that tag will exist in AR. If we configured a latest tag or others, those will be updated accordingly. Pull request builds, on the other hand, would build the image (to ensure the Dockerfile and context are valid) but skip pushing.

Understanding Workload Identity Federation

Workload Identity Federation allows GitHub Actions to securely impersonate a Google Cloud service account using short-lived tokens, as illustrated above. The diagram shows the high-level flow of how GitHub Actions and Google Cloud federate identity without a JSON key:

  1. GitHub OIDC Token: When our GitHub Actions job runs and requests an OIDC token (thanks to permissions: id-token: write), GitHub’s OIDC provider (token.actions.githubusercontent.com) issues a signed token about the workflow. This token includes information (claims) like the repository name, workflow, environment, etc., that identify who is requesting access.
  2. Token Exchange via STS: The google-github-actions/auth step sends this OIDC token to Google Cloud’s Security Token Service (STS), which is configured through our Workload Identity Provider. Because we set up the trust, Google Cloud verifies the token (ensuring it’s from GitHub, and matches our expected repository and conditions). If all checks out, STS issues a short-lived access token for the Google service account that we specified.
  3. Impersonation of Service Account: The GitHub Actions runner receives the Google access token and can now act as the service account for allowed operations (in our case, pushing to Artifact Registry). The token usually lasts for about 1 hour by default, and after use, it expires – which is good for security.

This federation approach means we never expose a long-lived key in our repo or secrets. Credentials are ephemeral and obtained at build time. There are additional benefits: we can fine-tune the trust so that only a specific repository (and even specific branches or workflows) can use the provider to assume the service account. We’ve effectively constrained the scope so only our CI from our repo can get these credentials. And if a token were somehow intercepted, it’s short-lived and limited in scope, reducing the risk window.

In summary, Workload Identity Federation bridges GitHub and Google Cloud identities in a secure way. As Google’s official documentation notes, this avoids turning an identity management problem into a secrets management problem – no static secrets needed. The GitHub Actions workflow itself, like we configured, is enough to perform authentication each time it runs.

Conclusion

By configuring this GitHub Actions workflow, we achieved an automated build-and-release process for Docker images with Google Cloud Artifact Registry. When you push a new version tag in Git, the workflow will checkout the code, authenticate to Google Cloud (via Workload Identity Federation), log in to Artifact Registry, then build and push the Docker image with the appropriate tags and labels. Pull request builds go through the motions but won’t push an image, serving as a validation step.

This setup is both secure and convenient. We no longer rely on storing JSON service account keys; instead, we use short-lived tokens and Google’s identity federation, which is considered a security best practice. Remember to replace placeholder values (like the region, project ID, repository name, and image name) with your actual values in the workflow, and adjust any specifics (e.g., build args or tags strategy) to fit your project’s needs. It’s also recommended to keep sensitive identifiers (project numbers, service account emails, etc.) in GitHub secrets rather than hard-coding them in the workflow file, especially if your repository is public.

With everything in place, each release you create (by pushing a tag) will result in a new container image pushed to Google Artifact Registry, without any manual steps. This continuous delivery pipeline is efficient and leverages modern cloud identity features to keep things safe. Happy building and deploying!