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. Specifyingaccess_token
means it will directly provide an OAuth 2.0 access token for the service account. This token will be available assteps.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:
- 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.
- 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.
- 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!