diff --git a/.dockerignore b/.dockerignore
index 8ccc08e554..8ce14320ca 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,7 +1,54 @@
+# Build outputs
 target/
+**/target/
+desktop/src-tauri/target/
+
+# Native non-relay UI builds (not part of the public image)
 desktop/
+mobile/
+
+# Node artifacts (built inside the image; host outputs would invalidate cache)
+node_modules/
+**/node_modules/
+web/dist/
+
+# VCS, IDE, scratch
 .git/
+.github/
+.gitignore
+.gitattributes
+.vscode/
+.idea/
 .scratch/
-*.md
-!README.md
-.env*
+
+# Local environment / secrets — never want these in the build context
+.env
+.env.*
+!.env.example
+*.pem
+*.key
+secrets/
+
+# OS junk
+.DS_Store
+Thumbs.db
+
+# CI artifacts and tooling that the image doesn't need
+.cache/
+coverage/
+dist/
+playwright-report/
+test-results/
+bin/
+sidecars/
+
+# Docs the image doesn't ship. examples/ stays — examples/countdown-bot is
+# a Cargo workspace member, so cargo-chef needs its manifest. Markdown files
+# inside crates (Cargo.toml `readme = ...`) likewise need to be present to
+# avoid manifest warnings; the savings from excluding them are negligible.
+docs/
+
+# Old Dockerfile noise (this Dockerfile is THE source of truth now)
+docker-compose.yml
+docker-compose.*.yml
+prometheus.yml
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000000..948b869208
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,254 @@
+name: Docker image
+
+# Builds and publishes the public Buzz relay image as ghcr.io/block/buzz.
+#
+# Strategy: each architecture builds on its native runner (ubuntu-24.04 for
+# amd64, ubuntu-24.04-arm for arm64), pushes to GHCR by digest, then a final
+# job stitches the per-arch digests into a single multi-arch manifest.
+# This avoids QEMU emulation (~10× slower for Rust) at zero cost on free
+# GitHub-hosted runners.
+#
+# Triggers:
+#   - push to main         → :main + :sha-<7>
+#   - push tags v*.*.*     → :latest + :{version} + :{major}.{minor} + :{major}
+#   - pull_request         → build only (no push), cache stays warm
+#   - workflow_dispatch    → manual canary
+
+on:
+  push:
+    branches: [main]
+    tags: ["v[0-9]*"]
+  pull_request:
+    paths:
+      - "Dockerfile"
+      - ".dockerignore"
+      - ".github/workflows/docker.yml"
+      - "Cargo.toml"
+      - "Cargo.lock"
+      - "rust-toolchain.toml"
+      - "crates/**"
+      - "web/**"
+      - "package.json"
+      - "pnpm-lock.yaml"
+      - "pnpm-workspace.yaml"
+      - "patches/**"
+  workflow_dispatch:
+
+# One image build per ref; cancel superseded PR builds, but never cancel
+# tag/main builds (publishing must not be aborted mid-flight).
+concurrency:
+  group: docker-${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }}
+
+permissions: {}
+
+env:
+  # Single source of truth for the image name. Set GHCR_IMAGE as a repo
+  # variable to override (e.g., for forks that want to push to their own
+  # namespace without forking this file).
+  IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/block/buzz' }}
+
+jobs:
+  build:
+    name: Build (${{ matrix.platform }})
+    runs-on: ${{ matrix.runner }}
+    timeout-minutes: 60
+    permissions:
+      contents: read
+      packages: write    # push to GHCR
+      id-token: write    # OIDC for build provenance attestation
+      attestations: write
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - platform: linux/amd64
+            runner: ubuntu-24.04
+            arch: amd64
+          - platform: linux/arm64
+            runner: ubuntu-24.04-arm
+            arch: arm64
+
+    outputs:
+      # Used downstream by `merge` to stitch the manifest.
+      version: ${{ steps.meta.outputs.version }}
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10  # v6
+        with:
+          persist-credentials: false
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5  # v4.1.0
+        with:
+          # Default parallelism of 4 OOMs the 7GB GitHub runner during Rust
+          # compiles (see moby/buildkit#3969). Vaultwarden hit this; we will
+          # too without the cap.
+          buildkitd-config-inline: |
+            [worker.oci]
+              max-parallelism = 2
+
+      - name: Log in to GHCR
+        # Skip on pull_request from forks — no GHCR creds, build-only.
+        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+        uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee  # v4.2.0
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Extract metadata
+        id: meta
+        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9  # v6.1.0
+        with:
+          images: ${{ env.IMAGE_NAME }}
+          # Tag matrix — every main commit gets sha-<7>, releases get full
+          # semver family. Pull requests get nothing (push: false below).
+          tags: |
+            type=ref,event=branch
+            type=sha,prefix=sha-,format=short
+            type=semver,pattern={{version}}
+            type=semver,pattern={{major}}.{{minor}}
+            type=semver,pattern={{major}}
+            type=raw,value=latest,enable={{is_default_branch}}
+          labels: |
+            org.opencontainers.image.title=Buzz
+            org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform
+            org.opencontainers.image.licenses=Apache-2.0
+
+      - name: Build and push by digest
+        id: build
+        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf  # v7.2.0
+        with:
+          context: .
+          file: ./Dockerfile
+          platforms: ${{ matrix.platform }}
+          labels: ${{ steps.meta.outputs.labels }}
+          # Push by digest, not by tag — the merge job assembles the tags
+          # into one multi-arch manifest. This is what makes the native-arm
+          # matrix possible.
+          outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
+          cache-from: |
+            type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}
+          cache-to: |
+            type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }},mode=max,compression=zstd
+
+      - name: Export digest
+        if: github.event_name != 'pull_request'
+        env:
+          DIGEST: ${{ steps.build.outputs.digest }}
+        run: |
+          mkdir -p /tmp/digests
+          touch "/tmp/digests/${DIGEST#sha256:}"
+
+      - name: Upload digest
+        if: github.event_name != 'pull_request'
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a  # v7.0.1
+        with:
+          name: digests-${{ matrix.arch }}
+          path: /tmp/digests/*
+          if-no-files-found: error
+          retention-days: 1
+
+  merge:
+    name: Merge multi-arch manifest
+    if: github.event_name != 'pull_request'
+    runs-on: ubuntu-24.04
+    needs: build
+    timeout-minutes: 15
+    permissions:
+      contents: read
+      packages: write    # push the merged manifest
+      id-token: write    # OIDC for provenance attestation on the manifest
+      attestations: write
+
+    steps:
+      - name: Download all per-arch digests
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c  # v8.0.1
+        with:
+          path: /tmp/digests
+          pattern: digests-*
+          merge-multiple: true
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5  # v4.1.0
+
+      - name: Log in to GHCR
+        uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee  # v4.2.0
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Extract metadata
+        id: meta
+        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9  # v6.1.0
+        with:
+          images: ${{ env.IMAGE_NAME }}
+          tags: |
+            type=ref,event=branch
+            type=sha,prefix=sha-,format=short
+            type=semver,pattern={{version}}
+            type=semver,pattern={{major}}.{{minor}}
+            type=semver,pattern={{major}}
+            type=raw,value=latest,enable={{is_default_branch}}
+
+      - name: Create and push manifest list
+        id: manifest
+        working-directory: /tmp/digests
+        env:
+          IMAGE_NAME: ${{ env.IMAGE_NAME }}
+          META_TAGS: ${{ steps.meta.outputs.tags }}
+        run: |
+          set -euo pipefail
+          # Build -t flags from the metadata-action output.
+          tags=()
+          while IFS= read -r tag; do
+            [ -n "$tag" ] && tags+=("-t" "$tag")
+          done <<< "$META_TAGS"
+
+          # Build the digest refs from the per-arch artifacts.
+          digests=()
+          for digest in *; do
+            digests+=("${IMAGE_NAME}@sha256:${digest}")
+          done
+
+          docker buildx imagetools create "${tags[@]}" "${digests[@]}"
+
+          # Capture the merged manifest digest for the attestation step.
+          first_tag=$(echo "$META_TAGS" | head -n1)
+          merged_digest=$(docker buildx imagetools inspect "$first_tag" \
+            --format '{{json .Manifest}}' | jq -r '.digest')
+          echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT"
+
+      - name: Attest provenance for the merged image
+        # Sigstore-signed in-toto attestation, verifiable with:
+        #   gh attestation verify oci://ghcr.io/block/buzz:<tag> --owner block
+        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32  # v4.1.0
+        with:
+          subject-name: ${{ env.IMAGE_NAME }}
+          subject-digest: ${{ steps.manifest.outputs.digest }}
+          push-to-registry: true
+
+      - name: Summary
+        env:
+          IMAGE_NAME: ${{ env.IMAGE_NAME }}
+          MERGED_DIGEST: ${{ steps.manifest.outputs.digest }}
+          META_TAGS: ${{ steps.meta.outputs.tags }}
+        run: |
+          {
+            echo "### Published \`${IMAGE_NAME}\`"
+            echo
+            echo "**Digest:** \`${MERGED_DIGEST}\`"
+            echo
+            echo "**Tags:**"
+            echo '```'
+            echo "${META_TAGS}"
+            echo '```'
+            echo
+            echo "Verify provenance:"
+            echo '```'
+            echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block"
+            echo '```'
+          } >> "$GITHUB_STEP_SUMMARY"
diff --git a/Dockerfile b/Dockerfile
index 29b8fb737e..aec27dea23 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,46 +1,95 @@
-# ── Build stage (Rust) ──────────────────────────────────────
-# Hard-code --platform to prevent exec format error on ARM Macs.
-FROM --platform=linux/amd64 rust:1.95-bookworm AS builder
+# syntax=docker/dockerfile:1.7
+#
+# Public Buzz relay image — published as ghcr.io/block/buzz:<tag>.
+#
+# Builds the `buzz-relay` binary (Rust 1.95) and the `buzz-web` static bundle
+# (pnpm + vite), then assembles them into a small debian-slim runtime with
+# `git` available (the relay shells out to git for repo hydrate / receive-pack
+# / upload-pack — see crates/buzz-relay/src/api/git).
+#
+# Multi-arch is handled by running this same Dockerfile on native amd64 and
+# native arm64 runners (see .github/workflows/docker.yml). The Dockerfile
+# itself is platform-agnostic; do not add --platform pins.
+
+ARG RUST_VERSION=1.95
+ARG NODE_VERSION=24
+ARG DEBIAN_VERSION=bookworm
+
+# ─── Stage 1: cargo-chef base ───────────────────────────────────────────────
+FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
+RUN cargo install cargo-chef --locked --version 0.1.71
 WORKDIR /build
+
+# ─── Stage 2: plan dependency graph ─────────────────────────────────────────
+# Only the manifests are needed to compute the recipe; this layer rebuilds
+# only when Cargo.{toml,lock} or crate manifests change, not on every source
+# edit.
+FROM chef AS planner
+COPY . .
+RUN cargo chef prepare --recipe-path recipe.json
+
+# ─── Stage 3: cook dependencies, then build the binary ──────────────────────
+FROM chef AS builder
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends \
+        build-essential \
+        pkg-config \
+        libssl-dev \
+        ca-certificates \
+        git \
+    && rm -rf /var/lib/apt/lists/*
+COPY --from=planner /build/recipe.json recipe.json
+# Cook the full workspace recipe — relay deps include workspace siblings, so
+# scoping to -p buzz-relay misses transitive deps and re-builds them later.
+RUN cargo chef cook --release --recipe-path recipe.json
 COPY . .
-RUN cargo build --release -p buzz-relay \
+RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \
     && strip target/release/buzz-relay
 
-# ── Web build stage (Node/pnpm) ────────────────────────────
-FROM --platform=linux/amd64 node:24-bookworm-slim AS web-builder
+# ─── Stage 4: web bundle (pnpm + vite) ──────────────────────────────────────
+# Independent of the Rust layers so a CSS change doesn't bust Rust cache and
+# vice versa.
+FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS web-builder
 WORKDIR /build
+RUN corepack enable
 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+COPY patches/ patches/
+COPY web/package.json web/
+RUN pnpm install --frozen-lockfile --filter buzz-web
 COPY web/ web/
-RUN corepack enable && pnpm install --frozen-lockfile --filter buzz-web
 RUN pnpm -C web build
 
-# ── Runtime stage ───────────────────────────────────────────
-FROM --platform=linux/amd64 debian:bookworm-slim
+# ─── Stage 5: runtime ───────────────────────────────────────────────────────
+FROM debian:${DEBIAN_VERSION}-slim AS runtime
 
-# CAKE: non-root UID 1000 (numeric, not username)
-RUN groupadd -g 1000 buzz && useradd -u 1000 -g buzz -m buzz
+# OCI annotations: required for GHCR to auto-link the image to this repo and
+# inherit its visibility. org.opencontainers.image.source is the load-bearing
+# one — without it GHCR keeps the image private even when the repo is public.
+LABEL org.opencontainers.image.title="Buzz" \
+      org.opencontainers.image.description="WebSocket relay server for the Buzz communications platform" \
+      org.opencontainers.image.source="https://github.com/block/buzz" \
+      org.opencontainers.image.url="https://github.com/block/buzz" \
+      org.opencontainers.image.documentation="https://github.com/block/buzz#readme" \
+      org.opencontainers.image.licenses="Apache-2.0"
 
-# CAKE: writable dirs
-RUN mkdir -p /cache /tmp && chown buzz:buzz /cache /tmp
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends \
+        ca-certificates \
+        git \
+    && rm -rf /var/lib/apt/lists/* \
+    && groupadd --system --gid 1000 buzz \
+    && useradd  --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \
+                --create-home --shell /usr/sbin/nologin buzz
 
-# git: relay shells out to `git` for hydrate/receive-pack/upload-pack (S3-backed repos)
-# socat: Istio abstract→file socket bridge
-RUN apt-get update && apt-get install -y --no-install-recommends \
-    ca-certificates git socat && rm -rf /var/lib/apt/lists/*
+COPY --from=builder    /build/target/release/buzz-relay /usr/local/bin/buzz-relay
+COPY --from=web-builder /build/web/dist                 /srv/buzz/web
 
-COPY --from=builder /build/target/release/buzz-relay /code/buzz-relay
-COPY --from=web-builder /build/web/dist /code/web
-COPY script/start /code/start
-RUN chmod +x /code/start
+ENV BUZZ_WEB_DIR=/srv/buzz/web
 
-ENV BUZZ_WEB_DIR="/code/web"
+# 3000: app (WS + REST + web UI)  ·  8080: /_liveness, /_readiness  ·  9102: /metrics
+EXPOSE 3000 8080 9102
 
-# CAKE: required Envoy env vars (overridden at runtime by CAKE).
-ENV ENVOY_ADMIN_SOCKET_PATH="@envoy-admin.sock" \
-    ENVOY_INGRESS_PORT="20001" \
-    ENVOY_HTTP_EGRESS_SOCKET_PATH="@egress.sock" \
-    ENVOY_DATADOG_PORT="3030" \
-    CASH_FRAMEWORK="rust"
+USER buzz:buzz
+WORKDIR /var/lib/buzz
 
-USER 1000
-ENTRYPOINT ["/code/start"]
+ENTRYPOINT ["/usr/local/bin/buzz-relay"]
