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.
GitHub Action (recommended path)
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 component —
templates/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: trueat the workflow or job level — not GitHub'spermissions: id-token: write. Without it the runner does not injectACTIONS_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=maxattaches 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 setpermissions.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-ipescape hatch. The gateway is an L4 SNI service, so an--source=serviceexternal-dns (or a manual record) is needed for*.<gateway-host>— not always available day one. Pass the Action'sgateway-ipinput (or envGATEWAY_IPforbuild.sh): the run maps<daemon>.<gateway-host>→ that IP in/etc/hostsfor its lifetime. SNI + mTLS are unchanged; only name resolution is short-circuited. Drop it once real wildcard DNS exists.
/route identity: OIDC (recommended) vs. bearer/admin
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 fromoidc-audience, defaultbuildkit-operator) automatically. Nothing else to pass. - GitLab CI — the component declares an
id_tokens:entry (audience from theoidc_audienceinput) and sends it as the/routecredential. No shared bearer to distribute. - Forgejo Actions — the
forgejo/action mints the token, but the opt-in isenable-openid-connect: trueon the job (Forgejo's spelling, notpermissions: 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: thebuildkit-operator-builddLoadBalancer on:8080(set asBUILDKIT_OPERATOR_BUILDD_URL). - shared SNI gateway LB on
:1234— fronts every daemon;/routereturnstcp://<daemon>.<gateway-host>:1234(e.g.buildkitd-pa081c22c974da132.<gateway-host>forSocialGouv/buildkit-operator-example→ keypa081c22c974da132).
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.