Skip to content

CI integration (agnostic) & public exposure

buildkit-operator is not tied to any CI system. The entire integration is: ask the control plane where to build, then point docker buildx there over mTLS. Anything that can run docker buildx and reach the buildkit-operator endpoints works the same — a GitHub-hosted runner, a GitLab runner, Jenkins, or a laptop. There is nothing GitHub/ARC-specific.

On GitHub, the integration is one step — setup-buildx, routing, mTLS, the warm cache, and the S3 cold cache are all handled by the Action:

jobs:
  build:
    runs-on: ubuntu-latest        # stock hosted runner — no self-hosted runner, no ARC
    permissions:
      id-token: write             # mint the OIDC identity token buildd verifies for /route
    steps:
      - uses: actions/checkout@v4
      - uses: socialgouv/buildkit-operator@v1
        with:
          buildd-url: ${{ vars.BUILDKIT_OPERATOR_BUILDD_URL }}
          ca:   ${{ secrets.BUILDKIT_OPERATOR_CA }}
          cert: ${{ secrets.BUILDKIT_OPERATOR_CERT }}
          key:  ${{ secrets.BUILDKIT_OPERATOR_KEY }}
          # No shared /route token: the Action mints a GitHub OIDC token (audience buildkit-operator)
          # that buildd verifies and binds to ${{ github.repository }}. Just grant id-token: write above.
          tags: ghcr.io/org/app:${{ github.sha }}
          push: "true"
          provenance: mode=max   # SLSA provenance …
          sbom: "true"           # … + SBOM, both generated by the daemon
          sign: "true"           # cosign keyless-sign (also uses id-token: write)

Inputs:

Input Meaning
buildd-url external buildd /route endpoint (LoadBalancer/Ingress) — required
ca / cert / key client mTLS material, PEM — required
tags image tag(s), whitespace-separated — required
repo project identity = the cache key (default: the GitHub repository). Ignored when buildd enforces OIDC — the verified repo claim is authoritative; used only on the legacy/admin path
name optional monorepo component (per-image daemon + cache; empty = whole repo)
arch amd64 | arm64 (default amd64)
context / file / target build context, Dockerfile path, target stage
build-args / labels build args / OCI labels, one KEY=VALUE per line — forwarded to buildx as --build-arg / --label (GitLab component: build_args / labels)
secrets build secrets, one id=value per line — forwarded to buildx as --secret id=…,env=…. Sent over the build session only: hashed into the cache key, never stored in a layer, the cache, or the image config
push push the result to the registry (default false)
oidc-audience audience of the minted GitHub OIDC token (default buildkit-operator; must match buildd's provider audience)
token legacy /route bearer, for a deployment with no OIDC. Leave empty otherwise — it proves the caller, not the project (see below)
admin-token break-glass admin token (X-Buildkit-Operator-Admin-Token) — bypasses OIDC; ops only
untrusted fork-PR build: ephemeral daemon, no write-back to the shared cache (default false)
gateway-ip map <daemon>.<gateway-host> → this gateway LB IP for the run (escape hatch when there is no wildcard DNS yet)
provenance / sbom / sign SLSA provenance (e.g. mode=max) / SBOM / cosign keyless-sign — all need push (and sign needs permissions.id-token: write)

The cold cache needs no client config: it is a project policy on buildd, returned by /route and applied automatically (see the S3 section below and storage-and-cold-cache.md).

GitLab CI/CD component

The GitLab counterpart is a reusable, importable CI/CD componenttemplates/build.yml (full docs in templates/README.md). It runs the same scripts/build.sh as the Action, so there is no duplicated build logic, and — because the build runs on the remote daemon — the job needs only docker buildx + curl + jq, no privileged docker:dind.

Set the mTLS material as masked/File group CI/CD variables (BUILDKIT_OPERATOR_BUILDD_URL, BUILDKIT_OPERATOR_CA/_CERT/_KEY). The /route credential is a GitLab-signed OIDC id_token the component mints automatically — no shared bearer to distribute (legacy/admin only: BUILDKIT_OPERATOR_ADMIN_TOKEN). Then:

include:
  - remote: "https://raw.githubusercontent.com/SocialGouv/buildkit-operator/v0.7.0/templates/build.yml"
    inputs:
      tags: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
      push: "true"

That generates a buildkit-operator-build job. Inputs mirror the Action (repo defaults to $CI_PROJECT_URL = the cache key; plus name, arch, context, dockerfile, target, untrusted, provenance, sbom, sign, …). If the repo is mirrored into a GitLab instance, the same file is consumable as a catalog component: include: { component: "$CI_SERVER_FQDN/<path>/build@<version>" }. Untrusted-MR builds and pre-warming are covered in templates/README.md.

Forgejo Actions

Forgejo Actions runs GitHub-format actions and mints the same kind of OIDC identity token, so buildkit-operator ships a reusable composite action — the Forgejo counterpart of the GitHub Action and the GitLab component: forgejo/action.yml. buildd verifies the Forgejo-signed token and binds the build to the verified repository claim, host-qualified with your instance host (cache key git.example.org/<owner>/<repo>, isolated from a same-named GitHub repo).

Two Forgejo-specific things (≠ GitHub), both validated end to end:

  • The OIDC opt-in is enable-openid-connect: true at the workflow or job level — not GitHub's permissions: id-token: write. Without it the runner does not inject ACTIONS_ID_TOKEN_REQUEST_URL, so no token is minted and the build falls back to the bearer/admin path.
  • It needs Forgejo ≥ 15.0 and a forgejo-runner > v12.5 (older runners — e.g. 6.x — simply don't inject the OIDC token).
# .forgejo/workflows/build.yml
jobs:
  build:
    runs-on: ubuntu-latest
    enable-openid-connect: true          # Forgejo's OIDC opt-in (NOT permissions: id-token: write)
    container:
      image: docker:27                   # any image with docker buildx + curl + jq (the build runs remote)
    steps:
      - uses: actions/checkout@v4
      - uses: https://github.com/socialgouv/buildkit-operator/forgejo@v1
        with:
          buildd-url: ${{ vars.BUILDKIT_OPERATOR_BUILDD_URL }}
          ca:   ${{ secrets.BUILDKIT_OPERATOR_CA }}
          cert: ${{ secrets.BUILDKIT_OPERATOR_CERT }}
          key:  ${{ secrets.BUILDKIT_OPERATOR_KEY }}
          # No shared /route token: the runner mints a Forgejo OIDC token (audience buildkit-operator)
          # that buildd verifies and binds to ${{ github.repository }}.
          tags: git.example.org/org/app:${{ github.sha }}
          push: "true"

The forgejo/ action uses no third-party nested actions (unlike the GitHub action.yml, which pulls docker/setup-buildx-action), so it works regardless of the instance's [actions].DEFAULT_ACTIONS_URL. The mTLS inputs accept raw PEM or base64 (handy: k8s Secret values are already base64). For a fully air-gapped runner you can skip the action and run scripts/build.sh directly — mint the token with one curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=buildkit-operator" and pass it as BUILDKIT_OPERATOR_TOKEN, exactly as the GitLab component does.

Buildd side is one provider entry. The issuer is the Forgejo Actions OIDC endpoint — the /api/actions subpath, not the instance root (the root .well-known/openid-configuration is the login IdP, a different issuer). buildd still host-qualifies the repo with the bare host, so the cache key is git.example.org/<owner>/<repo>:

oidc:
  providers:
    - type: forgejo                       # alias: gitea (same GitHub-compatible claim mapping)
      issuer: https://git.example.org/api/actions
      audience: buildkit-operator

The CI-agnostic core: scripts/build.sh

The Action is a thin wrapper around scripts/build.sh — a small POSIX script that any CI able to run docker buildx + curl + jq can call directly (it sends BUILDKIT_OPERATOR_TOKEN — an OIDC identity JWT or legacy bearer — or a BUILDKIT_OPERATOR_ADMIN_TOKEN in the admin header, releases the build via /complete on exit, and maps the gateway host when GATEWAY_IP is set):

# 1. route: ask buildd for this project's daemon endpoint
endpoint=$(curl -fsS -XPOST "$BUILDKIT_OPERATOR_BUILDD_URL/route" \
  -H 'content-type: application/json' \
  -d "{\"repo\":\"$REPO\",\"name\":\"$NAME\",\"arch\":\"$ARCH\"}" | jq -r .endpoint)

# 2. point buildx at it over mTLS (cert files written from BUILDKIT_OPERATOR_CA/CERT/KEY)
docker buildx create --name buildkit-operator --driver remote \
  --driver-opt "cacert=$certs/ca.pem,cert=$certs/cert.pem,key=$certs/key.pem" "$endpoint" --use

# 3. build — the S3 cold cache (if any) comes back in the /route response and is applied automatically
exec docker buildx build --builder buildkit-operator 

It reads its inputs from the environment (the Action maps its inputs to these):

Var Meaning
BUILDKIT_OPERATOR_BUILDD_URL external buildd /route endpoint (LB/Ingress)
BUILDKIT_OPERATOR_CA / _CERT / _KEY client mTLS material, PEM
REPO / ARCH project identity (defaults: git origin / amd64)
NAME optional monorepo component (segments the repo into per-image daemons; empty = whole repo)
TAGS / PUSH image tag(s), whitespace-separated / push the result
BUILD_CONTEXT / DOCKERFILE / TARGET build context, Dockerfile path, target stage

CI-agnostic by construction

The same scripts/build.sh runs unchanged on a GitLab runner, Jenkins, or a laptop — only the way the mTLS material reaches the job differs. The socialgouv/buildkit-operator-example repo demonstrates both a stock GitHub-hosted ubuntu-latest job and a .gitlab-ci.yml calling the same script, routing to the example daemon and exercising the S3 cache — and, end to end over the public internet, the full supply chain below.

Supply-chain attestations (SLSA provenance, SBOM, cosign)

The daemon — not the runner — produces the attestations, so they cost nothing on the CI side beyond turning them on. With push: "true":

  • provenance: mode=max attaches a SLSA provenance attestation;
  • sbom: "true" attaches an SBOM (the daemon runs the syft scanner);
  • sign: "true" cosign keyless-signs the pushed image via the job's GitHub OIDC identity (the job must set permissions.id-token: write).

The verifier side is one cosign verify --certificate-identity-regexp … --certificate-oidc-issuer https://token.actions.githubusercontent.com. This whole path is exercised end to end by the example repo: build on a remote daemon → push to GHCR with provenance + SBOM → keyless sign → verify.

Public exposure (why and how)

A hosted runner is outside the cluster, so buildkit-operator must be reachable over the internet — exactly like buildkit-service (public LB + mTLS). Two LoadBalancers are exposed, and only two regardless of how many projects exist:

Endpoint Service Purpose
BUILDKIT_OPERATOR_BUILDD_URL buildkit-operator-buildd LoadBalancer :8080 the /route API
tcp://<daemon>.<gateway-host>:1234 the shared SNI gateway LoadBalancer :1234 the build, over mTLS, to any daemon

Daemons themselves stay ClusterIP. buildd is started with --gateway-host <domain> (Helm gateway.host), so /route returns the deterministic hostname tcp://<daemon>.<gateway-host>:1234; the single gateway LB peeks the TLS SNI and pipes to the daemon's ClusterIP Service — mTLS stays end-to-end. This needs a wildcard DNS record *.<gateway-host> → the gateway LB (see architecture.md).

No wildcard DNS yet? gateway-ip escape hatch. The gateway is an L4 SNI service, so an --source=service external-dns (or a manual record) is needed for *.<gateway-host> — not always available day one. Pass the Action's gateway-ip input (or env GATEWAY_IP for build.sh): the run maps <daemon>.<gateway-host> → that IP in /etc/hosts for its lifetime. SNI + mTLS are unchanged; only name resolution is short-circuited. Drop it once real wildcard DNS exists.

The /route API spins up daemons and binds the build's cache identity to its repo, so once it is reachable off-cluster it must do more than gate access — it must prevent a caller from impersonating another project. The strong, default posture is OIDC (oidc.providers): the CI job presents a forge-signed identity token, buildd verifies it and replaces the request's repo with the verified claim (and derives untrusted server-side). A leaked credential can therefore only ever build its own repo — never read or poison another project's cache. Every forge mints the token natively, so there is no extra runner egress:

  • GitHub Actions — grant the job permissions: id-token: write; the Action mints the token (audience from oidc-audience, default buildkit-operator) automatically. Nothing else to pass.
  • GitLab CI — the component declares an id_tokens: entry (audience from the oidc_audience input) and sends it as the /route credential. No shared bearer to distribute.
  • Forgejo Actions — the forgejo/ action mints the token, but the opt-in is enable-openid-connect: true on the job (Forgejo's spelling, not permissions: id-token: write); needs Forgejo ≥ 15.0 + forgejo-runner > v12.5. See the Forgejo section.

Configure providers in the chart (oidc.providers — built-in type: github / type: gitlab / type: forgejo), and optionally a hard org gate with oidc.repoAllowlist (a verified-but-unlisted repo gets 403).

The legacy bearer is not an equivalent option. auth.tokenSecret authenticates the caller but not the project: on that path the client declares its own repo, so any holder can build as — and poison the cache of — any project, and none of the repo-scoped checks apply. Keep it only for an in-cluster deployment with no OIDC available, and clear it as soon as callers can mint tokens. The reference deployment (ovh-prod) has none: a caller that gets 401 needs permissions: id-token: write, not a token. A break-glass admin token (oidc.adminTokenSecret, sent in the X-Buildkit-Operator-Admin-Token header via the admin-token input / BUILDKIT_OPERATOR_ADMIN_TOKEN) bypasses OIDC for the manual build CLI and ops — same caveat, same advice.

For public exposure prefer the TLS Ingress; the raw L4 service.type: LoadBalancer serves plain HTTP and the chart refuses it without an IP allowlist (service.loadBalancerSourceRanges). Either exposure needs authentication configured — oidc.providers or, failing that, auth.tokenSecret. The gateway needs no token of its own — it is guarded by mTLS — but caps pre-auth connections via gateway.maxConns.

Releasing a build. /route returns a buildId that the client echoes back on /complete. That id is what authorizes the release when the job's identity token has expired — forge tokens live minutes (GitHub's, about two) and a build does not, so requiring a live one would reject the release of every build longer than its own token and leak an in-flight entry each time. The shipped clients handle this; a hand-rolled caller should send the buildId it was given.

The certificate SAN requirement

mTLS validates the daemon's hostname, so the daemon certificate's SAN must cover the address the runner dials — which, through the gateway, is <daemon>.<gateway-host>. The cert script (deploy/cert/create-certs.sh) bakes in *.buildkit-builds.svc (the daemons' namespace); for public exposure run it with GATEWAY_HOST=<gateway-host> so it also adds the wildcard SAN *.<gateway-host> (one cert validates every daemon's SNI hostname). If the SAN is wrong you get TLS validation failures or context deadline exceeded. (The gateway terminates no TLS, so it needs no cert of its own — it only peeks the SNI; the trust stays end-to-end between the client cert and the daemon cert.)

S3 from CI — zero client config

The cold cache is a buildd policy, so a CI caller configures no S3 at all (no flags, no env, no secrets). When buildd is set up with a bucket (--s3-bucket …), /route returns the per-project cache reference (bucket/region/endpoint, prefix = the project key, no credentials) and the client adds --cache-from/--cache-to type=s3 automatically. The daemon performs the S3 I/O, so:

  • the endpoint can be in-cluster (http://minio.buildkit-builds.svc:9000) — unreachable from the runner, yet it works, because the in-cluster daemon connects;
  • the AWS creds live on the daemon pods (a k8s Secret via --s3-creds-secret), never on the runner and never on the wire.

The example CI log shows the daemon doing it: importing cache manifest from s3:…. See storage-and-cold-cache.md.

Endpoint shape

A deployment exposes exactly two endpoints, regardless of project count:

  • buildd /route: the buildkit-operator-buildd LoadBalancer on :8080 (set as BUILDKIT_OPERATOR_BUILDD_URL).
  • shared SNI gateway LB on :1234 — fronts every daemon; /route returns tcp://<daemon>.<gateway-host>:1234 (e.g. buildkitd-pa081c22c974da132.<gateway-host> for SocialGouv/buildkit-operator-example → key pa081c22c974da132).

The S3 cold cache, when enabled, is a buildd-side policy pointing at an S3-compatible endpoint (OVH Object Storage in production); CI callers never address it.