Container Images
Pull a signed Chio image, run it as a non-root sidecar, check its health, and stop it without losing a receipt.
On this page
By step 4, a Chio sidecar runs in front of your pricing service from a signed two-architecture image, as a non-root user. It answers its liveness and readiness routes, and drains on docker stop before any admitted quote loses its receipt.
Every release publishes signed images to ghcr.io/backbay-labs: chio-sidecar, a musl-linked chio on Alpine with tini as PID 1; chio, the CLI image; chio-proof-room; and chio-tee, the confidential-node runner. The image overview compares them. The steps assume the pricing-service example (chio new pricing-service --example pricing-service) running on port 8000.
rpublished :9090Kchio-sidecar :9090dupstream :8080draingrace
The sidecar starts in front of a running upstream
docker run mounts openapi.yaml read-only and starts chio-sidecar in front of an upstream already listening on 8080. A missing spec or a receipt store that cannot open exits non-zero at startup, and /chio/health answers 200 with the receipt and revocation backends once the kernel is ready. Worked example.
1. Pull a release and verify its signature
Pin a version tag. The assembly job signs the multi-architecture manifest by digest with keyless cosign, so verification names the workflow's own identity and the GitHub OIDC issuer.
$ : "${CHIO_VERSION:?Set a release version, such as 0.2.0}"
$ docker pull ghcr.io/backbay-labs/chio-sidecar:$CHIO_VERSION
$ cosign verify ghcr.io/backbay-labs/chio-sidecar:$CHIO_VERSION \
--certificate-identity-regexp 'https://github\.com/backbay-labs/chio/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comVerification for ghcr.io/backbay-labs/chio-sidecar:0.2.0 -- The following checks were performed on each of these signatures: - The cosign claims were validated - Existence of the claims in the transparency log was verified offline - The code-signing certificate was verified using trusted certificate authority certificates
You should see all three checks pass. One signature covers every tag that points at the content-addressed manifest, and the manifest lists exactly linux/amd64 and linux/arm64. To build the images yourself, read Build from source.
2. Run the sidecar in front of your service
The image's default command is --help, so pass the subcommand. Give /var/lib/chio a writable volume, keep the root filesystem read-only, and set a stop timeout longer than the drain.
$ docker run -d --name chio-sidecar \
--read-only --stop-timeout 30 \
-p 9090:9090 \
-v chio-state:/var/lib/chio \
-v "$(pwd)/pricing-service/openapi.yaml:/etc/chio/spec/openapi.yaml:ro" \
ghcr.io/backbay-labs/chio-sidecar:$CHIO_VERSION \
--authority-seed-file /var/lib/chio/authority.seed \
api protect \
--upstream http://host.docker.internal:8000 \
--spec /etc/chio/spec/openapi.yaml \
--listen 0.0.0.0:9090 \
--receipt-store /var/lib/chio/receipts.db3f9c1e7a2b4d8e6f0a1c5b7d9e2f4a6c8b0d1e3f5a7c9b2d4e6f8a0c1b3d5e7f
You should see the container id. From here every POST /v1/quote that reaches port 9090 is checked against the caller's grant before the pricing handler runs. The sidecar exits non-zero at startup when the spec is missing or the receipt store cannot open, so a running container has both. The seed under the volume keeps one signing key across restarts; Secrets & Signing Keys moves it into host custody. On Linux, add --add-host host.docker.internal:host-gateway.
3. Check liveness and readiness
$ curl -fsS http://localhost:9090/chio/live{"status":"healthy","version":"0.1.0","receipt_backend":"","revocation_backend":""}You should see healthy. /chio/live is process-only: it answers 200 while the process runs, so a dependency blip never recycles a serving container. /chio/health is readiness: it names the receipt and revocation backends and returns 503 once the receipt store can no longer persist, which takes the instance out of rotation.
Point container liveness at /chio/live and routing readiness at /chio/health. The Alpine images declare no HEALTHCHECK, so the orchestrator probes both routes. The distroless variant, which the figure follows, builds one in: its HEALTHCHECK curls /chio/live every 10 seconds, with a 5-second timeout, a 15-second start period, and 3 retries.
4. Stop the container with time to drain
$ docker stop chio-sidecar
$ docker inspect --format '{{.State.ExitCode}}' chio-sidecarchio-sidecar 0
You should see the name, then exit code 0. On SIGTERM the sidecar stops accepting connections, lets admitted requests finish and write receipts, flushes the receipt log, drains its signing task, and exits. In the Alpine images tini forwards the signal; in the distroless image the binary runs as PID 1 and handles it.
The drain lasts the upstream hop ceiling plus a 5-second margin: 25 seconds with the default --upstream-timeout-secs of 20. The --stop-timeout 30 from step 2 covers it. Docker's default stop timeout is 10 seconds, so without the flag SIGKILL lands mid-drain and calls in flight end without receipts; lower --upstream-timeout-secs to shorten the drain instead.
The images run as non-root users on a read-only root
The three Alpine images run as chio:chio, a system user at uid and gid 10001 with /sbin/nologin as its shell. The distroless image sets USER 65532:65532, the distroless:nonroot default, explicitly so a manifest that introspects the image sees it. The reference ECS task definition sets "readonlyRootFilesystem": true and "user": "65532:65532" on the sidecar; /var/lib/chio is the one path that needs a writable mount.
The shipped Kubernetes manifests drop every Linux capability. The controller deployment sets capabilities: drop: [ALL] with allowPrivilegeEscalation: false and readOnlyRootFilesystem: true, and every container in the reference market template drops ALL. One initialization container runs as uid 0 to fix file ownership and adds back CHOWN, DAC_OVERRIDE, and FOWNER; no container that runs the Chio binary does. CAP_NET_BIND_SERVICE matters only below port 1024, and the sidecar's --listen default is 127.0.0.1:9090.
Version tags are stable; latest follows the default branch
The workflow runs on every push to main and on every v*.*.* tag. A tag push must match a full semver expression before anything builds; a non-conforming tag exits with sidecar-image refuses non-semver release tag. A release tag publishes X.Y.Z and X.Y, every push publishes a short-SHA tag, and latest moves only on the default branch.
Tag pushes never move latest. The workflow's comment names the case: tagging v0.1.1 after v0.2.0 would otherwise move latest back to the v0.1.1 image, since nothing checks version order. Pin a semver tag; the full pattern list is under Tag patterns.
Container failures show at start, at stop, or at a shell
docker run prints usage and exits
The default command is --help. chio run and chio mcp serve-http both need --policy plus positional input, so no zero-argument default is meaningful, and a bare docker run prints usage instead of failing before a health endpoint opens. Pass the subcommand, as the ECS and Cloud Run manifests do.
Calls in flight end without receipts at stop
The platform's stop timeout or grace period is shorter than the drain. Raise it above 25 seconds, or lower --upstream-timeout-secs.
docker exec finds no shell
Distroless images carry no shell, so docker exec -it ... sh has nothing to run. Reproduce on an Alpine image, or read the structured logs the process emits.
A demo container accepts demo-token
chio-trust-demo and chio-mcp-demo fall back to demo-token when the environment sets no token, so a bare docker compose up brings a working demo online. Keep them inside a development network, and deploy the published chio-sidecar image with secrets-backed environment values.
Images, stages, and the release build
Try chio-trust-demo, CHIO_TEE_MODE, --locked, or semver.
Image overview
| Image | Binary | Runtime base | User | EXPOSE |
|---|---|---|---|---|
chio, chio-proof-room | chio; chio-proof-room on the Proof Room image | alpine:3.22, digest-pinned | chio:chio (10001) | 8940, 8931, 7391, one per demo stage |
chio-sidecar | chio | alpine:3.22, digest-pinned | chio:chio (10001) | none; the port comes from --listen |
chio-tee | chio-tee | alpine:3.22, digest-pinned | chio:chio (10001) | none |
chio-sidecar, distroless variant | chio-sidecar | gcr. | 65532:65532 | 9090 |
Every image is compiled with one Rust toolchain, the channel the release pins (1.94.1, workspace rust-version 1.94), from a builder base pinned by a single @sha256: digest. Every Chio release passes the rust-toolchain-parity gate before it is tagged, which fails the build when any builder drifts from that channel or digest. chio release verify --evidence qualification shows its result for the release you run. The blocks below quote the files the images are built from.
The default kernel image
The chio image is built against a pruned workspace manifest, so it compiles only the crates the CLI resolves.
# Chio CLI image. Builds the `chio` binary plus the optional trust/MCP demo
# stages. Base images are digest-pinned to match Dockerfile.sidecar /
# Dockerfile.tee; bump the digests in lockstep with those files.
ARG RUST_VERSION=1.94.1
ARG ALPINE_VERSION=3.22
ARG NODE_VERSION=22
FROM rust:${RUST_VERSION}-alpine${ALPINE_VERSION}@sha256:797631f9efd6957d0013f200e410478c380907eee3b469c6f80d89022df28bc7 AS rust-builder
RUN apk add --no-cache build-base cmake openssl-dev pkgconf
WORKDIR /workspace
COPY deploy/docker/chio-workspace/Cargo.toml ./Cargo.toml
COPY deploy/docker/chio-workspace/Cargo.lock ./Cargo.lock
COPY .cargo ./.cargo
COPY crates ./crates
COPY examples/chio-3vendor/fixtures/runtime-spine/scenario.json ./examples/chio-3vendor/fixtures/runtime-spine/scenario.json
COPY fixtures/proof-room ./fixtures/proof-room
COPY spec ./spec
COPY wit ./wit
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/workspace/target,sharing=locked \
cargo metadata --format-version 1 --locked >/dev/null \
&& cargo build --profile docker-release --locked -p chio-cli --bin chio \
&& cp target/docker-release/chio /chioA second builder stage produces chio-proof-room the same way, and a third builds the receipt dashboard that the trust-control demo stage and the Proof Room serve.
Deployable stages
The default build has four runnable stages. chio is the published CLI image and the base the two demo stages extend; the Proof Room stage is published as chio-proof-room.
| Target | EXPOSE | What it runs |
|---|---|---|
chio | none | ENTRYPOINT ["/sbin/tini", "--", "chio"] with CMD ["--help"]. |
chio-trust-demo | 8940 | chio trust serve over four SQLite stores under /var/lib/chio, with the dashboard dist/ copied in. |
chio-mcp-demo | 8931 | chio mcp serve-http wrapping a python3 mock MCP server, pointed at the trust demo. |
chio-proof-room | 7391 | chio-proof-room serving the single- bundle with a doctor report. |
Pull commands
Each published image is signed the same way as the sidecar, so the cosign verify expression in step 1 checks any of them.
docker pull ghcr.io/backbay-labs/chio:0.2.0
docker pull ghcr.io/backbay-labs/chio-proof-room:0.2.0
docker pull ghcr.io/backbay-labs/chio-tee:0.2.0The two demo stages run the trust service and an MCP edge with a fallback token for local experiments. They are built from source, together, from the Build from source instructions.
Demo stage environment
| Variable | Default | Read by |
|---|---|---|
CHIO_ | demo-token | chio-trust-demo |
CHIO_ | http://chio-trust-demo:8940 | chio-mcp-demo |
CHIO_ | demo-token | chio-mcp-demo |
CHIO_AUTH_TOKEN | demo-token | chio-mcp-demo |
The Proof Room image carries the fixture trust file at /etc/chio/fixture-trust.toml and starts with --trust /etc/chio/fixture-trust.toml. Those pins are the fixture bundle's signers, so the image verifies only the bundle it ships until you mount your own trust file and set CHIO_TRUST.
The sidecar image
chio-sidecar is built from deploy/docker/Dockerfile.sidecar. An Alpine Rust builder produces a stripped, musl-linked chio, and a minimal Alpine runtime carries that binary, tini, and CA roots. The builder copies the full workspace tree because the root Cargo.lock resolves every workspace member, and the file's comments record why each toolchain package is present.
# Chio sidecar image. See deploy/SIDECAR_BUILD_GUIDE.md for build/run/deployment details.
ARG RUST_VERSION=1.94.1
ARG ALPINE_VERSION=3.22
############################
# Stage 1: build the binary
############################
FROM rust:${RUST_VERSION}-alpine${ALPINE_VERSION}@sha256:797631f9efd6957d0013f200e410478c380907eee3b469c6f80d89022df28bc7 AS builder
# `protoc` is required because chio-envoy-ext-authz (transitively reachable
# through the workspace) invokes tonic-build at compile time. CI installs
# `protobuf-compiler` for the same reason; keep this image consistent so
# any future dep change that pulls chio-envoy-ext-authz into chio-cli's
# transitive graph does not silently break the Docker build.
# `openssl-dev` + `openssl-libs-static` are required because the
# `chio-custody-hw` member pulls in `webauthn-rs-core`, which links
# against `openssl-sys` (no `vendored` feature on the workspace pin). The
# crate is reachable from chio-cli through `chio-kernel -> chio-custody-hw`,
# so `cargo build --package chio-cli` compiles it. Static linking
# (`OPENSSL_STATIC=1`) keeps the runtime layer slim: the final alpine
# image only ships `ca-certificates` + `tini` and never gains a
# `libssl.so` runtime dep.
RUN apk add --no-cache build-base cmake perl pkgconf musl-dev protoc openssl-dev openssl-libs-static
ENV OPENSSL_STATIC=1 \
OPENSSL_NO_VENDOR=1 \
PKG_CONFIG_ALL_STATIC=1The vendored Envoy protos that tonic-build consumes live under crates/protocol/chio-envoy-ext-authz/proto, so the stage's COPY crates ./crates line covers them and no top-level COPY proto is needed.
The sidecar build step
# Build with the workspace's container-specific profile, which uses thin LTO
# instead of the memory-heavy fat LTO release profile. Keep Cargo at one job so
# the native GitHub ARM runner stays within its memory envelope while compiling
# and linking the full chio-cli dependency graph.
RUN cargo build --profile docker-release --locked --jobs 1 --package chio-cli --bin chio \
&& strip target/docker-release/chio \
&& cp target/docker-release/chio /chioThe docker-release profile inherits release and replaces its lto = "fat" and codegen-units = 1 with lto = "thin" and codegen-units = 16, and --jobs 1 keeps Cargo to one compile at a time. Both bound peak memory so the arm64 build fits the native GitHub ARM runner it compiles on.
The sidecar runtime stage
The runtime stage starts from a digest-pinned alpine:3.22, installs only ca-certificates and tini, adds a chio user and group at uid and gid 10001, and creates /var/lib/chio and /etc/chio owned by that user. It sets CHIO_HOME=/var/lib/chio and RUST_LOG=info, then ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/chio"] with CMD ["--help"]. The rust:alpine builder makes the binary musl-linked, so the final layer needs no dynamic libgcc or libstdc++.
The TEE image
chio-tee mirrors the sidecar build. It compiles the chio-tee binary with --locked, strips it, and lands on the same digest-pinned alpine:3.22 runtime with ca-certificates and tini. It exposes no port. The runtime stage creates /var/lib/chio/tee, /etc/chio, and /run/chio-tee, all owned by chio:chio, and sets ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/chio-tee"] with the same CMD ["--help"]. Confidential Node covers the attestation flow and the replay modes.
TEE image environment
| Variable | Default | Purpose |
|---|---|---|
CHIO_HOME | /var/lib/chio | Working directory for spool and persisted state. |
CHIO_TEE_CONFIG | /etc/ | Path to the TEE config file inside the image. |
CHIO_TEE_MODE | verdict-only | Replay mode: emit the verdict, not the inputs. |
RUST_LOG | info | Tracing filter for the runner. |
The distroless sidecar variant
The distroless variant builds the same chio binary from a Debian rust:slim-bookworm builder, installs it as /usr/local/bin/chio-sidecar, and ships it on gcr.io/distroless/cc-debian12:nonroot. The builder is glibc, so cc-debian12 provides the C runtime the binary links against.
# The sidecar is the `chio` binary from chio-cli, run as `chio api protect`
# (reverse proxy) or `chio mcp serve-http` (MCP edge). If a dedicated
# chio-sidecar bin is introduced, replace the --bin arg accordingly.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release --locked -p chio-cli --bin chio \
&& mkdir -p /out \
&& cp /build/target/release/chio /out/chio-sidecarIt is the one file of the four that uses BuildKit cache mounts for the cargo registry and the target directory. A middle stage makes a health probe possible: distroless carries no shell and no curl, so a throwaway debian:bookworm-slim stage installs curl, resolves the multiarch directory from dpkg-architecture -qDEB_HOST_MULTIARCH, and copies curl and its shared libraries into /out with cp -L, so no symlink dangles. Deriving the directory instead of hard-coding x86_64-linux-gnu lets the same file build on amd64 and arm64.
The runtime stage sets USER 65532:65532, EXPOSE 9090, CHIO_LOG_LEVEL=info, and a HEALTHCHECK that curls http://localhost:9090/chio/live. The file gives the reason for that route: liveness recycles a container only when the process itself is unreachable, so a dependency blip reaching /chio/health leaves a serving container running. ENTRYPOINT is ["/usr/local/bin/chio-sidecar"] with the same CMD ["--help"].
Build context
The CLI and Proof Room stages copy a pruned manifest and only what it resolves; the sidecar and TEE images copy the full workspace, because compile-time macros read the WIT interfaces, contract ABIs, and schemas the workspace carries. The copy list for each file is on Build from source.
Every build passes --locked
Every cargo build in the four files passes --locked, and the two pruned-workspace stages run cargo metadata --format-version 1 --locked first, so a lock that no longer resolves fails before the compile starts. The image therefore matches the SBOM attached to it, with no transitive dependency picked up at build time.
How the published image is assembled
.github/workflows/sidecar-image.yml builds natively on a two-entry matrix: linux/amd64 on ubuntu-24.04 and linux/arm64 on ubuntu-24.04-arm, one platform per job, with no docker/setup-qemu-action step. The arm64 runner's memory ceiling is why Dockerfile.sidecar builds with thin LTO at one job.
On a push, each job builds with push-by-digest=true, writes its digest to an artifact, and stops. A separate assemble-and-sign job downloads both digests, continues only if there are exactly two, runs docker buildx imagetools create to build one tagged manifest list over them, then reads the manifest back and fails unless it lists exactly linux/amd64 and linux/arm64. Pull requests run the same build step with push: false, so a smoke build neither pushes nor signs.
Both build steps set provenance: mode=max and sbom: true, and both cache through type=gha under a per-architecture scope, so the two matrix legs never share a cache entry. Workspace advisory policy is deny.toml, checked in a separate CI job.
Tag patterns
The repository name is lower-cased from the owner login, giving ghcr.io/backbay-labs/chio-sidecar. Five patterns go to docker/metadata-action.
| Pattern | Trigger | Example |
|---|---|---|
type=ref,event=branch | Push to a branch | main |
type=semver,pattern={{version}} | Tag push vX.Y.Z | 0.2.0 |
type=semver,pattern={{major}}.{{minor}} | Tag push vX.Y.Z | 0.2 |
type=sha,format=short | Every push | sha-1a2b3c4 |
type=raw,value=latest,enable={{is_ | Default branch only | latest |
Supply-chain signing
The assembly job holds id-token: write and signs keyless with cosign through Sigstore Fulcio: the OIDC token is exchanged for a short-lived certificate, and no long-lived key sits in secrets. It signs the assembled manifest by digest, which covers every tag on that manifest, and refuses to sign if assembly produced no digest. The certificate identity is the workflow's own reference, so the verification expression names the backbay-labs organization that org.opencontainers.image.source carries on every image.
# Sign the digest, not the tag.
ref="${IMAGE_NAME}@${IMAGE_DIGEST}"
cosign sign --yes "${ref}"