Files
Playa/docs/superpowers/specs/2026-09-26-docker-deployment-design.md
EugeneTes 4ed3758f9c feat(docker): Dockerfile for the Telegram host
Two-stage build, agent tools, Node 24, claude CLI, data dirs owned by app, DOTNET_EnableDiagnostics=0. Adds the MCP spec's deferred test that the Dockerfile's PLAYWRIGHT_MCP_VERSION equals PlaywrightPreset.DefaultPackage.

Spec change: the image keeps root as its user and the entrypoint drops to app with setpriv. With USER app, compose's docker-init (PID 1) runs as app and its /proc/1/environ exposes every secret to the agent's bash. The spec also lists the csprojs the restore layer copies (GoogleCalendar, Mcp, Scheduler included) and sets HOME=/home/app in the image.
2026-09-27 12:04:42 +02:00

18 KiB
Raw Blame History

Docker deployment (Coolify) — design

Date: 2026-09-26. Status: draft. Depends on: 2026-09-26-process-environment-hook-design.md (the secrets below stay out of agent-spawned processes), 2026-09-26-scheduler-design.md §7 (the default time zone).

Goal

Run the Telegram host (hosts/Volcanic.AgenticCore.Telegram) as a single container on Coolify, deployed from this repo's docker-compose.yml. The container carries everything the agent's bash tool and the Claude Code provider need, runs as a non-root user, and keeps workspaces, sessions and the Claude login across redeploys. docker compose up -d --build on any Docker host runs the same thing locally.

Reference: VolcanicSecretary (Dockerfile, docker-compose.yml, docker-entrypoint.sh), adapted to a .NET build and to the Claude Code provider's subscription-only rules.

Decisions

Topic Decision
Files Dockerfile, .dockerignore, docker-compose.yml, .env.example at the repo root; user doc docs/docker.md
Build Multi-stage: mcr.microsoft.com/dotnet/sdk:10.0-noble → mcr.microsoft.com/dotnet/runtime:10.0-noble
Publish Framework-dependent dotnet publish -c Release of the Telegram host only. No self-contained, no AOT, no single-file: claude launches the bridge as dotnet <Bridge>.dll, so the dotnet muxer and a loose Bridge.dll + .runtimeconfig.json must exist
Entry ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]: setpriv (util-linux, already in the base image) drops to app and execs dotnet, so Environment.ProcessPath is still the muxer BridgeLocator.FindDotnet wants. No entrypoint script
Agent tools bash, git, ripgrep, python3 (+ python-is-python3, python3-venv), curl, ca-certificates, tzdata, jq, less, procps, openssh-client
Node Node.js 24 from NodeSource (ARG NODE_MAJOR=24); Ubuntu noble's nodejs is too old
Claude CLI npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}, ARG CLAUDE_CODE_VERSION=2.1 (newest 2.1.x at build time; set an exact version to pin). Updated by rebuilding, never in place
Playwright ARG INSTALL_PLAYWRIGHT=false in the Dockerfile; compose passes true. Installs @playwright/mcp globally at the version pinned by the MCP spec, and Chromium + OS deps under /opt/ms-playwright. Compose points the preset at the preinstalled playwright-mcp (no npx download) and adds --no-sandbox
User The image's default user stays root so that PID 1 (compose's init: true, i.e. docker-init) is root-owned; the bot and everything it spawns run as the base image's non-root app user (1654, $APP_UID), HOME=/home/app. With USER app, docker-init runs as app and its /proc/1/environ (the whole container environment, secrets included) is readable by the agent's bash (checked on Docker 29)
Data Three named volumes under /data: workspaces, agent, claude
Config Environment only, set in Coolify. Required: TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USER_IDS. Optional: OPENAI_API_KEY, STT_MODEL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, DEFAULT_TIMEZONE, PROCESS_ENV_ALLOW, TZ, PLAYWRIGHT_ENABLED, PLAYWRIGHT_HEADLESS, PLAYWRIGHT_BROWSER, PLAYWRIGHT_EXTRA_ARGS, PLAYWRIGHT_COMMAND
Healthcheck None (see below)
Coolify No ports, no expose, no domain, no SERVICE_FQDN_*; restart: unless-stopped; init: true
Local script None. cp .env.example .env && docker compose up -d --build is the local run; the dotnet run path in docs/telegram-bot.md stays for development

Out of scope: webhooks/HTTP endpoints, a .NET SDK inside the runtime image for the agent to use, CI image publishing to a registry, multi-arch builds (the image is built on the Coolify server, whatever its arch), running tests in the image build.

Dockerfile

Build stage (sdk:10.0-noble):

  1. WORKDIR /src. Copy Directory.Build.props, VolcanicAgenticCore.slnx and only the *.csproj files the host needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, GoogleCalendar, Mcp, Scheduler, Telegram), then dotnet restore hosts/Volcanic.AgenticCore.Telegram — a cached layer until a project file changes.
  2. Copy src/, extensions/, hosts/ and dotnet publish hosts/Volcanic.AgenticCore.Telegram -c Release --no-restore -o /out. TreatWarningsAsErrors applies, so a warning fails the image build as it fails dotnet build.
  3. Guard: test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll && test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json. The bridge reaches the output through a ReferenceOutputAssembly="false" project reference; if publish drops the runtimeconfig (it does not in build), fix the csproj, not the Dockerfile.

Runtime stage (runtime:10.0-noble), as root:

  1. apt-get install --no-install-recommends the agent tools above, then NodeSource nodejs; clean apt lists.
  2. npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} && claude --version (fails the build if the CLI does not run). The global prefix /usr/lib/node_modules stays root-owned, so the CLI's auto-updater cannot replace the binary at runtime: a Claude Code upgrade changes what system/init validation sees and invalidates the prompt cache, so it should only arrive with a rebuild.
  3. If INSTALL_PLAYWRIGHT=true: ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright, npm install -g @playwright/mcp@${PLAYWRIGHT_MCP_VERSION}, then run the playwright CLI from that package's own dependency with install --with-deps chromium, so browser build and library version match. chmod -R a+rX /opt/ms-playwright. Roughly +400 MB. PLAYWRIGHT_BROWSERS_PATH is set unconditionally so the MCP spec (2026-09-26-mcp-client-playwright-design.md) can rely on it: its server process inherits the environment. ARG PLAYWRIGHT_MCP_VERSION defaults to the exact version in the MCP spec's PlaywrightPreset.DefaultPackage, which is the single source of truth. A unit test in that spec fails when the two differ, so bumping the version means changing both in one commit. Compose does not override this arg.
  4. mkdir -p /data/workspaces /data/agent /data/claude /home/app && chown -R app:app /data /home/app. Named volumes copy this ownership on first mount, which is what lets a non-root process write to them.
  5. COPY --from=build /out /app, WORKDIR /app. No USER (see the User row); an earlier step checks that app is uid/gid 1654, which the entrypoint hard-codes.
  6. Environment baked into the image (compose and Coolify may override):
Variable Value Why
Telegram__WorkspaceRoot /data/workspaces Chat <id> works in /data/workspaces/<id>
VOLCANIC_AGENT_DIR /data/agent Sessions, extensions/*.dll, settings.json, auth.json
HOME /home/app The image user is root, so Docker would otherwise set /root
CLAUDE_CONFIG_DIR /data/claude Moves both ~/.claude/ and ~/.claude.json (credentials and onboarding state) into one volume. Already on the provider's env allow-list, so every claude child and claude auth login see it
DOTNET_CLI_TELEMETRY_OPTOUT, DOTNET_NOLOGO 1 Quiet
TZ UTC Overridable
DOTNET_EnableDiagnostics 0 Turns off the runtime's diagnostic IPC socket, which would hand the bot's environment to the agent's same-uid processes and bypass the host's non-dumpable hardening (process-environment-hook spec §8). Unset it (with ProcessEnvironment__HardenHostProcess=false) only to attach dotnet-dump/dotnet-trace while debugging

VOLUME is not declared in the Dockerfile; the compose file owns volumes (Coolify treats anonymous volumes badly).

docker-compose.yml

services:
  bot:
    build:
      context: .
      args:
        INSTALL_PLAYWRIGHT: ${INSTALL_PLAYWRIGHT:-true}
        CLAUDE_CODE_VERSION: ${CLAUDE_CODE_VERSION:-2.1}
    restart: unless-stopped
    # dotnet does not reap orphans; killed `claude` trees and bash tool children would pile up as zombies.
    init: true
    # Chromium crashes with Docker's 64 MB /dev/shm.
    shm_size: 1gb
    stop_grace_period: 30s
    environment:
      - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
      - TELEGRAM_ALLOWED_USER_IDS=${TELEGRAM_ALLOWED_USER_IDS}
      - Telegram__Model=${TELEGRAM_MODEL:-claude-code/claude-opus-5-5}
      - TZ=${TZ:-UTC}
      - Logging__LogLevel__Default=${LOG_LEVEL:-Information}
      # Optional features; empty means off / default.
      - OPENAI_API_KEY=${OPENAI_API_KEY:-}                  # speech to text (attachments spec)
      - STT_MODEL=${STT_MODEL:-}
      - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}              # Google Calendar (calendar spec)
      - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
      - DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-}              # chats' default zone (scheduler spec §7); empty → TZ
      - PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-}            # deny-listed variables the agent may see, e.g. GH_TOKEN
      # Playwright MCP preset (MCP spec). Enabled when the image has it; the preinstalled binary, no npx download.
      - PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}
      - PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true}     # no display in the container
      - PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-}
      - PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox}
      - PLAYWRIGHT_COMMAND=${PLAYWRIGHT_COMMAND:-playwright-mcp}
    volumes:
      - workspaces:/data/workspaces
      - agent:/data/agent
      - claude:/data/claude

volumes:
  workspaces:
  agent:
  claude:
  • No ports/expose: long polling only makes outbound connections. No domain is configured in Coolify, so no Traefik labels are generated.
  • No ANTHROPIC_*, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_USE_* entries, ever: the provider refuses to run with them. docs/docker.md says so next to the Coolify env setup, because shared/team variables in Coolify are the likely way one sneaks in.
  • Env mapping: the two required settings use the existing shortcuts (TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USER_IDS, comma separated) because a list in Telegram__AllowedUserIds__0..n form is awkward in Coolify. Everything else in TelegramOptions is reachable as Telegram__<Name> (e.g. Telegram__ReconnectMaxDelay=00:02:00) without a compose change, by adding it in Coolify and to environment. The Claude provider's VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS / ..._TOTAL_TIMEOUT_MS work the same way, as do the Scheduler__*, GoogleCalendar__*, ProcessEnvironment__* and Telegram__Speech__* / Telegram__InputDebounce settings of the sibling specs.
  • Optional features use the env shortcuts their specs define: OPENAI_API_KEY (+ STT_MODEL) turns on speech to text; GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET turn on the calendar, and each chat then connects its own Google account. Empty values mean off, so they're in environment and .env.example from the start. OPENAI_API_KEY is not on the Claude Code provider's denied list and never reaches claude.
  • Time zones: each chat has its own (/timezone). A chat that never set one uses DEFAULT_TIMEZONE, else the container's TZ (default UTC), so a deployment whose users are mostly in one zone should set DEFAULT_TIMEZONE. The chat's zone is also passed as TZ to the agent's bash and watcher scripts (the image has tzdata). Calendar times use the calendar's own zone, else the chat's.
  • Secrets set here (TELEGRAM_BOT_TOKEN, OPENAI_API_KEY, GOOGLE_CLIENT_SECRET and anything matching *_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD or their Section__Key forms) are removed from the environment of the agent's bash, scheduled scripts and MCP servers by the process environment hook. To give the agent a credential on purpose (say GH_TOKEN for gh), add it in Coolify and list its name in PROCESS_ENV_ALLOW; an MCP server gets one through its own env entry instead. On top of that, the bot (the only child of the root-owned PID 1) makes itself non-dumpable at startup, so the agent can't read its /proc/<pid>/environ or attach to it, PID 1's own /proc/1/environ is root-only, and the configured secrets' values are replaced by [REDACTED] in tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no dotnet-dump/dotnet-trace attach; ProcessEnvironment__HardenHostProcess=false turns it off for a debugging session. The container stays the real isolation boundary: none of this is a sandbox.
  • Playwright: PLAYWRIGHT_COMMAND=playwright-mcp runs the globally installed, version-pinned server, so the first browser call in a chat doesn't wait on an npx download. PLAYWRIGHT_EXTRA_ARGS defaults to --no-sandbox: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root app user, so the container is the isolation boundary. Overriding PLAYWRIGHT_EXTRA_ARGS must keep --no-sandbox. PLAYWRIGHT_ENABLED follows INSTALL_PLAYWRIGHT (nested default, supported by Compose v2), so an image built without Playwright doesn't advertise tools it can't run.
  • The owner (first id in TELEGRAM_ALLOWED_USER_IDS) gets the 🟢 Online. message and the Claude Code re-login prompts. The calendar has no owner special case: every chat connects its own account.
  • Coolify prefixes named volumes with the resource uuid; they survive redeploys and are only removed when the resource is deleted with its volumes. Bind mounts (as in VolcanicSecretary) also work but the host directories must then be owned by uid 1654; the doc mentions this in one line.
  • Deploys: Coolify stops the old container before starting the new one for compose resources. If they ever overlap, the second poller gets 409 Conflict, which the polling service already backs off from.

Healthcheck

None. The bot serves no HTTP, and the failure modes a probe could detect are already handled in-process: lost connectivity self-heals with backoff (ReconnectPolicy), a dead polling loop is restarted, and the unrecoverable cases exit the process (401 → exit 1, invalid config → exit 2), which restart: unless-stopped turns into a visible restart loop in Coolify's logs. A file-heartbeat probe would add a moving part for no new signal. Coolify shows the service as running without health status; that is acceptable.

First run and Claude login

  1. In Coolify: new resource from this repo, build pack Docker Compose, set TELEGRAM_BOT_TOKEN and TELEGRAM_ALLOWED_USER_IDS (owner first), deploy.
  2. The start log says Claude Code was found (image-provided) and lists allowed users.
  3. The owner opens the bot, sends /start, then /claude-code-login: link + code prompt arrive as one message, the pasted code is fed to claude auth login --claudeai inside the container, and credentials land in /data/claude. /claude-code-doctor should then report logged in, claude.ai, bridge ok.
  4. Later expiries use the existing owner re-login flow; nothing Docker-specific.

Fallback without the bot: docker exec -u app -it <container> claude auth login --claudeai (same CLAUDE_CONFIG_DIR; docker exec defaults to root, whose credentials the bot couldn't read). Copying an existing .credentials.json into the claude volume also works but is not the documented path.

Extensions and project context

  • Extra extension assemblies go to /data/agent/extensions/*.dll (user directory) or /data/workspaces/<chatId>/.volcanic/extensions/ (project directory); discovery is unchanged. Getting a DLL there is docker cp + restart; they must be built against the same Volcanic.AgenticCore.Abstractions version as the image.
  • AGENTS.md / CLAUDE.md, PERSONA.md, MEMORY.md live in each chat's workspace directory in the workspaces volume, edited by the agent or by docker exec.

Docs

  • New docs/docker.md: build args, env table (with which variables the agent can't see, and PROCESS_ENV_ALLOW), volumes, Coolify steps, first login, no-API-key warning, updating Claude Code (redeploy; set CLAUDE_CODE_VERSION to pin), troubleshooting ([executable_missing], [auth], [mcp_startup] → bridge files, permission denied on bind mounts → chown 1654, 409 → another instance).
  • README.md: one line under the Telegram quick start linking docs/docker.md.
  • docs/telegram-bot.md: "Security" points at the container as the recommended way to run it.
  • Update the "Out of scope: … Docker/Coolify" line in 2026-09-25-telegram-host-design.md to reference this spec.

Verification

No unit tests (nothing in C# changes). Manual checklist in the implementation plan:

  1. docker compose build succeeds with INSTALL_PLAYWRIGHT=false and true; the bridge guard and claude --version pass.
  2. docker compose up with a real bot: allowed user gets answers; /claude-code-login then /claude-code-doctor all green; id via the agent's bash reports uid 1654; env via the agent's bash shows neither TELEGRAM_BOT_TOKEN nor OPENAI_API_KEY, and date shows the chat's zone after /timezone Europe/Berlin; cat /proc/1/environ via the agent's bash fails with permission denied, and no dotnet-diagnostic-* socket exists under /tmp.
  3. Agent can run git, rg, python3, node, npx; with Playwright, playwright-mcp --version prints the pinned version offline, and "Open https://example.com and tell me the H1" works in a chat on the first try (no npx download, no sandbox error).
  4. docker compose down && docker compose up -d --build: previous chat resumes, no re-login needed, workspace files remain.
  5. Setting ANTHROPIC_API_KEY in compose makes the provider refuse with its documented error (then remove it).

Open questions

  1. Persist HOME too? ~/.gitconfig, ~/.ssh, gh auth and the npm/npx cache are lost on redeploy with this design. A fourth volume at /home/app would keep them; left out until there is a need.
  2. Playwright default: compose builds it in (true) on the assumption that the MCP spec ships; flip the compose default to false if that spec lands later. (Sandboxing and which binary is launched are settled above.)
  3. CLAUDE_CODE_VERSION=2.1 tracks the newest 2.1.x at build time. Pin an exact, doctor-verified version instead, at the cost of manual bumps?
  4. Should the agent get a .NET SDK in the image (INSTALL_DOTNET_SDK build arg, +~700 MB) so it can build .NET projects in its workspace?