How Do I Authenticate to a Private Registry?

Run one login step in the job before anything pulls or pushes an image, using a registry token from secrets or short lived credentials from an OIDC exchange.

Add one login step to the job before any step pulls or pushes an image, and give that step either a registry token stored as a repository secret or short lived credentials obtained from an OpenID Connect exchange with your cloud provider. The login writes a credential into the Docker config on the runner, every later step in the same job reads it from there, and the credential goes away with the runner.

The choice between the two paths decides what your repository stores. A token secret is a long lived string you have to rotate and revoke by hand. An OIDC exchange stores nothing and trades a signed statement about the running job for credentials that expire on their own.

Answer

Path one: a token from repository secrets. docker/login-action runs docker login against a registry host with a username and a password or token. For GitHub Container Registry the password can be the per job GITHUB_TOKEN, which needs packages: write in the workflow or job permissions block (working with the container registry).

name: publish-image

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  publish:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

      - name: Log in to the registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          profile-name: super-fast-builder

For a registry outside GitHub, keep the same step and swap the three inputs. The packages: write scope comes off, because the credential now comes from your own secrets rather than from GITHUB_TOKEN:

      - name: Log in to the registry
        uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_TOKEN }}

Path two: an OIDC exchange for a cloud registry. The job asks GitHub for an identity token describing the repository, branch, and workflow it is running from, then trades that token with the cloud provider for temporary credentials. Requesting the token needs id-token: write on the job (security hardening with OpenID Connect). Nothing about the cloud account is stored in the repository; the scoping lives in a trust policy on the cloud side that names one repository and one ref.

name: publish-image-oidc

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

      - name: Exchange the OIDC token for AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-actions-ecr-push
          aws-region: us-east-1

      - name: Log in to Amazon ECR
        id: ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push
        uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}
          profile-name: super-fast-builder

Two steps rather than one, because a cloud registry splits the work. The first step performs the token exchange and puts role credentials in the environment. The second step calls the registry's authorization API with those credentials and runs docker login with what comes back (aws-actions/amazon-ecr-login). Azure Container Registry and Google Artifact Registry follow the same shape with their own exchange and login actions.

Pricing on WarpBuild is purely usage based. The runner in the examples above, warp-ubuntu-latest-x64-4x, is $0.008 per minute and the 16 vCPU builder profile is $0.06 per minute, both figures from the pricing page, checked on 2026-08-13.

Detail

What the login step actually writes

docker login does not hold a session open. It writes an entry keyed by registry host into ~/.docker/config.json on the machine running the command, and every later docker pull, docker push, and buildx build reads that file to find a credential for the host in the image reference. The credential itself is exchanged again per request: the registry answers an unauthenticated call with 401 and a WWW-Authenticate: Bearer header naming a scope such as repository:acme/api:pull,push, the client trades the stored credential for a bearer token carrying exactly that scope, and the request is retried. The container registry glossary entry covers that handshake and the API routes a push and a pull touch.

Two consequences follow from the credential living in a file on the runner. Order matters, so the login step goes above anything that resolves a private reference. And scope is per host, so a workflow that pulls a base image from one registry and pushes the result to another runs two login steps.

WarpBuild does not access or store build secrets. Each runner is its own virtual machine, created on demand and destroyed after the build, so the Docker config that the login step wrote is destroyed with it (security documentation).

The permission scope each path needs

Declaring a permissions block at the workflow or job level sets every scope you do not list to none, so the block is the whole grant for GITHUB_TOKEN in that job (workflow syntax reference).

PathJob permissionsStored in the repositoryCredential lifetime
GitHub Container Registry with GITHUB_TOKENcontents: read, packages: writeNothing; the token is minted for the jobEnds with the job, 24 hours at most
Any registry with a username and token secretcontents: readThe token, as a repository or organization secretUntil you rotate it
Cloud registry through an OIDC exchangecontents: read, id-token: writeNothing; a trust policy names the repositoryOne hour by default on AWS, up to the role maximum

The GITHUB_TOKEN lifetime comes from automatic token authentication. The AWS session length comes from AssumeRoleWithWebIdentity, and the ECR authorization token that amazon-ecr-login writes is separately valid for 12 hours (ECR registry authentication).

The middle row is the one that carries standing risk. A registry token in repository secrets stays valid whether or not a workflow is running, works from any machine that holds a copy, and has to be rotated on a schedule someone owns. The OIDC row removes that object entirely: the token GitHub mints is bound to one run, the cloud side verifies its signature against GitHub's published keys, and the trust policy compares the sub claim against one repository and one ref before handing back anything. The AWS OIDC answer walks through the role and trust policy setup.

Where the login goes when the build runs on a remote builder

A remote Docker builder is a separate VM reached over TLS, and it holds no registry credentials of its own. The login step still runs in the job, on the runner, above the build step. Buildx sends the registry auth from the runner's Docker config to the builder as part of the build session, which is what lets the builder resolve a private FROM line and push the finished tags.

That ordering also holds when the runner is not a WarpBuild runner. In that case the job carries a second, unrelated credential: the WarpBuild API key that assigns the builder, which is not required on WarpBuild runners (Docker builders documentation).

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Log in to the registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Configure the WarpBuild builder
        uses: Warpbuilds/docker-configure@v1
        with:
          api-key: ${{ secrets.WARPBUILD_API_KEY }}
          profile-name: super-fast-builder

      - name: Build and push
        run: |
          docker buildx build \
            --push \
            --tag ghcr.io/${{ github.repository }}:${{ github.sha }} \
            .

Builder profiles keep their layer cache on the builder disk, so cache-from and cache-to registry references are not needed on this path and there is no third registry credential to arrange for them. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the product surface, and profile sizes and rates are in the remote Docker builder catalog. The Docker builds on GitHub Actions solution covers the swap from a runner local build.

Failures worth recognizing

Pull succeeds and push returns 403 or denied. The credential authenticated but the scope it received does not include push. On the GITHUB_TOKEN path this is a missing packages: write in the permissions block. On a token secret path it is a read only token.

A pull request from a fork cannot push. GITHUB_TOKEN is read only for workflows triggered by a fork pull request, and repository secrets are not exposed to those runs, so both paths fail by design. Publish from push on a protected branch or from a pull_request_target workflow you have reviewed.

The build fails on the FROM line. The base image lives in a private registry that the job never logged in to. Add a login step for that host as well; two logins for two hosts is normal.

The OIDC exchange returns Not authorized to perform sts:AssumeRoleWithWebIdentity. The token was minted but the trust policy did not match it. Compare the policy condition against the sub claim the run actually produced, remembering that the claim differs between a branch push, a tag, and a job that declares an environment.

Does the login step have to run before the build step?

Yes. The login step writes the credential into the Docker config on the runner, and every later step reads it from there. A build step placed above the login step fails as soon as it resolves a private base image or pushes a tag. The container registry glossary entry explains what the client is doing at each of those moments.

Where does the login happen when the build runs on a remote Docker builder?

In the job, on the runner, above the build step. The builder VM holds no registry credentials of its own, and buildx sends the auth from the runner's Docker config to the builder over the build session. Profile sizes and per minute rates are in the remote Docker builder catalog.

Which permissions does each path need?

Pushing to GitHub Container Registry with GITHUB_TOKEN needs contents: read and packages: write. A username and token secret needs no extra permission scope, because the credential comes from secrets. An OIDC exchange needs contents: read and id-token: write, and the trust policy on the cloud side does the rest of the scoping. The AWS OIDC answer shows that policy.

Set up the login step against the runner catalog in the Docker builds on GitHub Actions solution, size a builder profile on the pricing page, and read the isolation model for build secrets in the security documentation.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.