Skip to content

SPEC: Docker deploy + GHCR publish #3

Description

@Teagan42

Part of #2

SPEC — Deploy chartr as a Docker container, published to GHCR

Labels: ready-for-agent
Source map: .plan/maps/docker-deploy/map.md (all decisions closed)

Problem Statement

A prospective user of chartr today has two supported install paths — the macOS DMG and the Linux AppImage — both of which run the webview desktop app on the user's own machine. Users who want to run chartr on a home server, a NAS, a VPS, or any always-on Linux box have no first-class path: they can go build from source, but they lose the batteries-included experience the desktop apps provide (bundled agents, a curated skill set, sensible defaults). They also have no reproducible, signed artifact to pull.

Chartr's backend already serves everything a browser needs over HTTP and websocket (the webview shell just points at http://127.0.0.1:<port>). The gap is packaging, not architecture.

Solution

Ship an official ghcr.io/rengwu/chartr Docker image, published on every push to main (as :edge) and on every v*.*.* tag (as :1.2.3, :1.2, :1, and :latest), for both linux/amd64 and linux/arm64, signed with cosign and carrying SLSA provenance + SPDX SBOM attestations. A user runs docker compose up, opens http://localhost:8787 in their browser, and gets the full chartr cockpit — the same Svelte UI the desktop app renders — with the top-tier AI-agent CLIs already installed inside the container. They bind-mount their source-code root and any per-agent credential/state directories they want the agents to see; chartr does the rest.

The image is single-user and self-hosted. It binds 0.0.0.0:8787 inside the container and users publish it as 127.0.0.1:8787 on the host by default. Users who want remote access add a Caddy or Traefik reverse proxy in front and set CHARTR_PUBLIC_URL accordingly.

User Stories

  1. As a self-hoster running a Linux home server, I want to docker compose up chartr and have it serve its web UI on my LAN so I can drive it from any device without installing a desktop app.
  2. As a security-minded operator, I want to cosign verify the image against the workflow that built it so I can trust the bytes before I pull them.
  3. As a developer who juggles multiple machines, I want to pull a specific image tag (:1.2.3) so my chartr version doesn't drift when the moving :edge tag updates.
  4. As a user tracking main, I want a :edge tag that refreshes on every merge to main so I can preview new features without waiting for a release.
  5. As an ARM-based Linux user (Raspberry Pi, Ampere, Apple Silicon under Asahi), I want the same image reference to work on linux/arm64 so I don't have to build it myself.
  6. As an operator, I want the image to be single-user by default so I don't have to configure auth for solo use.
  7. As an operator, I want the container to bind localhost by default when I map it so hostile hosts on my LAN can't reach it.
  8. As a user, I want chartr to see my source repos so its agent sessions can operate on real code — I'll bind-mount my source root wherever the agents expect it.
  9. As a user, I want the container to come with Claude Code, OpenAI Codex, Aider, Gemini CLI, and OpenCode already installed so my first-run experience is not "install five things first."
  10. As a user with a preferred version of a baked agent, I want to shadow the image's copy by bind-mounting my own into /opt/agents so I can pin an exact version without rebuilding the image.
  11. As a user, I want to see which agents are baked into my image via docker inspect (OCI labels), a chartr agents list-baked subcommand, and the docs — so I can pick between the two paths (baked vs. bring-your-own) with full information.
  12. As a user, I want the container to preserve my chartr config across recreations so I don't re-register agents, sources, or spaces every time I redeploy.
  13. As a user, I want the container to preserve my runtime session state so a docker compose restart doesn't lose in-flight session archives.
  14. As a user on Linux with UID 1000, I want files that appear on my host from container bind-mounts to be owned by me — not root, not nobody — so I can cat, edit, and commit them without chowning constantly.
  15. As a user on Linux with a non-1000 UID, I want to set PUID/PGID env vars in my compose file so the container writes files as my host user.
  16. As a user of Docker Desktop on macOS, I want everything to Just Work without me thinking about UID mapping, because Docker Desktop handles it.
  17. As a user, I want the image to include git, node, python3, and curl so shell-script agent wrappers and language-runtime-based agents work without additional bind-mounts.
  18. As a user with an Anthropic API key, I want to pass it via env_file: .env in compose so my key isn't committed to my repo alongside compose.yaml.
  19. As a user of Claude Code, I want to bind-mount my ~/.claude directory into the container so my prior conversations and MCP config are visible to the baked Claude Code.
  20. As an operator exposing chartr on a public domain via reverse proxy, I want to set CHARTR_PUBLIC_URL=https://chartr.example.com so the server accepts requests on that Host/Origin.
  21. As an operator running the container with -p 9000:8787, I want CHARTR_PUBLIC_URL=http://localhost:9000 to unblock the UI without me editing chartr's source.
  22. As a user running chartr behind Caddy, I want a documented Caddyfile snippet so I get automatic TLS and websocket forwarding without hand-tuning.
  23. As a user running chartr behind Traefik, I want documented labels I can copy into my compose file so the same container fits into an existing Traefik deployment.
  24. As a user, I want long-lived terminal websocket sessions to survive idle periods through a reverse proxy so my session doesn't drop after 60 seconds of no keyboard input.
  25. As an operator, I don't want to see a "you're binding a non-loopback address" warning at startup inside a container, because in-container that's the correct configuration.
  26. As an operator running chartr on bare-metal at 0.0.0.0:8787 by mistake, I still want the exposure warning to fire, because there it is a misconfiguration.
  27. As an operator, I want a HEALTHCHECK in the image so docker ps and my compose-driven restart policies can tell whether chartr is actually serving.
  28. As a maintainer of the chartr image, I want docker build to reuse Go module, Go build, npm, and pip caches between CI runs so builds stay under 10 minutes when warm.
  29. As a maintainer, I want the GHCR workflow to fail loudly if a baked agent's @latest install breaks, so a broken image never gets published.
  30. As a maintainer, I want a post-publish verify job to pull both arches, cosign-verify the signature, and smoke-run chartr --help and chartr agents list-baked so we catch broken images before users do.
  31. As a user, I want SPDX SBOM and SLSA provenance attestations attached to the image so my company's compliance tooling can consume them.
  32. As a user, I want to run docker run as a one-liner alternative to compose when I just want to try chartr quickly.
  33. As a user, I want a canonical compose.yaml file shipped in the repo (packaging/docker/compose.yaml) so I can curl it or copy from git clone rather than transcribing from the docs.
  34. As a user, I want a short "Run in Docker" section on docs/getting-started.md pointing at a deeper docs/docker.md, so I discover the option in the natural place but don't have to wade through it if I want the desktop app.
  35. As a user, I want a troubleshooting table covering every deployment gotcha (public-URL mismatch, PUID vs user:, PATH-not-found, healthcheck loops, websocket idle drops, agent auth-loop) so I don't have to file an issue for the common failures.
  36. As a user, I don't want to run chartr as root inside the container even when the entrypoint starts as root, because chartr should never write files as root into my bind-mounts.
  37. As a user, I want PTY child processes chartr spawns (agents, terminal sessions) to be reaped correctly on container stop, so docker stop chartr doesn't leave zombie processes.
  38. As a maintainer, I want the baked-agent list to be discoverable at multiple levels — docs, OCI label, runtime subcommand — so both humans and automation can reason about it.
  39. As a maintainer, I want a documented deprecation policy (announce → one minor version → remove) so users of a deprecated baked agent aren't blindsided by its removal.
  40. As a user, I don't want VNC or a desktop shell — I want the web UI. VNC was in the original ask and has been explicitly ruled out.

Implementation Decisions

Serve mode: no new entrypoint needed

  • cmd/chartr is already the headless HTTP entrypoint (R1). It listens on -addr (default 127.0.0.1:8787), serves the embedded Svelte SPA at /, exposes ~33 REST routes plus /ws/control and /ws/terminal/{termID} websockets. The webview is only a thin native wrapper.
  • No new subcommand. No new serve entrypoint. Docker deploy is a packaging exercise on top of the existing binary.

Env-var contract (small Go change in cmd/chartr)

  • Add env fallbacks with precedence flag > env > compiled default:
    • CHARTR_ADDR fallback for -addr.
    • CHARTR_DATA_DIR fallback for -data-dir.
  • No new flags; existing flag surface is unchanged.
  • Existing bare-metal defaults (127.0.0.1:8787) unchanged. The container image sets the envs to 0.0.0.0:8787 and /data.

Config directory relies on existing XDG behavior

  • internal/server.ConfigRoot already honors XDG_CONFIG_HOME before falling back. The image sets XDG_CONFIG_HOME=/config. Zero Go change for ConfigDir; no new env is introduced.

Loopback-exposure warning: silence in-container

  • internal/server/exposure.go currently emits a warning whenever chartr binds a non-loopback address. In-container that is the intended configuration.
  • Add a small gate: silence when CHARTR_IN_CONTAINER=1. The image sets this env. Bare-metal operators binding 0.0.0.0:8787 (a genuine misconfig) still see the warning.

Host-gate and origin allowlist: CHARTR_PUBLIC_URL (Go change in internal/server)

  • R2 established that today both hostRuleFor (internal/server/hostgate.go) and originPatterns (internal/server/origins.go) derive their allowlists solely from the resolved listener address. Docker shapes (b) docker run -p 9000:8787 and (c) reverse-proxied https://chartr.example.com are refused today.
  • Introduce a CHARTR_PUBLIC_URL env, initially a single full-URL value, extensible later to a comma-separated list.
  • The value feeds a small adapter called from Serve (internal/server/server.go around the existing gate(ln.Addr(), s.mux) middleware wiring):
    • Its parsed host is added to hostRule.names, and its port is added to the accepted-port set (or port-match is relaxed).
    • The origin URL is appended verbatim to originPatterns (so https:// schemes and non-standard ports flow through unmodified).
  • No wildcards. Both gates are exact-match by design; the host gate deliberately refuses name patterns to preserve its DNS-rebinding guarantee. This is a strict rule.

Websocket keepalive and REST hardening (Go change in internal/server)

  • Add periodic server→client websocket ping frames on /ws/control and /ws/terminal/{termID}, 30-second interval. Prevents idle-drop through common reverse proxies (nginx defaults to 60s, most cloud proxies similar).
  • Add a ReadHeaderTimeout (10s) on the http.Server. No WriteTimeout or IdleTimeout — websocket handlers hijack the connection, and the existing // No WriteTimeout note stays correct.
  • Transport, resize protocol (text control frames), and reconnection semantics are unchanged.

PATH probe: disable in-container

  • internal/env/HydratePATH runs a login-shell probe to merge PATH. Meaningless in-container.
  • The existing CHARTR_NO_PATH_PROBE env (internal/env/path.go) already gates this. Image sets CHARTR_NO_PATH_PROBE=1. Zero Go change.

Persistence: two volumes, mirroring the existing DataDir/ConfigDir split

  • /dataDataDir (runtime, per-session payload archives). VOLUME /data.
  • /configConfigDir (operator config, agent registry, sources, prompt contract). VOLUME /config.
  • /tmp and repo working trees are explicitly not volumes. /tmp is container-local; repo trees are operator-supplied bind-mounts governed by the agent contract below.

UID/GID: LinuxServer.io PUID/PGID pattern

  • Final USER is root — required so the entrypoint can chown and drop privileges.
  • POSIX-shell entrypoint script:
    1. Reads PUID (default 1000) and PGID (default 1000).
    2. Reconciles the baked chartr user/group with those numeric ids (usermod -o, groupmod -o), idempotent and tolerant of drift.
    3. chown -R "$PUID:$PGID" /data /config — the two chartr-owned volumes only. Never touches /opt/agents (bind-mounted host bin dir) or repo bind-mounts.
    4. exec gosu chartr:chartr /usr/local/bin/chartr.
  • The reaper is tini, baked into the image and used unconditionally as the first ENTRYPOINT element: tini -- /usr/local/bin/chartr-entrypoint.
  • The compose example uses environment: PUID/PGID, NOT user:. Setting user: bypasses the root entrypoint, and the chown step never runs — a documented troubleshooting entry.
  • Child processes chartr spawns inherit the gosu'd uid. No Go-side handling.

Agent CLI contract: hybrid — baked curated set + bind-mount extension point

  • Baked set for v1 (from D9): Claude Code (@anthropic-ai/claude-code npm), OpenAI Codex CLI (@openai/codex npm), Gemini CLI (@google/gemini-cli npm), OpenCode (npm; exact package name verified at implementation), Aider (aider-chat pip).
  • Version pin: float on @latest at build time. The published image digest is the reproducibility anchor; the moving :edge and :latest tags refresh whenever a new build runs; users who need a specific agent version pin the image digest.
  • Bind-mount extension: image creates an empty /opt/agents. Operators mount their own agent binaries there; PATH=/opt/agents:/usr/local/bin:/usr/bin:/bin means bind-mounted binaries shadow the baked ones. Deliberate — this is how a user overrides a broken baked agent without rebuilding.
  • Baked-agent manifest: image writes /etc/chartr/baked-agents with the kebab-case slug list (one per line), and sets chartr.agents=<comma-list> as an OCI label at build time.
  • Discoverability, three channels: docs section in docs/docker.md, OCI label (chartr.agents=...), and a new small chartr agents list-baked subcommand that reads /etc/chartr/baked-agents (empty output when the file is absent — non-container invocations). This is the only Go work introduced by the agent-set decision.
  • Deprecation policy: announce in release notes, keep for one minor version, then remove. If an agent's @latest install fails during the build, the workflow must fail — no || true on the install lines. That failure is the trigger for the deprecation cycle.
  • Runtime dependencies pre-installed: nodejs (LTS via NodeSource), npm, python3, pip3, git, ca-certificates, curl, gosu, tini. Everything else is the operator's responsibility to add via a derived image or bind-mount.

Agent authentication: two supported patterns, both in the compose example

  • Bind-mount agent state — e.g. - ${HOME}/.claude:/config/agents/.claude:rw for Claude Code's on-disk state. Readable/writable per the agent's needs.
  • Env passthrough via env_file: .env — API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.) stay out of compose.yaml and out of version control.

Dockerfile shape

Three-stage build (packaging/docker/Dockerfile):

  1. web stagenode:22-bookworm-slim. Runs npm ci then npm run build in web/; produces web/dist/. BuildKit cache mount on ~/.npm.
  2. go stagegolang:1.23-bookworm. Grafts web/dist from stage 1 before the Go build so web/embed.go embeds it. CGO_ENABLED=0, -trimpath, -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}", output /out/chartr. BuildKit cache mounts on /root/.cache/go-build and /go/pkg/mod. Consumes ARG VERSION and ARG COMMIT supplied by the workflow.
  3. final stagedebian:bookworm-slim. Installs the runtime deps + NodeSource-provided node 22 + gosu + tini. npm install -g the four npm agents at @latest; pip3 install --break-system-packages --prefix=/usr/local aider-chat. Writes /etc/chartr/baked-agents. Creates /data, /config, /opt/agents and a baseline chartr user/group at 1000:1000. Copies the compiled binary and the entrypoint script. Sets the full env contract (PATH=/opt/agents:..., CHARTR_ADDR, CHARTR_DATA_DIR, XDG_CONFIG_HOME, CHARTR_NO_PATH_PROBE, CHARTR_IN_CONTAINER, PUID, PGID). Declares VOLUME ["/data", "/config"], EXPOSE 8787, and a HEALTHCHECK against curl http://127.0.0.1:8787/api/health. USER stays root. OCI labels including chartr.agents=... and (from D7's docker/metadata-action) image.version and image.revision. ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/chartr-entrypoint"]; empty CMD.

Full annotated pseudocode is in .plan/maps/docker-deploy/D6-dockerfile-shape.md.

Entrypoint script

packaging/docker/chartr-entrypoint, POSIX sh. Sole job: reconcile PUID/PGID with the baked chartr user, chown the two chartr-owned volumes, exec gosu chartr:chartr /usr/local/bin/chartr. Idempotent. Never touches /opt/agents or any operator bind-mount.

GHCR publish workflow

.github/workflows/docker-publish.yml:

  • Triggers: push to main (→ :edge), push of v*.*.* tags (→ semver + :latest), workflow_dispatch. No cron rebuild for v1.
  • Permissions: minimum required — contents: read, packages: write, id-token: write, attestations: write.
  • build-and-publish job:
    • docker/setup-qemu-action for arm64, docker/setup-buildx-action, docker/login-action (GITHUB_TOKEN, GHCR).
    • docker/metadata-action for tags + labels (image.version, image.revision).
    • docker/build-push-action, platforms: linux/amd64,linux/arm64, provenance: mode=min, sbom: true, cache-from/to: type=gha, build-args VERSION and COMMIT.
    • sigstore/cosign-installer + cosign sign --yes on the manifest-list digest (keyless, OIDC).
  • verify job (needs build-and-publish):
    • cosign verify with --certificate-identity-regexp bound to this workflow file and --certificate-oidc-issuer https://token.actions.githubusercontent.com.
    • docker run --platform linux/amd64 and linux/arm64 (qemu) the just-published digest, running chartr --help and chartr agents list-baked. Any failure fails the job — no auto-cleanup; the operator manually deletes bad versions via gh api.
  • Concurrency: docker-publish-${{ github.ref }}, cancel-in-progress: false — never cancel a publish in flight.

Annotated outline in .plan/maps/docker-deploy/D7-ghcr-workflow.md.

Docs

  • docs/getting-started.md — short new section "Run chartr as a Docker container" (~15 lines) pointing at docs/docker.md.
  • docs/docker.md — new page. Sections: what you get, quick start (compose + docker run), configuration reference (env vars, volumes, PUID/PGID), agent authentication, adding your own agents (/opt/agents), remote access (Caddy + Traefik snippets, CHARTR_PUBLIC_URL), troubleshooting (7-row table covering every prior decision), verifying the image (cosign verify snippet).
  • README.md — one-line addition to the install section.
  • packaging/docker/compose.yaml — real file shipped in-repo; embedded verbatim in docs/docker.md.

Full compose file, docker run one-liner, both reverse-proxy snippets, and the troubleshooting table are in .plan/maps/docker-deploy/D8-compose-and-docs.md.

Modules built or modified

  • Modified Go: cmd/chartr/main.go, internal/server/exposure.go, internal/server/hostgate.go, internal/server/origins.go, internal/server/server.go, internal/server/terminals.go.
  • New Go: an agents list-baked subcommand under cmd/chartr (or a new cmd/chartr/agents.go), reading /etc/chartr/baked-agents.
  • New packaging: packaging/docker/Dockerfile, packaging/docker/chartr-entrypoint, packaging/docker/compose.yaml.
  • New CI: .github/workflows/docker-publish.yml.
  • New docs: docs/docker.md. Modified: docs/getting-started.md, README.md.

Testing Decisions

What makes a good test here — asserts external behavior visible to a user of the image or of the built binary: env → server behavior, HTTP responses on the gated endpoints, websocket ping frames on the wire, subcommand stdout. Tests do NOT reach into hostRuleFor or originPatterns internals to inspect returned values; they hit the live server through the gate and assert responses. They do NOT count how many times a function is called.

One seam for the Go changesinternal/chartrtest.Rig. Existing tests (e.g. internal/server/filemode_test.go, and origins/hostgate tests) already use it. Every Go change under this spec is testable through table-driven tests that:

  1. Set the relevant env var(s) via t.Setenv.
  2. Construct a Rig (optionally via WithConfigDir/WithDataDir) and Start it.
  3. Make an HTTP or websocket request against the rig's URL and assert the response (status, header, body, or ping-frame timing).

Modules tested through this seam:

  • CHARTR_ADDR / CHARTR_DATA_DIR env fallbacks — table-driven: env only, flag only, both set (flag wins), neither set (compiled default). Assert via the rig's resolved bind address and data-dir on disk.
  • CHARTR_IN_CONTAINER silences the exposure warning — capture stderr / the logger's sink over a rig startup that binds 0.0.0.0. Two cases: env unset (warning present), env set (warning absent). Bare-metal case still fires.
  • CHARTR_PUBLIC_URL host-gate + origin allowlist — the primary test surface. Cases:
    • Env unset, bind 0.0.0.0:8787, request Host localhost:8787 → 200. Host localhost:9000 → 4xx (host-gate rejects).
    • Env http://localhost:9000, request Host localhost:9000 → 200. Websocket Origin http://localhost:9000 → accepted.
    • Env https://chartr.example.com, request Host chartr.example.com → 200. Websocket Origin https://chartr.example.com → accepted.
    • Env set with a name that doesn't match the request Host → still rejected. Prevents the allowlist from becoming a wildcard by accident.
  • Websocket 30s pings — connect to /ws/control and /ws/terminal/{id} via the rig, keep the socket open for ~65 seconds (or with a compressed test clock if the implementation exposes one), assert that at least two ping frames arrive within that window.
  • ReadHeaderTimeout — slow-drip a request-header stream and assert the server closes after ~10s. Prior art: none obvious; add if the surface is small.
  • chartr agents list-baked — pure I/O test at the cmd/chartr layer: write a temp file, point the subcommand at it via a test-only override (or set the file path relative to os.Executable), assert stdout. Missing file → empty stdout, exit 0.

Image tests — the D7 verify job is the only image-level test. It pulls the just-published multi-arch digest and runs chartr --help and chartr agents list-baked on both architectures (arm64 under qemu). No separate bats/shellspec suite. The entrypoint script's PUID/PGID branches are exercised transitively by the smoke-test's default env (which triggers the chown step); pathological UIDs are documented, not tested.

Not tested by CI (deliberate scope choice, documented in docs/docker.md):

  • Caddy and Traefik snippets — end-to-end reverse-proxy behavior. Users report if they break.
  • Specific baked-agent version compatibility with @latest upstreams — the build failing IS the signal.

Out of Scope

  • VNC / desktop-shell shim. Original ask included it; narrowed out during charting. If reintroduced, that is a fresh effort, not a resumption of this map.
  • Multi-user / in-container authentication. Destination is single-user self-hosted.
  • Helm chart or Kubernetes manifests. Explicitly ruled out for this effort.
  • Reverse-proxy configuration for services other than Caddy and Traefik. Users on nginx, HAProxy, Cloudflare Tunnel, etc. adapt the pattern themselves.
  • Auto-cleanup of failed-verify publishes. Manual gh api ... DELETE is documented; no CI code path automates it.
  • A chartr:slim variant with no baked agents. Deferred — may graduate from fog after the first build lands, once the actual image size is known.
  • Image-size tuning beyond a "does it work and is it reasonable" first cut. Same reason.
  • Scheduled/cron rebuilds of :edge to refresh baked agents in the absence of code pushes. Considered and rejected for v1.

Further Notes

  • Two npm package names to verify at implementation time: OpenAI Codex CLI (confirm the current npm name) and OpenCode (opencode-ai vs @sst/opencode — pick the current package). D9's resolution called these out; a failed npm install at build time IS the signal.
  • Two OCI labels come from the workflow, not the Dockerfile: image.version and image.revision are set by docker/metadata-action in the GHCR workflow — do not hardcode them in the Dockerfile.
  • The compose file and the doc-embedded copy will drift unless a small make or CI step diffs them. That check is not required for v1; modest drift risk accepted.
  • User-facing prerequisite: users on Linux with non-1000 UIDs must set PUID/PGID (or accept files owned by uid 1000). The compose example demonstrates PUID: "${UID:-1000}". macOS Docker Desktop's uid remapping makes this transparent there.
  • Cross-references: the closed decision tickets under .plan/maps/docker-deploy/ hold the full rationale for every choice above — each ticket has a Resolution section that includes the "why" and the downstream impact. Implementers should read the ticket for any decision they need to argue with.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions