secured-remote-docker-build/README.md
Lukas Schaefer 7aeef62a39
Implement auth for git repo
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-09-03 21:16:26 -04:00

7.4 KiB
Raw Permalink Blame History

Docker Build Service

Small HTTP service for CI (e.g. Forgejo Actions) that builds images with BuildKit and pushes them to a registry. Callers authorize builds with a JWT signed by a shared secret.

The API container does not mount the host Docker socket. It talks to BuildKit only over a Unix socket on a shared Docker volume (buildkit-socket). Nothing else on the host or compose project mounts that volume, so other containers cannot reach BuildKit. Build steps use BuildKits default isolated network.

Flow:

  1. Prepare the build context
  2. Verify sha256(Dockerfile) matches the JWT claim
  3. buildctl build (BuildKit) with push=true using registry credentials from the request

Quick start

cp .env.example .env
# set JWT_SECRET to a long random value

docker compose up --build

Health check: GET http://localhost:8080/health

Configuration

Variable Default Description
JWT_SECRET (required) HS256 secret used to verify build tokens
GIT_HOST (required) Only host used for clones
BUILDKIT_DNS (optional) Real upstream DNS IP for RUN steps (replaces Dockers 127.0.0.11, which BuildKit otherwise turns into 8.8.8.8)
BUILDKIT_HOST unix:///run/buildkit/buildkitd.sock BuildKit socket address
BUILDKIT_PRUNE_KEEP_DURATION 168h Keep cache used within this window; older unused cache is pruned by buildkit-helper
BUILDKIT_PRUNE_INTERVAL_SECONDS 86400 How often buildkit-helper runs prune (86400 = daily)
BUILD_SERVICE_PORT 8080 Host port mapped to the API
GIT_CLONE_TIMEOUT_SECONDS 300 Max clone duration
BUILD_TIMEOUT_SECONDS 1800 Max build/push duration
JWT_ALGORITHM HS256 JWT algorithm
MAX_UPLOAD_BYTES 209715200 Max total uploaded file bytes (200 MB)
MAX_FILE_BYTES 52428800 Max single uploaded file bytes (50 MB)
MAX_MANIFEST_FILES 512 Max paths in JWT files claim

JWT

Tokens must be signed with JWT_SECRET and include:

Claim Description
repo owner/name (cloned from https://<git_host>/<owner>/<name>.git when a git commit is used)
dockerfile Path to the Dockerfile inside the build context (no ..)
dockerfile_sha256 Hex SHA-256 of the Dockerfile bytes that will be built
exp Expiry (standard JWT claim)

For requests that include file uploads, the JWT must also include:

Claim Description
files Non-empty list of relative file paths. Uploaded paths must match this list exactly.

File parts sent without a JWT files claim are ignored.

Arbitrary git URLs are rejected. Optional but recommended: keep TTL short (e.g. 15 minutes). Mint tokens from a trusted issuer that has the secret — not from untrusted CI jobs if you can avoid it.

Mint a token

pip install PyJWT

JWT_SECRET='your-secret' python scripts/mint_token.py \
  --repo 'org/app' \
  --dockerfile Dockerfile \
  --hash-from ./Dockerfile \
  --ttl-minutes 15

For file uploads, add --files with the paths you will send:

JWT_SECRET='your-secret' python scripts/mint_token.py \
  --repo 'org/app' \
  --dockerfile Dockerfile \
  --hash-from ./Dockerfile \
  --files Dockerfile src/app.py \
  --ttl-minutes 15

--hash-from can also be an http(s) URL to the Dockerfile. Or pass the digest directly with --dockerfile-sha256 instead of --hash-from:

JWT_SECRET='your-secret' python scripts/mint_token.py \
  --repo 'org/app' \
  --dockerfile Dockerfile \
  --dockerfile-sha256 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' \
  --ttl-minutes 15

With docker:

docker exec -it build-service python scripts/mint_token.py \
  --repo 'org/app' \
  --dockerfile Dockerfile \
  --hash-from https://git.lschaefer.xyz/lukasdotcom/actions-tester/raw/branch/main/Dockerfile \
  --ttl-minutes 600000

CI action (other repos)

Composite action for Forgejo / GitHub Actions — call the build API with uses: instead of hand-rolled curl. Repo: https://git.lschaefer.xyz/lukasdotcom/docker-build-container.

- name: Trigger buildkit build
  uses: https://git.lschaefer.xyz/lukasdotcom/docker-build-container@<sha-or-tag>
  with:
    url: http://build-kit.local:8080/build
    token: ${{ secrets.BUILD }}
    commit: ${{ needs.build-caddy.outputs.dist_commit }}
    tags: |
      lukasdotcom/image:latest
      lukasdotcom/image:${{ needs.build-caddy.outputs.dist_commit }}
    username: lukasdotcom
    password: ${{ secrets.DOCKER }}
    # optional — private repo clone
    repo_username: oauth2
    repo_password: ${{ secrets.GIT_TOKEN }}

tags is a newline- or comma-separated list of full image refs. For private repos, pass repo_username and repo_password together (git credentials for the clone; not the same as registry username / password).

To overlay files (multipart), pass files — newline-separated paths used as both the local file and the context path (JWT must include a matching files claim):

    files: |
      Dockerfile
      dist/app
    commit: ${{ github.sha }}   # optional with uploads

The action only needs bash and curl (no Python).

API

POST /build

Git commit

Shallow-fetches commit from https://<git_host>/<owner>/<name>.git.

{
  "token": "<jwt>",
  "commit": "a1b2c3d4e5f6789012345678901234567890abcd",
  "tags": [
    "lukasdotcom/app:1.0.0",
    "lukasdotcom/app:latest"
  ],
  "username": "registry-user",
  "password": "registry-password",
  "repo_username": "oauth2",
  "repo_password": "<git-token>"
}
curl -N -sS http://localhost:8080/build \
  -H 'Content-Type: application/json' \
  -d '{
    "token": "'"$TOKEN"'",
    "commit": "'"$COMMIT"'",
    "tags": ["lukasdotcom/app:latest"],
    "username": "user",
    "password": "pass",
    "repo_username": "oauth2",
    "repo_password": "'"$GIT_TOKEN"'"
  }'
Field Description
token JWT with repo, dockerfile, and dockerfile_sha256
commit Git commit SHA to build (740 hex chars)
tags One or more image refs to build and push (registry/name:tag; safe charset only; max 16)
remote Optional. Registry host for auth. Defaults to https://index.docker.io/v1/ (Docker Hub)
username / password Registry credentials
repo_username / repo_password Optional. Git credentials for private clones (both required together)

File upload

Content-Type: multipart/form-data. Clones commit, then overlays the uploaded files. The JWT files claim must list exactly the uploaded paths. Optional repo_username / repo_password form fields work the same as in JSON.

curl -N -sS http://localhost:8080/build \
  -F "token=$TOKEN" \
  -F "commit=$COMMIT" \
  -F 'tags=["lukasdotcom/app:latest"]' \
  -F "username=$USER" \
  -F "password=$PASS" \
  -F "files=@./Dockerfile;filename=Dockerfile" \
  -F "files=@./src/app.py;filename=src/app.py"

Each files part uses its multipart filename as the relative path.

On success, the response is a chunked text/plain stream of buildctl progress (--progress=plain) as the build and push run. Use curl -N (or similar) so the client does not buffer the body.

JWT / clone / upload / Dockerfile path or hash errors return normal JSON error responses before streaming starts. If the BuildKit build fails after the stream has begun, the HTTP status stays 200 and the stream ends with a line like [build-service] ERROR: ....