GitHub Actions

Mint the key

In the panel, create a project key for the project you are pushing to, scoped to the one repository the workflow builds:

text
repositories:push:acme/api

Give it an expiry. A year is reasonable for a CI key you will rotate deliberately; 90 days is better if you have the discipline.

Add it to the repository as an Actions secret named LAZER_KEY.

The workflow

yaml
name: build

on:
    push:
        branches: [main]

jobs:
    image:
        runs-on: ubuntu-latest
        permissions:
            contents: read
        steps:
            - uses: actions/checkout@v4

            - uses: docker/setup-buildx-action@v3

            - uses: docker/login-action@v3
              with:
                  registry: lazer.sh
                  username: ci
                  password: ${{ secrets.LAZER_KEY }}

            - uses: docker/build-push-action@v6
              with:
                  push: true
                  tags: |
                      lazer.sh/acme/api:${{ github.sha }}
                      lazer.sh/acme/api:latest
                  cache-from: type=gha
                  cache-to: type=gha,mode=max

The username is not checked when you present a key — the scopes decide. Use something descriptive so the audit trail reads well.

Multi-architecture

Add QEMU and a platform list. The push cost is the same because layers are deduplicated:

yaml
- uses: docker/setup-qemu-action@v3

- uses: docker/build-push-action@v6
  with:
      push: true
      platforms: linux/amd64,linux/arm64
      tags: lazer.sh/acme/api:${{ github.sha }}

Tagging by commit, and retention

Tagging every build with github.sha gives you a precise rollback target, but it also means a tagged image per commit forever. Pair it with a retention rule — keep the last 50 tagged, expire untagged after 7 days — and the cost stays flat. See retention.

Pulling in a later job

Deploy jobs need a separate key scoped to pull. Do not reuse the push key:

yaml
- uses: docker/login-action@v3
  with:
      registry: lazer.sh
      username: deploy
      password: ${{ secrets.LAZER_PULL_KEY }}

If the push fails

denied: insufficient scope means the key's scope does not cover the repository in the tag — check for a typo in the project or repository segment. denied: quota exceeded means the project is out of allocation. Both fail fast, before any layer uploads.