Entrypoint drops the capability bounding set and sets no_new_privs. Security docs and spec no longer claim the agent can't reach the secrets; docker exec fallbacks use a clean environment. PLAYWRIGHT_ENABLED drops the nested compose default. Tests pin the Dockerfile's Playwright install step and the compose model fallback.
24 KiB
Docker deployment (Coolify) — design
Date: 2026-09-26. Status: implemented (plan: docs/superpowers/plans/2026-09-26-docker-deployment.md).
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", "--bounding-set=-all", "--no-new-privs", "--", "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):
WORKDIR /src. CopyDirectory.Build.props,VolcanicAgenticCore.slnxand only the*.csprojfiles the host needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, GoogleCalendar, Mcp, Scheduler, Telegram), thendotnet restore hosts/Volcanic.AgenticCore.Telegram— a cached layer until a project file changes.- Copy
src/,extensions/,hosts/anddotnet publish hosts/Volcanic.AgenticCore.Telegram -c Release --no-restore -o /out.TreatWarningsAsErrorsapplies, so a warning fails the image build as it failsdotnet build. - 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 aReferenceOutputAssembly="false"project reference; ifpublishdrops the runtimeconfig (it does not inbuild), fix the csproj, not the Dockerfile.
Runtime stage (runtime:10.0-noble), as root:
apt-get install --no-install-recommendsthe agent tools above, then NodeSourcenodejs; clean apt lists.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_modulesstays root-owned, so the CLI's auto-updater cannot replace the binary at runtime: a Claude Code upgrade changes whatsystem/initvalidation sees and invalidates the prompt cache, so it should only arrive with a rebuild.- If
INSTALL_PLAYWRIGHT=true:ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright,npm install -g @playwright/mcp@${PLAYWRIGHT_MCP_VERSION}, then run theplaywrightCLI from that package's own dependency withinstall --with-deps chromium, so browser build and library version match.chmod -R a+rX /opt/ms-playwright. Roughly +400 MB.PLAYWRIGHT_BROWSERS_PATHis 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_VERSIONdefaults to the exact version in the MCP spec'sPlaywrightPreset.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. 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.COPY --from=build /out /app,WORKDIR /app. NoUSER(see the User row); an earlier step checks thatappis uid/gid 1654, which the entrypoint hard-codes.- 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): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an
# image built with INSTALL_PLAYWRIGHT=false (no nested default: Coolify parses these expressions itself).
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-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.mdsays 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 inTelegram__AllowedUserIds__0..nform is awkward in Coolify. Everything else inTelegramOptionsis reachable asTelegram__<Name>(e.g.Telegram__ReconnectMaxDelay=00:02:00) without a compose change, by adding it in Coolify and toenvironment. The Claude provider'sVOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS/..._TOTAL_TIMEOUT_MSwork the same way, as do theScheduler__*,GoogleCalendar__*,ProcessEnvironment__*andTelegram__Speech__*/Telegram__InputDebouncesettings 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_SECRETturn on the calendar, and each chat then connects its own Google account. Empty values mean off, so they're inenvironmentand.env.examplefrom the start.OPENAI_API_KEYis not on the Claude Code provider's denied list and never reachesclaude. - Time zones: each chat has its own (
/timezone). A chat that never set one usesDEFAULT_TIMEZONE, else the container'sTZ(defaultUTC), so a deployment whose users are mostly in one zone should setDEFAULT_TIMEZONE. The chat's zone is also passed asTZto the agent'sbashand watcher scripts (the image hastzdata). Calendar times use the calendar's own zone, else the chat's. - Secrets set here (
TELEGRAM_BOT_TOKEN,OPENAI_API_KEY,GOOGLE_CLIENT_SECRETand anything matching*_API_KEY,*_TOKEN,*_SECRET,*_PASSWORDor theirSection__Keyforms) are removed from the environment of the agent'sbash, scheduled scripts and MCP servers by the process environment hook. To give the agent a credential on purpose (sayGH_TOKENforgh), add it in Coolify and list its name inPROCESS_ENV_ALLOW; an MCP server gets one through its ownenventry 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>/environor attach to it, PID 1's own/proc/1/environis 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 nodotnet-dump/dotnet-traceattach;ProcessEnvironment__HardenHostProcess=falseturns it off for a debugging session. The entrypoint also gives the bot an empty capability bounding set andno_new_privs, so nothing it spawns regains root through the base image's setuid binaries (su,mount,passwd, ...). All of this keeps secrets out of the agent's environment and tool output by accident; it does not stop an agent that goes after them on purpose.appowns/data/workspacesand/data/agent, extensions are discovered on every session creation, and MCP servers expand${env:NAME}from the host's unfiltered environment, so the agent can drop a DLL into<workspace>/.volcanic/extensions/(loaded into the bot at the next/new) or add an MCP server withenv: {"X": "${env:TELEGRAM_BOT_TOKEN}"}to/data/agent/settings.json(loaded after it kills the same-uid bot andrestart: unless-stoppedbrings it back). Anyone on the allow-list must be treated as able to obtain every secret configured here. The container stays the real isolation boundary: none of this is a sandbox (open question 5). - Playwright:
PLAYWRIGHT_COMMAND=playwright-mcpruns the globally installed, version-pinned server, so the first browser call in a chat doesn't wait on an npx download.PLAYWRIGHT_EXTRA_ARGSdefaults to--no-sandbox: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-rootappuser, so the container is the isolation boundary. OverridingPLAYWRIGHT_EXTRA_ARGSmust keep--no-sandbox.PLAYWRIGHT_ENABLEDdefaults totrue, so an image built withINSTALL_PLAYWRIGHT=falsealso needsPLAYWRIGHT_ENABLED=false(otherwise theplaywrightserver is logged as unavailable and its tools are missing). It doesn't followINSTALL_PLAYWRIGHTthrough a nested default (${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}): Compose v2 supports that, but Coolify parses these expressions itself to build its variable UI, and a value cut at the first}would make the host exit 2 in a restart loop. - 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
- In Coolify: new resource from this repo, build pack Docker Compose, set
TELEGRAM_BOT_TOKENandTELEGRAM_ALLOWED_USER_IDS(owner first), deploy. - The start log says Claude Code was found (image-provided) and lists allowed users.
- The owner opens the bot, sends
/start, then/claude-code-login: link + code prompt arrive as one message, the pasted code is fed toclaude auth login --claudeaiinside the container, and credentials land in/data/claude./claude-code-doctorshould then report logged in,claude.ai, bridge ok. - Later expiries use the existing owner re-login flow; nothing Docker-specific.
Fallback without the bot: docker exec -u app -it <container> env -i HOME=/home/app PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm claude auth login --claudeai (docker exec defaults to root, whose credentials the bot couldn't read). env -i matters: a
docker exec process gets the whole container environment, secrets included, and an ordinary (dumpable) app process
has its /proc/<pid>/environ readable by the agent's bash and watcher scripts for as long as it runs. 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 isdocker cp+ restart; they must be built against the sameVolcanic.AgenticCore.Abstractionsversion as the image. AGENTS.md/CLAUDE.md,PERSONA.md,MEMORY.mdlive in each chat's workspace directory in theworkspacesvolume, edited by the agent or bydocker exec.
Docs
- New
docs/docker.md: build args, env table (with which variables the agent can't see, andPROCESS_ENV_ALLOW), volumes, Coolify steps, first login, no-API-key warning, updating Claude Code (redeploy; setCLAUDE_CODE_VERSIONto 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 linkingdocs/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.mdto reference this spec.
Verification
One unit test (PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage); no C# behaviour changes. Checked
during implementation without a real bot: both image variants build; bridge guard, claude --version, the agent
tools, uid 1654, /data ownership; root-owned PID 1 whose /proc/1/environ and the bot's are unreadable to app;
no dotnet-diagnostic-* socket; playwright-mcp --version offline and a headless Chromium launch as app;
docker compose config; host startup log and the 401 → exit 1 path. The rest of this list needs a real bot and
stays a manual checklist:
docker compose buildsucceeds withINSTALL_PLAYWRIGHT=falseandtrue; the bridge guard andclaude --versionpass.docker compose upwith a real bot: allowed user gets answers;/claude-code-loginthen/claude-code-doctorall green;idvia the agent's bash reports uid 1654;envvia the agent's bash shows neitherTELEGRAM_BOT_TOKENnorOPENAI_API_KEY, anddateshows the chat's zone after/timezone Europe/Berlin;cat /proc/1/environvia the agent's bash fails with permission denied, and nodotnet-diagnostic-*socket exists under/tmp.- Agent can run
git,rg,python3,node,npx; with Playwright,playwright-mcp --versionprints 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). docker compose down && docker compose up -d --build: previous chat resumes, no re-login needed, workspace files remain.- Setting
ANTHROPIC_API_KEYin compose makes the provider refuse with its documented error (then remove it).
Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containers):
dotnet build: 0 warnings, 0 errors.dotnet test: all green except the three Windows-only baseline failures inVolcanic.AgenticCore.Tests. Changing the Dockerfile'sPLAYWRIGHT_MCP_VERSIONto0.0.81makesDockerfile_version_matches_DefaultPackagefail (Expected "0.0.82", Actual "0.0.81").docker build --build-arg INSTALL_PLAYWRIGHT=falseand=trueboth succeed.claude --version→2.1.283 (Claude Code); bridgeruntimeconfig.jsonpresent;DOTNET_EnableDiagnostics=0;git,rg,python3,node(v24.21.0),npx,jq,setprivonPATH;/data/{workspaces,agent,claude}owned byapp. With Playwright,playwright-mcp --version→Version 0.0.82with--network none, Chromium under/opt/ms-playwright, and a headless Chromium launch asapprenders a page.- Running host,
--init --network none: PID 1docker-initas root, the bot'sdotnetas uid/gid 1654 with no effective capabilities;/proc/1/environand the bot'senvironarePermission deniedforapp(the bot's also for a rootdocker exec, since the host is not dumpable); nodotnet-diagnostic-*socket. The log showsHost process hardened: not dumpable,Process environment: 15 deny rules, allow: none, the MCP line (disabled/enabled (headless, chromium)),Speech to text,Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5,Scheduler,Default time zone: UTC,Allowed users: 1. Workspaces under /data/workspaces, thenTelegram unreachablebackoff. docker compose config: with a minimal.env(INSTALL_PLAYWRIGHT=false,PLAYWRIGHT_ENABLED=false), both are"false"; with.env.example's defaults both are"true"andTelegram__Modelis the compose fallback;--no-sandbox,playwright-mcp,init: true,shm_size, three named volumes, no ports. Not yet checked on Coolify itself.docker compose up -d --buildwithTELEGRAM_BOT_TOKEN=123:fake: same startup lines, thenTelegram rejected the bot token (401 Unauthorized), exit 1, and a visiblerestart: unless-stoppedloop; no other warnings.- After review (entrypoint with
--bounding-set=-all --no-new-privs): the bot's/proc/<pid>/statusshowsCapEff/CapBndall zero andNoNewPrivs: 1;suasappfails; the host starts and logs the same lines; a headless Chromium launch (--no-sandbox) under the samesetprivflags renders a page; theenv -ilogin fallback runsclaude --versionwith onlyHOME,PATH,CLAUDE_CONFIG_DIR,TERMin its environment.
Open questions
- Persist
HOMEtoo?~/.gitconfig,~/.ssh,ghauth and the npm/npx cache are lost on redeploy with this design. A fourth volume at/home/appwould keep them; left out until there is a need. Playwright default: resolved. The MCP spec shipped first, so compose keeps building it in (true). (Sandboxing and which binary is launched are settled above.)CLAUDE_CODE_VERSION=2.1tracks the newest 2.1.x at build time. Pin an exact, doctor-verified version instead, at the cost of manual bumps?- Should the agent get a .NET SDK in the image (
INSTALL_DOTNET_SDKbuild arg, +~700 MB) so it can build .NET projects in its workspace? - The agent can reach the configured secrets on purpose (see the secrets bullet): it owns the directories that
extension and MCP-server discovery read. Mitigate with a host option that turns off workspace/agent-dir extension
and settings discovery in the container, or with root-owned, read-only
extensions/andsettings.jsonpaths? Until then this is documented, and the allow-list is the trust boundary.