Skip to main content
Docker packages Comis and all its dependencies into a single container, making deployment consistent across any server. This page covers the production setup — for a quick start, see the Installation Docker page.
The Installation Docker page covers basic container setup using the docker-setup.sh script. This page focuses on production hardening: multi-stage builds, health checks, security, volume management, and the full service architecture.

Production Dockerfile

The repository includes a 4-stage Dockerfile that produces a minimal production image. Source code, build tools, and development dependencies are discarded after compilation.

Build architecture

Stage 1 — build: Starts from node:22-bookworm with corepack/pnpm enabled. Copies dependency manifests first for layer caching, installs dependencies with a BuildKit cache mount on the pnpm store, compiles all packages with pnpm build, then prunes devDependencies and removes .d.ts, .map, and .tsbuildinfo files. Stage 2 — runtime-assets: Copies the built output from Stage 1 and strips remaining source files (src/, test/, tsconfig*.json, vitest.config.*) so only dist/ and node_modules remain. Stage 3 — base variant selection: Two base image options are defined:
  • base-default — full node:22-bookworm (includes debugging tools)
  • base-slim — minimal node:22-bookworm-slim
The COMIS_VARIANT build arg selects which base to use (default: slim). Stage 4 — final: Installs runtime dependencies (procps, curl, ca-certificates, dumb-init, git), creates a non-root comis user (UID/GID 1000), copies from runtime-assets, creates a comis CLI symlink at /usr/local/bin/comis, and configures dumb-init as PID 1. Exposes port 4766 with a health check (60s interval, 10s timeout, 30s start period, 3 retries).

Build args

ARG COMIS_NODE_BOOKWORM_IMAGE="node:22-bookworm"
ARG COMIS_NODE_BOOKWORM_SLIM_IMAGE="node:22-bookworm-slim"
ARG COMIS_VARIANT="slim"
ARG COMIS_DOCKER_APT_PACKAGES=""
ARG COMIS_WITH_BROWSER=0
ARG COMIS_WITH_XVFB=0
ARG COMIS_WITH_CLOAKBROWSER=0
  • Digest pinning — the base image ARGs accept @sha256: digests for reproducible builds. Override at build time with values resolved from docker inspect --format='{{.RepoDigests}}' node:22-bookworm, e.g. docker build --build-arg COMIS_NODE_BOOKWORM_IMAGE=node:22-bookworm@sha256:<digest> ....
  • Variant selection — set COMIS_VARIANT=default for the full image with debugging tools, or leave as slim for production.
  • Custom packages — set COMIS_DOCKER_APT_PACKAGES to install additional system packages into the final image.
  • Browser tool — three independent flags mirror the bare-VPS installer: COMIS_WITH_BROWSER=1 installs Google Chrome + headless shared libs; COMIS_WITH_XVFB=1 adds Xvfb (entrypoint shim starts it on :99 before the daemon); COMIS_WITH_CLOAKBROWSER=1 installs CloakBrowser (stealth Chromium) instead of stock Chrome. All implications and the datacenter-IP caveat are documented in agent-tools/browser.

Installer-built variant (Dockerfile.install)

The repo ships a second Dockerfile that runs install.sh end-to-end inside the container instead of doing the multi-stage TypeScript build. Same build args, same final image shape, but exercises the bare-VPS installer code path:
docker build -f Dockerfile.install \
  --build-arg COMIS_WITH_CLOAKBROWSER=1 \
  -t comis-installed:cloak .
Use this for CI validation of install.sh changes, or when you want strict parity with a bare-VPS deploy. The main Dockerfile remains the production path (smaller, faster, no apt repo activity in the final layer).
The multi-stage build keeps the final image small. Only compiled JavaScript, production dependencies, and minimal system tools are included. The full Dockerfile is in the repository root.

Docker Compose

The docker-compose.yml in the repository root defines three services with a profile-based activation model.

Services

comis-daemon (always runs):
  • Gateway port binding via COMIS_GATEWAY_HOST and COMIS_GATEWAY_PORT env vars (default: 127.0.0.1:4766)
  • Volumes for /home/comis/.comis (persistent data) and /etc/comis (config); both bind-mount the same host dir (~/.comis by default)
  • Environment variable pass-through for secrets, provider keys, and channel tokens
  • Health check: curl -sf http://127.0.0.1:4766/health (60s interval, 10s timeout, 30s start period, 3 retries)
  • Resource limits: 2G memory / 2 CPU (limit), 256M / 0.5 CPU (reservation)
  • JSON log rotation: 50MB max size, 5 files
comis-web (profile: web):
  • Nginx-served Lit SPA dashboard on port 8080
  • Built from Dockerfile.web (see Web dashboard below)
  • Depends on a healthy comis-daemon
  • Health check via wget on port 8080
comis-cli (profile: cli):
  • Shares the daemon’s network namespace (network_mode: service:comis-daemon) so it reaches the gateway at 127.0.0.1:4766
  • Interactive mode (stdin/tty enabled) for one-shot commands
  • Security hardened: no-new-privileges, drops NET_RAW and NET_ADMIN capabilities

Using profiles

# Start only the daemon (default)
docker compose up -d

# Start daemon + web dashboard
docker compose --profile web up -d

# Run a CLI command against the running daemon
docker compose --profile cli run --rm comis-cli status
docker compose --profile cli run --rm comis-cli agent list

Build and run

# Build and start (first time or after code changes)
docker compose up -d --build

# Build web dashboard image (if using web profile)
docker compose --profile web build
docker compose --profile web up -d

View logs

docker compose logs -f comis-daemon
Look for the "Comis daemon started" message confirming the daemon is running.

Stop services

# Stop daemon
docker compose down

# Stop web dashboard
docker compose --profile web down

Volume management

The daemon uses two mount points inside the container:
Container pathPurposeDefault host path
/home/comis/.comisSQLite databases, logs, traces, secrets, .env~/.comis
/etc/comisCanonical Linux config path — same host dir as above; the daemon reads config.yaml here and writes config.last-good.yaml next to it~/.comis

Files in /home/comis/.comis

FileWhat it contains
memory.dbAgent conversations and memory (SQLite database)
daemon.logLog files (if file logging is enabled)
secrets.dbEncrypted secrets store
traces/Execution traces
.envRuntime environment (token, network settings)
If you do not mount a volume for /home/comis/.comis, you will lose all agent memory and configuration when the container restarts. The Compose file mounts COMIS_DATA_DIR (default ~/.comis) to /home/comis/.comis to ensure persistence.

Backup

docker compose stop comis-daemon
docker cp comis-daemon:/home/comis/.comis ./comis-backup
docker compose start comis-daemon

Health checks

The HEALTHCHECK directive in the Dockerfile sends a request to the GET /health endpoint. Configuration:
ParameterValue
Interval60s
Timeout10s
Start period30s
Retries3
The endpoint returns:
{ "status": "ok", "timestamp": "2026-03-15T10:00:00.000Z" }
Docker uses the health check to determine container status:
StatusWhat it means
healthyThe daemon is running and the gateway is responding
unhealthyThree consecutive health checks failed
startingWithin the 30s start period; failures are not counted
You can see the health status in docker ps output:
docker ps
CONTAINER ID   IMAGE        STATUS                   PORTS
abc123         comis:local  Up 5 minutes (healthy)   127.0.0.1:4766->4766/tcp
With restart: unless-stopped in Compose, Docker automatically restarts the container if it exits.

Security

The production setup includes several hardening measures:
  • Non-root user — the container runs as the comis user (UID/GID 1000), not as root
  • dumb-init — proper PID 1 signal handling prevents zombie processes and ensures clean shutdowns
  • Digest-pinned base images — ARGs at the top of the Dockerfile accept @sha256: digests for reproducible, tamper-evident builds
  • UID/GID 1000 — predictable file ownership on host-mounted volumes
  • Writable state mounts — both /home/comis/.comis (data, including .env) and /etc/comis (config) are mounted read-write so the daemon can persist new credentials (via the agent’s gateway env_set action, comis configure, or the env.set RPC) and write its config.last-good.yaml snapshot next to config.yaml for the bootstrap-failure recovery path. Prompt-injected exec is contained separately by the bwrap sandbox, which excludes /home/comis/.comis and /etc/comis from the per-command mount set entirely (see Per-command exec sandbox below).
  • Minimal base imagenode:22-bookworm-slim has fewer packages and a smaller attack surface than the full image
  • No unnecessary ports — only port 4766 is exposed
  • Resource limits — memory and CPU limits prevent runaway processes (2G/2CPU limit, 256M/0.5CPU reservation)
  • JSON log rotation — daemon logs rotate at 50MB with 5 files retained, preventing disk exhaustion
  • CLI hardening — the CLI service sets security_opt: no-new-privileges and drops NET_RAW and NET_ADMIN capabilities

Platform Support

Production deployments must run on a Linux host — bare metal, a Linux VPS, or any cloud Linux VM. macOS and Windows Docker Desktop are supported for development and testing only.

Why

Docker Desktop runs containers inside a linuxkit VM. Its kernel does not allow a nested PID namespace to mount a fresh procfs — the operation EPERMs even when the container has apparmor=unconfined and seccomp=unconfined. The bubblewrap exec sandbox always uses --unshare-pid paired with --proc /proc, so on Docker Desktop every bwrap invocation aborts at the proc-mount step before forking the inner shell. To keep the agent functional for local testing despite this kernel limitation, the daemon detects the failure mode at startup (via a one-shot smoke test) and automatically disables the exec sandbox when running inside a container with a non-functional bwrap. Behaviour by environment:
EnvironmentSandbox stateexec works?Trust boundary
Linux host (bare metal, VPS, cloud VM)bwrap activeyes, sandboxedper-command bwrap mount-set isolation
Docker Desktop on macOS / Windowsauto-disabledyes, unsandboxedthe container itself (effectively none, see below)
Bare-metal Linux with broken bwrap (rare)bwrap returned anywayno, fails at runtimen/a — operator must fix kernel/userns config
Inside Docker Desktop with the sandbox auto-disabled, agent-issued shell commands run as the comis user with full read access to everything the daemon owns inside the container — /home/comis/.comis/.env, the encrypted secrets.db, /etc/comis/config.yaml, channel tokens, the SECRETS_MASTER_KEY. A single prompt-injected cat /home/comis/.comis/.env exfiltrates them all. This is why Docker Desktop is dev/testing only. On a real Linux host the sandbox runs unrestricted and these paths are not mounted into the bwrap’d process at all. You can confirm the active mode in the daemon’s startup logs:
docker compose logs comis-daemon | grep -E "Exec sandbox|smoke test"
Log lineMeaning
Exec sandbox provider detected provider=bwrapSandbox is active.
Exec sandbox DISABLED ... shell commands will run UNSANDBOXEDAuto-disabled fallback. Do not use this configuration in production.
bwrap installed but smoke test failed -- exec sandbox is non-functional on this kernelBare-metal kernel rejected bwrap. Investigate and fix; do not deploy.

Recommendations for Mac / Windows users

Use caseRecommendation
Local development / quick try-outDocker Desktop is the fastest path. Sandbox is auto-disabled — treat the container’s data dir as untrusted, never put real production secrets in ~/.comis-docker/.env.
Local development that exercises exec with a real sandboxUse a Linux VM on your Mac instead of Docker Desktop: Lima, Colima with the vz VM type, OrbStack with a Linux distro, or UTM. The container kernel inside those is a real Linux kernel and bubblewrap runs unrestricted.
Production / multi-user deploymentDeploy to a real Linux host: any major cloud VM, a self-hosted Linux server, or a single-board computer running Linux. The same image and compose files work without changes.

Per-command exec sandbox (bubblewrap)

The Docker image ships bubblewrap (bwrap) and the daemon uses it as a per-command sandbox for every shell command issued by the agent’s exec skill. This is not redundant with Docker’s container isolation — the two boundaries protect different things:
BoundaryWhat it isolates
DockerThe container from the host kernel and other containers
bwrapEach agent-issued shell command from the daemon’s own data inside the container — /home/comis/.comis/.env, /home/comis/.comis/secrets.db, /etc/comis/config.yaml, channel tokens, the master encryption key

Why this matters

Comis processes user input (chat messages, channel events) through an LLM that has tool-use authority. A prompt-injection-style payload can drive the exec skill to run arbitrary shell commands. Without bwrap, those commands run as the comis user inside the container — the same user that owns /home/comis/.comis — so a single cat /home/comis/.comis/.env would exfiltrate SECRETS_MASTER_KEY, which decrypts every credential ever stored in secrets.db. That makes the “container is the trust boundary” model unsafe for any deployment that accepts untrusted user input and stores secrets and enables exec. bwrap closes this gap by running each exec’d command under --unshare-all (new mount/pid/ipc/uts/user namespaces, only network is shared) and only mounting the agent’s workspace, system binaries (read-only), and a fresh tmpfs for /tmp. /home/comis/.comis, /etc/comis, and /home/comis are not mounted into the sandbox — they don’t exist from the perspective of an exec’d command.

Required compose security_opt

docker-compose.yml configures the daemon service with:
security_opt:
  - apparmor=unconfined
  - seccomp=unconfined
  - systempaths=unconfined
These opt-outs are required because bwrap needs to create a user + pid namespace and mount a private /proc inside it, and Docker’s defaults block all three layers:
  • AppArmor — the default docker-default AppArmor profile denies unprivileged user-namespace creation by binaries inside the container. Without apparmor=unconfined, bwrap fails with setting up uid map: Permission denied.
  • seccomp — Docker’s default seccomp profile filters several unshare and clone flags that bwrap needs. Without seccomp=unconfined, the same userns setup is blocked at the syscall layer instead of AppArmor.
  • systempaths — Docker masks and read-only-binds parts of /proc by default, which makes bwrap’s --proc /proc mount in the new namespace fail with Can't mount proc on /newroot/proc: Operation not permitted. Without systempaths=unconfined, userns succeeds but the sandbox aborts at the proc-mount step.
Ubuntu 23.10+ / 24.04 hosts need a host-level change too. These hosts set kernel.apparmor_restrict_unprivileged_userns=1, which denies user namespaces to the now-unconfined container bwrap — so even with all three security_opt entries, bwrap fails at setting up uid map: Permission denied. On such hosts also run, on the host:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0   # persist in /etc/sysctl.d
(Alternatively, load an AppArmor profile that grants userns to /usr/bin/bwrap.) This is a host setting; docker-compose.yml cannot set it.
When bwrap fails, the daemon does not abort — it logs a WARN and runs agent-issued shell commands unsandboxed. Always check the startup logs for Exec sandbox provider detected (provider name bwrap) to confirm the sandbox is active — if you see the sandbox disabled, the broker’s network containment is not enforced.

Verifying the sandbox

After docker compose up -d, confirm bwrap is active:
# Should print "bwrap"
docker compose logs comis-daemon | grep "Exec sandbox provider detected"

# Should print "/usr/bin/bwrap"
docker compose exec comis-daemon which bwrap

# Should fail with "Permission denied" or "No such file or directory"
# (bwrap's mount set excludes /home/comis/.comis)
docker compose exec comis-daemon bwrap \
  --unshare-all --share-net \
  --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 \
  --ro-bind /bin /bin --ro-bind /etc/passwd /etc/passwd \
  --tmpfs /tmp \
  /bin/sh -c 'cat /home/comis/.comis/.env'
If you have a deployment where the agent processes only trusted input and you want a smaller image or different isolation strategy, you can rebuild without bubblewrap:
# docker-compose.override.yml
services:
  comis-daemon:
    build:
      args:
        # Override the apt list — note the daemon will log a startup INFO
        # noting the sandbox is absent, and `exec` runs unsandboxed.
        COMIS_DOCKER_APT_PACKAGES: ""
    security_opt: !override
      - no-new-privileges:true
You also need to disable the exec skill in your agent config, or accept that prompt-injected commands can read all daemon state inside the container. There is no middle ground.

Secrets management

Comis supports three credential-storage modes, selected via security.storage in config.yaml. The encrypted secrets.db is the default — the master key is auto-generated on first boot and written to ~/.comis/.env (mode 0600). To opt out, set security.storage: env (or file).
Mode (security.storage)When to useHow it works
encrypted (default)All deployments. The encrypted store is active immediately on first boot — no manual key generation required.On first boot with no SECRETS_MASTER_KEY present, the daemon auto-generates a 32-byte master key, writes it to <data dir>/.env (mode 0600), and opens secrets.db on the same boot. Back up ~/.comis/.env — losing SECRETS_MASTER_KEY makes secrets.db permanently unreadable (AES-256-GCM, no key escrow). All three credential families — named secrets, OAuth profiles, and MCP OAuth tokens — live in secrets.db (the secrets, oauth_profiles, and mcp_credentials tables); no plaintext mcp-tokens/ directory is written.
env / file (opt-out)Single-tenant deployments where you want to manage credentials exclusively via env vars (env) or plaintext-at-0600 files (file) and do not need the encrypted store.Set security.storage: env (or file). The daemon boots in that mode and emits a startup WARN. Provider keys and channel tokens come from compose’s environment: pass-through and from <data dir>/.env. In file mode MCP OAuth tokens are written to the plaintext mcp-tokens/ directory (0600), matching secrets.json / auth-profiles.json. In env mode the daemon does not create secrets.db, and MCP OAuth login is unavailable (no writable token store — comis mcp login fails with an actionable storage-mode error).

Important caveat about secrets.db inside Docker

Storing both the encrypted database (/home/comis/.comis/secrets.db) and the master key (SECRETS_MASTER_KEY env var) inside the same container provides no additional protection against an in-container compromise — anyone who can read /proc/<daemon-pid>/environ or your compose config can recover the key and decrypt the DB. The encryption pays off only when:
  • You inject SECRETS_MASTER_KEY from a host-level secret manager (HashiCorp Vault, AWS Secrets Manager, Docker secrets backed by a tmpfs mount) so it never lives on disk in the container, AND
  • You can rely on the bwrap exec sandbox (see above) to keep the key out of agent-issued processes.
For most Docker deployments, the flat .env mode is both simpler and no less secure than running secrets.db with a co-located key.

Adding a credential

# Envfile-only mode: add to your repo .env (for compose pass-through)
# OR to the data dir's .env file (read by the daemon at startup)
echo "TELEGRAM_BOT_TOKEN=12345:abc..." >> ~/.comis/.env

# Then restart the daemon to reload the file
docker compose restart comis-daemon
The daemon prefers values already in process.env (set by compose) over values from <data dir>/.env, so per-deployment overrides via compose’s environment: block always win.

Environment file

Copy .env.docker.example from the repository root as your starting point:
cp .env.docker.example .env
This template includes all configurable variables: image selection, data/config paths, network binding, gateway token, secrets master key, LLM provider keys, and channel tokens. See the comments in .env.docker.example for details.
Pass API keys via environment variables (using .env or docker compose environment section), not in the config file mounted into the container. Environment variables are not written to the image layers.

Development overrides

The repository includes a docker-compose.override.yml that Docker Compose activates automatically when the file exists alongside docker-compose.yml. Development overrides change the following:
SettingProductionDevelopment
Image variantslim (bookworm-slim)default (full bookworm with debugging tools)
Port binding127.0.0.1:47660.0.0.0:4766 (all interfaces)
NODE_ENVproductiondevelopment
Resource limits2G / 2 CPU4G / 4 CPU
Web dashboardRequires --profile webStarts automatically (no profile needed)
To use production settings without overrides, either rename or remove docker-compose.override.yml:
mv docker-compose.override.yml docker-compose.override.yml.bak

Web dashboard

The web dashboard is built from Dockerfile.web — a 2-stage build:
  1. Build stagenode:22-bookworm-slim, installs the @comis/web package and its dependencies, runs pnpm build to produce the Lit SPA
  2. Serve stagenginx:alpine, copies the built SPA into the Nginx html directory with a custom config from docker/nginx.conf

Nginx configuration

The docker/nginx.conf provides:
  • SPA fallback — all routes serve index.html via try_files
  • API reverse proxy/api/ requests are proxied to comis-daemon:4766 with HTTP/1.1 upgrade support
  • WebSocket proxy/ws is proxied to the daemon’s WebSocket endpoint with an 86400s (24h) read timeout
  • Static asset caching — JS, CSS, images, and fonts get 30-day cache headers with Cache-Control: public, immutable
The dashboard runs on port 8080 with a health check via wget.

CI/CD

The repository ships two automated Docker workflows:
WorkflowRegistryTrigger
docker-release.ymlGitHub Container Registry (ghcr.io)Push to main or v* tag
dockerhub-release.ymlDocker Hub (comisai/comis)v* tag only
Both workflows build multi-arch manifests (linux/amd64 + linux/arm64) and produce both default and slim variants. The Docker Hub workflow version tags are derived directly from the git tag — pushing v1.0.26 produces comisai/comis:1.0.26, comisai/comis:1.0, and comisai/comis:latest (plus the matching -slim variants). For full details on image tags, required secrets, and manual publishing, see the Docker Hub Publishing page.

Updating

To update Comis to a new version:
# Pull latest code
git pull

# Rebuild and restart
docker compose up -d --build
If using the web dashboard:
docker compose --profile web up -d --build
The mounted /home/comis/.comis volume preserves your data across rebuilds. Only the application code is replaced.

Install with Docker

Quick-start Docker setup for getting started.

Reverse Proxy

Put Comis behind Nginx or Caddy for TLS and custom domains.

Daemon

How the daemon starts, runs, and shuts down.

Troubleshooting

Solutions to common issues.