# Build Image

> Use when turning a Git repository into a container image with Zeabur CI/CD — a one-off build or a reusable branch source. Use when the user says "build my repo into an image", "deploy my own code" (build first, then deploy), or "rebuild that branch". Works with public repos and private ones (HTTPS token or SSH deploy key), on any Git host — not just GitHub. Talks to the GraphQL API directly; no CLI needed.

- Skill: `zeabur/build-image` (Agent Skill)
- Install (CLI): `npx skillmds@latest add zeabur/build-image`
- Raw SKILL.md: https://api.skillmd.com/api/skills/zeabur/build-image/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: zeabur (https://skillmd.com/u/zeabur)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/zeabur/build-image

---


# Zeabur Build Image

Zeabur CI/CD clones a Git repo, builds it into a container image (Dockerfile or auto-detected), and pushes it to Zeabur's registry. You get back an `imageReference` you can pull from anywhere — a VPS running k3s or docker, or any other machine. No Zeabur project is created.

Set up the `zapi` helper from the **auth** skill first.

**One difference from the other toolkit skills:** the CI/CD API derives the owner from the token — there is **no `ownerID` argument**, so builds always belong to the token's own account (a team workspace cannot be selected).

## Source URL and authentication must match

| Repo | `authentication.type` | URL shape |
|------|----------------------|-----------|
| Public | `PUBLIC` | `https://<host>/<owner>/<repo>` (`.git` optional) |
| Private over HTTPS | `HTTPS` + `{username, password}` (password = token/PAT) | `https://<host>/<owner>/<repo>` |
| Private over SSH | `SSH` + `{privateKey}` (deploy key) | `git@<host>:<owner>/<repo>.git` or `ssh://git@<host>/...` |

Any public Git host works (GitHub, GitLab, Bitbucket, self-hosted…). Rejected regardless of type: credentials embedded in the URL, plain `http://`, IP addresses, localhost. Credentials are encrypted server-side, used once for the build, and can never be read back through the API — but they must still transit the mutation, so read them from an env var or file instead of pasting them into the conversation.

## One-off build

`region` is the build-cluster location, not your server's — `hkg1` is the safe default (an unsupported value returns `INVALID_ARGUMENT: Unsupported build region`). Always pass an `idempotencyKey`: retrying with the same key returns the original build instead of starting (and paying for) a second one.

```bash
OP_ID=$(uuidgen)
zapi "$(jq -n --arg op "$OP_ID" '{query: "mutation($in: CreateCICDBuildInput!){ createCICDBuild(input: $in) { id status region createdAt } }", variables: {in: {region: "hkg1", url: "https://github.com/<owner>/<repo>", ref: "main", authentication: {type: "PUBLIC"}, idempotencyKey: $op}}')"
```

- `ref` accepts a branch, tag, or commit SHA.
- `dockerfile` — omit it and the builder auto-detects/generates one (the result shows up as `resolvedDockerfile` on the build); set it only for a non-default path.
- `rootDirectory` — relative subdirectory for monorepos (no absolute paths, no `..`).
- `buildVariables: [{key, value}]` — build-time variables. Keys must match `[A-Za-z_][A-Za-z0-9_]*` and be unique. **Never put secrets here**: unlike Git credentials, build variables are stored in plaintext and are readable back through the API by anyone who can query the build.

For a private repo, replace the authentication object:

```bash
--arg tok "$GIT_TOKEN" … authentication: {type: "HTTPS", https: {username: "<user>", password: $tok}}
--arg key "$(cat ./deploy_key)" … authentication: {type: "SSH", ssh: {privateKey: $key}}
```

## Poll until it finishes

**A successful mutation is not a successful start** — dispatch failures surface asynchronously as `FAILED`. Poll every ~10 seconds; builds are hard-capped at 30 minutes:

```bash
zapi '{"query":"query($id: ID!){ cicdBuild(id: $id) { status imageReference resolvedDockerfile failureSummary startedAt finishedAt } }","variables":{"id":"<cicd_…>"}}' | jq '.data.cicdBuild'
```

`status` runs `QUEUED → RUNNING → SUCCEEDED | FAILED | CANCELLED`. There is no build-log API yet — on `FAILED`, `failureSummary` is all the diagnostics you get. To abort a build: `mutation($id: ID!){ cancelCICDBuild(id: $id) }`.

If you buffer the response in a shell variable inside a polling loop, print it with `printf '%s\n' "$RESP"`, not `echo` — `resolvedDockerfile` and `failureSummary` are multi-line, and `echo` under `sh`/`zsh` expands their `\n` escapes into raw newlines, breaking `jq` (see the **auth** skill). Piping `zapi … | jq` directly avoids the problem entirely.

**Save the build `id`** — there is no API to list past builds.

## Use the image

`imageReference` looks like `<registry>/cicd/o-<owner>@sha256:…` — digest-pinned, and pullable **without any registry credentials**. On a k3s server just reference it (no imagePullSecret), and `docker pull` works the same on a compose/docker machine:

```bash
kubectl create deployment myapp --image='<imageReference>' --port=<port>
```

The flip side of anonymous pull: treat the image as public — anyone who learns the reference can pull it, so don't bake secrets into the image. Image retention is not guaranteed forever; deploy soon after building. To wire up the service, ingress, and domain, continue with the **deploy** skill (steps after the workload) or **kubernetes-workloads** / **subdomain**.

## Reusable source — rebuild a branch on demand

When the user will rebuild the same branch repeatedly, register it once (`ref` must be a **branch** here — tags/commits are one-off-build only):

```bash
zapi "$(jq -n '{query: "mutation($in: CreateCICDSourceInput!){ createCICDSource(input: $in) { id url ref region generation } }", variables: {in: {region: "hkg1", url: "https://github.com/<owner>/<repo>", ref: "main", authentication: {type: "PUBLIC"}}}}')"
```

Then each rebuild is one call (returns a full build to poll; `buildVariables` are not supported on this path):

```bash
zapi '{"query":"mutation($in: TriggerCICDSourceInput!){ triggerCICDSource(input: $in) { id status } }","variables":{"in":{"sourceID":"<cicdsrc_…>"}}}'
```

- List: `cicdSources(first, after)` (forward-only pagination); latest build per source: `cicdSourceLatestBuild(sourceID, status)` — `null` just means "never built".
- Update: `updateCICDSource` requires the current `expectedGeneration` (stale value → `ABORTED`, re-read and retry). Omitted fields keep their value; empty-string `rootDirectory`/`dockerfile` resets to the default; omitted `authentication` keeps the stored credential, explicit `PUBLIC` clears it. The `url` is immutable — new repo means a new source.
- There are **no automatic Git triggers yet** (no webhooks/polling) — rebuilds happen when you call `triggerCICDSource`.

## When the API pushes back

| Error | Meaning / next move |
|-------|--------------------|
| `PERMISSION_DENIED` | CI/CD is not enabled for this account — direct the user to Zeabur support/dashboard. |
| `RESOURCE_EXHAUSTED` | Concurrent-build limit hit (often 1) — wait for the active build to finish, then retry. |
| `FAILED_PRECONDITION: The Git source could not be resolved` | The ref doesn't exist, the repo is private but auth was `PUBLIC`, or the credentials are wrong. Verify with `git ls-remote` locally. |
| `ALREADY_EXISTS` | Same `idempotencyKey` reused with **different** inputs — generate a fresh key. |

