Running Comis in a Docker container for production
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.
The repository includes a 4-stage Dockerfile that produces a minimal production
image. Source code, build tools, and development dependencies are discarded after
compilation.
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).
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.
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:
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.
# Start only the daemon (default)docker compose up -d# Start daemon + web dashboarddocker compose --profile web up -d# Run a CLI command against the running daemondocker compose --profile cli run --rm comis-cli statusdocker compose --profile cli run --rm comis-cli agent list
# 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 builddocker compose --profile web up -d
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.
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 image — node: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
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.
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:
Environment
Sandbox state
exec works?
Trust boundary
Linux host (bare metal, VPS, cloud VM)
bwrap active
yes, sandboxed
per-command bwrap mount-set isolation
Docker Desktop on macOS / Windows
auto-disabled
yes, unsandboxed
the container itself (effectively none, see below)
Bare-metal Linux with broken bwrap (rare)
bwrap returned anyway
no, fails at runtime
n/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 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 sandbox
Use 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 deployment
Deploy 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.
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:
Boundary
What it isolates
Docker
The container from the host kernel and other containers
bwrap
Each 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
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.
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.
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.ymlservices: 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.
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 use
How 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).
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.
# 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 filedocker 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.
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.
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:
Setting
Production
Development
Image variant
slim (bookworm-slim)
default (full bookworm with debugging tools)
Port binding
127.0.0.1:4766
0.0.0.0:4766 (all interfaces)
NODE_ENV
production
development
Resource limits
2G / 2 CPU
4G / 4 CPU
Web dashboard
Requires --profile web
Starts automatically (no profile needed)
To use production settings without overrides, either rename or remove
docker-compose.override.yml:
The repository ships two automated Docker workflows:
Workflow
Registry
Trigger
docker-release.yml
GitHub Container Registry (ghcr.io)
Push to main or v* tag
dockerhub-release.yml
Docker 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.