---
id: platform/ci-image-deploy
title: CI deploys a tested image digest
description: Build and test one container image in GitHub Actions, publish its immutable digest, and deploy that digest to an image-backed Bex service.
keywords: [bex, ci, github actions, docker, image digest, deploy]
sidebar_position: 42
last_updated: 2026-09-23
---

# CI deploys a tested image digest

Use this workflow when CI must promote the **same artifact it tested**, not a
second rebuild or a mutable `:latest` tag. The downloadable sample lives at
[`/examples/ci-image-deploy/`](/examples/ci-image-deploy/README.md)
(Node 22+, no npm dependencies) and in the
[examples catalog](/examples?task=deploy). Source pin: Bex `0eb2036e2`.

Repository-backed services are a different path: Bex rebuilds from Git on each
trigger and rejects `imageUrl`. See [GitHub integration](./github-integration.md)
and [How deploys work](./how-deploys-work.md). This guide is only for an
**existing image-backed** service.

## Prerequisites

Confirm these against your installation before copying the workflow:

| Requirement | Where |
| --- | --- |
| Image-backed service (`runtime: image`) already created | [Docker deploys](./docker-deploys.md) |
| Registry **pull** credential attached for private images | [Docker deploys](./docker-deploys.md) |
| Machine API key (`id` + `secret`) with workspace **developer** role | [REST & GraphQL API](./api.md) |
| Registry **push** access for CI (for example GHCR via `GITHUB_TOKEN`) | GitHub package settings |
| Billing / payment readiness when the platform requires it | [Billing setup](./billing-setup.md) |

Push permission and Bex pull credentials are separate. CI can push while the
service still fails to pull if the workspace registry credential is missing.

## Download the sample

```bash
curl -fsSLO https://bex.co/examples/ci-image-deploy-latest.zip
curl -fsSLO https://bex.co/examples/ci-image-deploy-latest.zip.sha256
shasum -a 256 -c ci-image-deploy-latest.zip.sha256
unzip ci-image-deploy-latest.zip
cd ci-image-deploy
```

## Configure GitHub

1. Repository variable `BEX_SERVICE_ID` — the image-backed service id.
2. Optional variables `BEX_API_ORIGIN` / `BEX_OAUTH_TOKEN_URL` for self-hosted.
3. Secrets `BEX_KEY_ID` and `BEX_KEY_SECRET` from the dashboard API Keys page.
4. Actions permission to write packages when using GHCR.

The workflow concurrency group is scoped to
`bex-image-deploy-<repository>-<BEX_SERVICE_ID>` with
`cancel-in-progress: false`. Overlapping pushes for that service queue in
GitHub Actions. That does **not** disable dashboard deploys, deploy hooks, or
other pipelines. Canceling a GitHub job does not cancel or roll back a Bex
deployment that already started.

The sample builds `linux/amd64` only. Do not publish a multi-platform manifest
unless every listed platform is tested.

## What the workflow does

1. **Build once** into a local tag (`ci-candidate:local`).
2. **Test that image** (`docker run … node --test`). A failing test stops the job;
   there is no `always()` deploy step.
3. **Tag and push the same local image**, then resolve its registry
   `@sha256:…` digest (no second application rebuild).
4. Run `deploy.mjs` with `BEX_IMAGE_URL=<name>@sha256:…`.

`deploy.mjs` exchanges the machine key for a bearer token, posts
`{ "imageUrl": "…" }` to `POST /v1/services/{id}/deploys`, records the returned
deployment id, and polls **only that id** until status `live` or a terminal
failure. Mutable tag-only refs are rejected before any POST.

## Offline verification

```bash
BEX_CI_FIXTURE=1 \
  BEX_IMAGE_URL='ghcr.io/example/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
  node deploy.mjs
node scripts/handoff-harness.mjs
```

These fixtures exercise token and deploy request shapes against a loopback
stack. They do **not** push images or create hosted deployments.

## Recovery

| Symptom | Next step |
| --- | --- |
| Image tests fail | Fix the app; deploy is not invoked |
| `image_url_requires_sha256_digest` | Pass `name@sha256:<64-hex>`, not `:latest` |
| `token_exchange_failed` / `deploy_post_auth_failed` | Renew key; confirm developer role and API origin ([API](./api.md)) |
| `deploy_post_failed:400` mentioning repo-backed | Service must be image-backed; use commit deploy for Git services |
| Registry pull / `update_failed` | Check service registry credential and [logs](./logging.md) |
| Pre-deploy failure | Inspect `preDeployStatus` and pre-deploy logs |
| `deploy_post_ambiguous` | Do **not** blind-retry POST; list deploys for a matching image, then decide |
| Deadline exceeded | Raise `BEX_CI_DEADLINE_MS` only after checking [deploy status](./how-deploys-work.md) |
| Need prior revision | [Rollbacks](./rollbacks.md) — not automatic on CI cancel |

## Related

- [Docker deploys](./docker-deploys.md)
- [How deploys work](./how-deploys-work.md)
- [GitHub integration](./github-integration.md)
- [REST & GraphQL API](./api.md)
- [User-authorized OAuth](./oauth-integrations.md) (browser consent; different from machine keys)
