forked from admin/VolcanicAgenticCore
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.
240 lines
18 KiB
Markdown
240 lines
18 KiB
Markdown
# 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
|
||
|
||
```yaml
|
||
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?
|