Chio/Docs
LOGIN · JOIN

PlatformConfiguration & Custody

Node

Container Images

Four reference Dockerfiles, what each stage builds, the ports they expose, and how the release workflow assembles and signs the image it pushes.

Source

Read out of deploy/docker/Dockerfile, deploy/docker/Dockerfile.sidecar, deploy/docker/Dockerfile.tee, deploy/sidecar/Dockerfile, .github/workflows/sidecar-image.yml, and scripts/check-rust-toolchain-parity.py.

Image overview

DockerfileBinaryRuntime baseUserEXPOSE
deploy/docker/Dockerfilechio, plus chio-proof-room on one stagealpine:3.22, digest-pinnedchio:chio (10001)8940, 8931, 7391, one per demo stage
deploy/docker/Dockerfile.sidecarchioalpine:3.22, digest-pinnedchio:chio (10001)none; the port comes from --listen
deploy/docker/Dockerfile.teechio-teealpine:3.22, digest-pinnedchio:chio (10001)none
deploy/sidecar/Dockerfilechio-sidecargcr.io/distroless/cc-debian12:nonroot65532:655329090

Every builder stage in all four files starts from rust:${RUST_VERSION} with a single @sha256: digest, and scripts/check-rust-toolchain-parity.py fails the build when one drifts. It reads the channel out of rust-toolchain.toml (currently 1.94.1) and requires each file to carry exactly one ARG RUST_VERSION set to it, each Rust FROM to interpolate that argument, and the three Alpine builders to share one lockstep digest. The same check ties the workspace rust-version (1.94) to the channel and to the pruned manifest under deploy/docker/chio-workspace/. A version on this page would be a fifth copy of that number, so there is none: the blocks below are the files.


The default kernel image

deploy/docker/Dockerfile builds against a pruned workspace manifest rather than the repository root one. Its Rust builder copies a separate deploy/docker/chio-workspace/ Cargo.toml and Cargo.lock pair, then only the trees that pruned workspace resolves against.

deploy/docker/Dockerfile2-27dockerfile
# 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 /chio

A second builder stage, proof-room-builder, repeats the pattern against deploy/docker/proof-room-workspace/ and produces chio-proof-room. A third, dashboard-builder, runs node:22-alpine to build the crates/products/chio-cli/dashboard Vite SPA that two of the deployable stages serve.

Deployable stages

Four stages are runnable images. chio is the base the two demo stages extend; the proof-room stage is built from alpine directly because it ships a different binary.

TargetEXPOSEWhat it runs
chiononeENTRYPOINT ["/sbin/tini", "--", "chio"] with CMD ["--help"].
chio-trust-demo8940chio trust serve behind four SQLite stores under /var/lib/chio, with the dashboard dist/ copied in.
chio-mcp-demo8931chio mcp serve-http wrapping a python3 mock MCP server, pointed at the trust demo.
chio-proof-room-quickstart7391chio-proof-room serving the first-run/single-call-authority bundle with a doctor report.

Build

bash
# Build the bare CLI image (default target).
docker build -f deploy/docker/Dockerfile -t chio:cli .

# Build the trust-control demo image (dashboard included).
docker build -f deploy/docker/Dockerfile --target chio-trust-demo -t chio:trust-demo .

# Build the MCP-server demo image.
docker build -f deploy/docker/Dockerfile --target chio-mcp-demo -t chio:mcp-demo .

# Build the proof-room quickstart image.
docker build -f deploy/docker/Dockerfile --target chio-proof-room-quickstart -t chio-proof-room:local .

The first three targets are wired together by examples/docker/compose.yaml, which builds all three from this one Dockerfile and gates the MCP demo on the trust demo reporting healthy.

Environment defaults on the demo stages

VariableDefaultRead by
CHIO_SERVICE_TOKENdemo-tokenchio-trust-demo
CHIO_CONTROL_URLhttp://chio-trust-demo:8940chio-mcp-demo
CHIO_CONTROL_TOKENdemo-tokenchio-mcp-demo
CHIO_AUTH_TOKENdemo-tokenchio-mcp-demo

The proof-room stage instead bakes fixture trust anchors as ENV defaults: CHIO_PROOF_ROOM_TRUSTED_RECEIPT_KERNEL_KEYS, CHIO_PROOF_ROOM_TRUSTED_BUNDLE_SIGNER_KEYS, CHIO_TRANSACTION_TRUSTED_ROOT_KEYS, CHIO_SWARM_TRUSTED_WITNESS_KEYS, CHIO_DISCLOSURE_TRUSTED_LINEAGE_SIGNER_KEYS, and CHIO_DISCLOSURE_TRUSTED_CRYPTO_CONTEXT_REPORT_SIGNER_KEYS, alongside CHIO_PROOF_ROOM_UI_DIR and CHIO_PROOF_FIXTURE_ROOT. Those keys are the fixture bundle’s signers, so the image verifies the bundle it ships and nothing else.

The demo stages carry development defaults

chio-trust-demo and chio-mcp-demo fall back to demo-token when the environment does not set a token, so a bare docker compose up brings a working demo online. Keep them inside a development network, and build deployed sidecars from deploy/docker/Dockerfile.sidecar with secrets-backed environment values.

The sidecar image

deploy/docker/Dockerfile.sidecar is the file the release workflow builds. It has two stages: an Alpine Rust builder that produces a stripped, musl-linked chio, and a minimal Alpine runtime carrying 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 own comments record why each toolchain package is present.

deploy/docker/Dockerfile.sidecar2-27dockerfile
# 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=1

The vendored Envoy protos that tonic-build consumes live under crates/protocol/chio-envoy-ext-authz/proto, so the stage’s COPY crates ./crates line already covers them and no top-level COPY proto is needed.

The build step

deploy/docker/Dockerfile.sidecar87-93dockerfile
# 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 /chio

Two choices here shape the release pipeline. The 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 is compiled on.

The 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"]. Because the builder is rust:alpine, the binary is musl-linked and the final layer needs no dynamic libgcc or libstdc++.

CMD is ["--help"]. The Dockerfile states the reason: both chio run and chio mcp serve-http need --policy plus positional input, so there is no meaningful zero-argument default and a bare docker run prints usage instead of exiting non-zero before a health endpoint opens. Every deployment overrides it with a real subcommand; the ECS and Cloud Run manifests show the shape.


The TEE image

deploy/docker/Dockerfile.tee mirrors the sidecar build pattern and produces chio-tee instead. It copies the same workspace trees, builds with cargo build --release --locked --package chio-tee --bin chio-tee, strips, and lands on the same digest-pinned alpine:3.22 runtime with ca-certificates and tini. It exposes no port.

bash
docker build -f deploy/docker/Dockerfile.tee -t chio-tee:local .

Environment defaults

VariableDefaultPurpose
CHIO_HOME/var/lib/chioWorking directory for spool and persisted state.
CHIO_TEE_CONFIG/etc/chio/tee.tomlPath to the TEE config file inside the image.
CHIO_TEE_MODEverdict-onlyReplay mode: emit the verdict, not the inputs.
RUST_LOGinfoTracing filter for the runner.

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"] fallthrough. See Confidential Node for the attestation flow and the replay modes.


The distroless sidecar variant

deploy/sidecar/Dockerfile 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 the C runtime the binary links against is what cc-debian12 provides.

deploy/sidecar/Dockerfile53-60dockerfile
# 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-sidecar

This is the only one of the four that uses BuildKit cache mounts for the cargo registry and the target directory. A middle stage exists purely to make a health probe available: distroless carries no shell and no curl, so a throwaway debian:bookworm-slim stage installs curl, resolves the Debian multiarch directory from dpkg-architecture -qDEB_HOST_MULTIARCH, and copies curl and its shared-library dependencies into /out with cp -L so no symlink dangles in a runtime that lacks the versioned target. Deriving the directory rather than hard-coding x86_64-linux-gnu is what 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 every 10 seconds. The file names the reason for that route: container liveness should recycle only when the process itself is unreachable, so a dependency blip reaching /chio/health must not restart a serving container. ENTRYPOINT is ["/usr/local/bin/chio-sidecar"] with the same CMD ["--help"].

No exec into a distroless container

Distroless images have no shell, so docker exec -it ... sh has nothing to run. Reproduce on the Alpine variant instead, or read the structured logs the process already emits.

Build strategy

Two copy lists, two reasons

The default Dockerfile copies a pruned manifest and only what that manifest resolves: the deploy/docker/chio-workspace/ pair, .cargo/, crates/, one examples/ fixture file, fixtures/proof-room, spec/, and wit/. It copies neither formal/, tests/, sdks/, bench/, contracts/, integrations/, nor xtask/, because the pruned workspace does not declare them.

Dockerfile.sidecar and Dockerfile.tee copy the repository-root Cargo.toml and Cargo.lock and every top-level directory the root workspace declares as a member or contains one in. Their comments name the reasons individually: wit/ is read at compile time by chio-wasm-guards through wasmtime::component::bindgen!; contracts/ is embedded by chio-web3-bindings through Alloy’s sol! macro; spec/ holds schemas that control-plane handlers embed with include_str!; fixtures/ holds proof-room assets a build script embeds; and bench/, examples/, formal/, tests/, sdks/, integrations/, and xtask/ are workspace members or hold them, so leaving one out breaks manifest loading rather than the compile.

Every build passes --locked

Every cargo build invocation in these 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 rather than after it. An image that picked up newer transitive dependencies at build time would not be the image the SBOM attached to it describes.


How the published image is assembled

.github/workflows/sidecar-image.yml does not cross-build. It runs a two-entry matrix of native runners, linux/amd64 on ubuntu-24.04 and linux/arm64 on ubuntu-24.04-arm, and passes each job exactly one platform. There is no docker/setup-qemu-action step, and the memory ceiling the arm64 runner imposes is the reason 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, refuses to continue unless there are exactly two, runs docker buildx imagetools create to build one tagged manifest list over them, and then reads the manifest back and fails unless it lists exactly linux/amd64 and linux/arm64. Pull requests take the same build step with push: false, so a smoke build never pushes and never 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 hygiene is a separate CI concern; the active policy is deny.toml.


Image tagging

The workflow runs on every push to main and on any v*.*.* tag, and a tag push is checked against a full semver regular expression before anything builds: a non-conforming tag exits with sidecar-image refuses non-semver release tag. The repository name is lower-cased from the owner login, giving ghcr.io/backbay-labs/chio-sidecar. Five tag patterns go to docker/metadata-action:

The package is not anonymously pullable today

The registry path above is where CI pushes, not a public download.docker pull against it without credentials returns DENIED. Authenticate to ghcr.io with a GitHub token that can read the package, or build from deploy/docker/Dockerfile.sidecar yourself using the steps above. Treat every tag family below as a description of what CI produces rather than of what a reader can fetch unauthenticated.
PatternTriggerExample
type=ref,event=branchPush to a branchmain
type=semver,pattern={{version}}Tag push vX.Y.Z0.4.2
type=semver,pattern={{major}}.{{minor}}Tag push vX.Y.Z0.4
type=sha,format=shortEvery pushsha-1a2b3c4
type=raw,value=latest,enable={{is_default_branch}}Default branch onlylatest

latest does not track tag pushes

The workflow deliberately omits :latest on tag pushes, and its comment states the case it is avoiding: tagging v0.1.1 after v0.2.0 would otherwise move :latest back to the older image, since nothing checks version ordering. Versioned tags still ship through the two semver patterns, so pin to one of those.

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 pointing at that content-addressed manifest, and it refuses to sign at all if manifest assembly produced no digest.

bash
# Workflow step: sign the digest, not the tag.
ref="${IMAGE_NAME}@${IMAGE_DIGEST}"
cosign sign --yes "${ref}"

On the consumer side, the certificate identity is the workflow’s own reference, so the regular expression has to name the backbay-labs organisation that org.opencontainers.image.source carries on every one of these images:

bash
cosign verify ghcr.io/backbay-labs/chio-sidecar:latest \
  --certificate-identity-regexp 'https://github\.com/backbay-labs/chio/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Runtime restrictions

Non-root by default

  • The three Alpine images run USER chio:chio, a system user created at uid and gid 10001 with /sbin/nologin as its shell.
  • The distroless image runs USER 65532:65532, which is already the default in distroless:nonroot; the file states it explicitly so a manifest that introspects the image sees it.

Read-only root filesystem

The reference ECS task definition sets "readonlyRootFilesystem": true and "user": "65532:65532" on the sidecar container. The image writes under /var/lib/chio, so that path needs a writable mount and nothing else does.

Dropped Linux capabilities

The shipped Kubernetes manifests drop every capability. The controller deployment sets capabilities: drop: [ALL] alongside allowPrivilegeEscalation: false and readOnlyRootFilesystem: true, and every container in the reference market template drops ALL as well. One of them, an initialisation container that runs as uid 0 to fix file ownership, adds CHOWN, DAC_OVERRIDE, and FOWNER back; nothing running the Chio binary does. Adding CAP_NET_BIND_SERVICE back is only needed below port 1024, and the sidecar’s --listen default is 127.0.0.1:9090.


Next steps

  • Sidecar HTTP Service for the runtime configuration these images take: flags, environment, health endpoints, supervision.
  • Confidential Node for what the chio-tee binary does with the spool directories this image creates.
  • ECS Fargate for the task definition that sets the read-only root filesystem and the non-root user named above.
  • Cloud Run and Azure Container Apps for the two multi-container manifests that pull this image.
  • Kubernetes Admission for the CRD field that names a sidecar image for governed pods.
Container Images · Chio Docs