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
- 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.
- 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.
- 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.
- 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.
- 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.
- As an operator, I want the image to be single-user by default so I don't have to configure auth for solo use.
- 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.
- 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.
- 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."
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- As a user running chartr behind Caddy, I want a documented Caddyfile snippet so I get automatic TLS and websocket forwarding without hand-tuning.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- As a user, I want SPDX SBOM and SLSA provenance attestations attached to the image so my company's compliance tooling can consume them.
- As a user, I want to run
docker run as a one-liner alternative to compose when I just want to try chartr quickly.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
/data — DataDir (runtime, per-session payload archives). VOLUME /data.
/config — ConfigDir (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:
- Reads
PUID (default 1000) and PGID (default 1000).
- Reconciles the baked
chartr user/group with those numeric ids (usermod -o, groupmod -o), idempotent and tolerant of drift.
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.
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):
web stage — node:22-bookworm-slim. Runs npm ci then npm run build in web/; produces web/dist/. BuildKit cache mount on ~/.npm.
go stage — golang: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.
final stage — debian: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 changes — internal/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:
- Set the relevant env var(s) via
t.Setenv.
- Construct a
Rig (optionally via WithConfigDir/WithDataDir) and Start it.
- 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.
Part of #2
SPEC — Deploy chartr as a Docker container, published to GHCR
Labels:
ready-for-agentSource 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 buildfrom 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/chartrDocker image, published on every push tomain(as:edge) and on everyv*.*.*tag (as:1.2.3,:1.2,:1, and:latest), for bothlinux/amd64andlinux/arm64, signed with cosign and carrying SLSA provenance + SPDX SBOM attestations. A user runsdocker compose up, openshttp://localhost:8787in 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:8787inside the container and users publish it as127.0.0.1:8787on the host by default. Users who want remote access add a Caddy or Traefik reverse proxy in front and setCHARTR_PUBLIC_URLaccordingly.User Stories
docker compose upchartr and have it serve its web UI on my LAN so I can drive it from any device without installing a desktop app.cosign verifythe image against the workflow that built it so I can trust the bytes before I pull them.:1.2.3) so my chartr version doesn't drift when the moving:edgetag updates.main, I want a:edgetag that refreshes on every merge to main so I can preview new features without waiting for a release.linux/arm64so I don't have to build it myself.localhostby default when I map it so hostile hosts on my LAN can't reach it./opt/agentsso I can pin an exact version without rebuilding the image.docker inspect(OCI labels), achartr agents list-bakedsubcommand, and the docs — so I can pick between the two paths (baked vs. bring-your-own) with full information.docker compose restartdoesn't lose in-flight session archives.nobody— so I cancat, edit, and commit them without chowning constantly.PUID/PGIDenv vars in my compose file so the container writes files as my host user.git,node,python3, andcurlso shell-script agent wrappers and language-runtime-based agents work without additional bind-mounts.env_file: .envin compose so my key isn't committed to my repo alongsidecompose.yaml.~/.claudedirectory into the container so my prior conversations and MCP config are visible to the baked Claude Code.CHARTR_PUBLIC_URL=https://chartr.example.comso the server accepts requests on that Host/Origin.-p 9000:8787, I wantCHARTR_PUBLIC_URL=http://localhost:9000to unblock the UI without me editing chartr's source.0.0.0.0:8787by mistake, I still want the exposure warning to fire, because there it is a misconfiguration.HEALTHCHECKin the image sodocker psand my compose-driven restart policies can tell whether chartr is actually serving.docker buildto reuse Go module, Go build, npm, and pip caches between CI runs so builds stay under 10 minutes when warm.@latestinstall breaks, so a broken image never gets published.verifyjob to pull both arches, cosign-verify the signature, and smoke-runchartr --helpandchartr agents list-bakedso we catch broken images before users do.docker runas a one-liner alternative to compose when I just want to try chartr quickly.compose.yamlfile shipped in the repo (packaging/docker/compose.yaml) so I cancurlit or copy fromgit clonerather than transcribing from the docs.docs/getting-started.mdpointing at a deeperdocs/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.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.docker stop chartrdoesn't leave zombie processes.Implementation Decisions
Serve mode: no new entrypoint needed
cmd/chartris already the headless HTTP entrypoint (R1). It listens on-addr(default127.0.0.1:8787), serves the embedded Svelte SPA at/, exposes ~33 REST routes plus/ws/controland/ws/terminal/{termID}websockets. The webview is only a thin native wrapper.Env-var contract (small Go change in
cmd/chartr)flag > env > compiled default:CHARTR_ADDRfallback for-addr.CHARTR_DATA_DIRfallback for-data-dir.127.0.0.1:8787) unchanged. The container image sets the envs to0.0.0.0:8787and/data.Config directory relies on existing XDG behavior
internal/server.ConfigRootalready honorsXDG_CONFIG_HOMEbefore falling back. The image setsXDG_CONFIG_HOME=/config. Zero Go change forConfigDir; no new env is introduced.Loopback-exposure warning: silence in-container
internal/server/exposure.gocurrently emits a warning whenever chartr binds a non-loopback address. In-container that is the intended configuration.CHARTR_IN_CONTAINER=1. The image sets this env. Bare-metal operators binding0.0.0.0:8787(a genuine misconfig) still see the warning.Host-gate and origin allowlist:
CHARTR_PUBLIC_URL(Go change ininternal/server)R2established that today bothhostRuleFor(internal/server/hostgate.go) andoriginPatterns(internal/server/origins.go) derive their allowlists solely from the resolved listener address. Docker shapes (b)docker run -p 9000:8787and (c) reverse-proxiedhttps://chartr.example.comare refused today.CHARTR_PUBLIC_URLenv, initially a single full-URL value, extensible later to a comma-separated list.Serve(internal/server/server.goaround the existinggate(ln.Addr(), s.mux)middleware wiring):hostRule.names, and its port is added to the accepted-port set (or port-match is relaxed).originPatterns(sohttps://schemes and non-standard ports flow through unmodified).Websocket keepalive and REST hardening (Go change in
internal/server)/ws/controland/ws/terminal/{termID}, 30-second interval. Prevents idle-drop through common reverse proxies (nginx defaults to 60s, most cloud proxies similar).ReadHeaderTimeout(10s) on thehttp.Server. NoWriteTimeoutorIdleTimeout— websocket handlers hijack the connection, and the existing// No WriteTimeoutnote stays correct.PATH probe: disable in-container
internal/env/HydratePATHruns a login-shell probe to merge PATH. Meaningless in-container.CHARTR_NO_PATH_PROBEenv (internal/env/path.go) already gates this. Image setsCHARTR_NO_PATH_PROBE=1. Zero Go change.Persistence: two volumes, mirroring the existing DataDir/ConfigDir split
/data—DataDir(runtime, per-session payload archives).VOLUME /data./config—ConfigDir(operator config, agent registry, sources, prompt contract).VOLUME /config./tmpand repo working trees are explicitly not volumes./tmpis container-local; repo trees are operator-supplied bind-mounts governed by the agent contract below.UID/GID: LinuxServer.io
PUID/PGIDpatternUSERisroot— required so the entrypoint canchownand drop privileges.PUID(default1000) andPGID(default1000).chartruser/group with those numeric ids (usermod -o,groupmod -o), idempotent and tolerant of drift.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.exec gosu chartr:chartr /usr/local/bin/chartr.tini, baked into the image and used unconditionally as the first ENTRYPOINT element:tini -- /usr/local/bin/chartr-entrypoint.environment: PUID/PGID, NOTuser:. Settinguser:bypasses the root entrypoint, and the chown step never runs — a documented troubleshooting entry.gosu'd uid. No Go-side handling.Agent CLI contract: hybrid — baked curated set + bind-mount extension point
D9): Claude Code (@anthropic-ai/claude-codenpm), OpenAI Codex CLI (@openai/codexnpm), Gemini CLI (@google/gemini-clinpm), OpenCode (npm; exact package name verified at implementation), Aider (aider-chatpip).@latestat build time. The published image digest is the reproducibility anchor; the moving:edgeand:latesttags refresh whenever a new build runs; users who need a specific agent version pin the image digest./opt/agents. Operators mount their own agent binaries there;PATH=/opt/agents:/usr/local/bin:/usr/bin:/binmeans bind-mounted binaries shadow the baked ones. Deliberate — this is how a user overrides a broken baked agent without rebuilding./etc/chartr/baked-agentswith the kebab-case slug list (one per line), and setschartr.agents=<comma-list>as an OCI label at build time.docs/docker.md, OCI label (chartr.agents=...), and a new smallchartr agents list-bakedsubcommand 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.@latestinstall fails during the build, the workflow must fail — no|| trueon the install lines. That failure is the trigger for the deprecation cycle.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
- ${HOME}/.claude:/config/agents/.claude:rwfor Claude Code's on-disk state. Readable/writable per the agent's needs.env_file: .env— API keys (ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY, etc.) stay out ofcompose.yamland out of version control.Dockerfile shape
Three-stage build (
packaging/docker/Dockerfile):webstage —node:22-bookworm-slim. Runsnpm cithennpm run buildinweb/; producesweb/dist/. BuildKit cache mount on~/.npm.gostage —golang:1.23-bookworm. Graftsweb/distfrom stage 1 before the Go build soweb/embed.goembeds 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-buildand/go/pkg/mod. ConsumesARG VERSIONandARG COMMITsupplied by the workflow.finalstage —debian:bookworm-slim. Installs the runtime deps + NodeSource-provided node 22 + gosu + tini.npm install -gthe four npm agents at@latest;pip3 install --break-system-packages --prefix=/usr/local aider-chat. Writes/etc/chartr/baked-agents. Creates/data,/config,/opt/agentsand a baselinechartruser/group at1000: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). DeclaresVOLUME ["/data", "/config"],EXPOSE 8787, and aHEALTHCHECKagainstcurl http://127.0.0.1:8787/api/health.USERstays root. OCI labels includingchartr.agents=...and (from D7'sdocker/metadata-action)image.versionandimage.revision.ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/chartr-entrypoint"]; emptyCMD.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 bakedchartruser, chown the two chartr-owned volumes,exec gosu chartr:chartr /usr/local/bin/chartr. Idempotent. Never touches/opt/agentsor any operator bind-mount.GHCR publish workflow
.github/workflows/docker-publish.yml:pushtomain(→:edge),pushofv*.*.*tags (→ semver +:latest),workflow_dispatch. No cron rebuild for v1.contents: read,packages: write,id-token: write,attestations: write.build-and-publishjob:docker/setup-qemu-actionfor arm64,docker/setup-buildx-action,docker/login-action(GITHUB_TOKEN, GHCR).docker/metadata-actionfor 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-argsVERSIONandCOMMIT.sigstore/cosign-installer+cosign sign --yeson the manifest-list digest (keyless, OIDC).verifyjob (needsbuild-and-publish):cosign verifywith--certificate-identity-regexpbound to this workflow file and--certificate-oidc-issuer https://token.actions.githubusercontent.com.docker run --platform linux/amd64andlinux/arm64(qemu) the just-published digest, runningchartr --helpandchartr agents list-baked. Any failure fails the job — no auto-cleanup; the operator manually deletes bad versions viagh api.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 atdocs/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 verifysnippet).README.md— one-line addition to the install section.packaging/docker/compose.yaml— real file shipped in-repo; embedded verbatim indocs/docker.md.Full compose file,
docker runone-liner, both reverse-proxy snippets, and the troubleshooting table are in.plan/maps/docker-deploy/D8-compose-and-docs.md.Modules built or modified
cmd/chartr/main.go,internal/server/exposure.go,internal/server/hostgate.go,internal/server/origins.go,internal/server/server.go,internal/server/terminals.go.agents list-bakedsubcommand undercmd/chartr(or a newcmd/chartr/agents.go), reading/etc/chartr/baked-agents.packaging/docker/Dockerfile,packaging/docker/chartr-entrypoint,packaging/docker/compose.yaml..github/workflows/docker-publish.yml.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
hostRuleForororiginPatternsinternals 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 changes —
internal/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:t.Setenv.Rig(optionally viaWithConfigDir/WithDataDir) andStartit.Modules tested through this seam:
CHARTR_ADDR/CHARTR_DATA_DIRenv 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_CONTAINERsilences the exposure warning — capture stderr / the logger's sink over a rig startup that binds0.0.0.0. Two cases: env unset (warning present), env set (warning absent). Bare-metal case still fires.CHARTR_PUBLIC_URLhost-gate + origin allowlist — the primary test surface. Cases:0.0.0.0:8787, request Hostlocalhost:8787→ 200. Hostlocalhost:9000→ 4xx (host-gate rejects).http://localhost:9000, request Hostlocalhost:9000→ 200. Websocket Originhttp://localhost:9000→ accepted.https://chartr.example.com, request Hostchartr.example.com→ 200. Websocket Originhttps://chartr.example.com→ accepted./ws/controland/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 thecmd/chartrlayer: write a temp file, point the subcommand at it via a test-only override (or set the file path relative toos.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 --helpandchartr agents list-bakedon 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):@latestupstreams — the build failing IS the signal.Out of Scope
gh api ... DELETEis documented; no CI code path automates it.chartr:slimvariant with no baked agents. Deferred — may graduate from fog after the first build lands, once the actual image size is known.:edgeto refresh baked agents in the absence of code pushes. Considered and rejected for v1.Further Notes
opencode-aivs@sst/opencode— pick the current package). D9's resolution called these out; a failednpm installat build time IS the signal.image.versionandimage.revisionare set bydocker/metadata-actionin the GHCR workflow — do not hardcode them in the Dockerfile.makeor CI step diffs them. That check is not required for v1; modest drift risk accepted.PUID/PGID(or accept files owned by uid 1000). The compose example demonstratesPUID: "${UID:-1000}". macOS Docker Desktop's uid remapping makes this transparent there..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.