Skip to main content
how-to

Run Ethos in Docker

Run Ethos via Docker Compose. Set one provider API key, run one command, and get a web UI you can talk to. Config is provisioned by the CLI (ethos setup --from-env), which validates your key before writing it — no interactive setup, no hand-edited YAML.

Task​

Run Ethos via Docker Compose with at least one provider API key.

Result​

Web UI at http://localhost:3000 that opens directly in chat, config validated and written into a named volume. Config and session data persist across restarts.

Prerequisites​

  • Docker 24+ with Compose v2 (docker compose subcommand, not the legacy docker-compose binary).
  • At least one provider API key (Anthropic, OpenAI, OpenRouter, Google, or Azure).

The shipped image does not include ffmpeg. Everything except spoken replies on channels works without it; to get those, add ffmpeg to the runtime stage of docker/Dockerfile (apt-get install -y --no-install-recommends ffmpeg) and rebuild. Without it the gateway still starts, prints a ⚠ ffmpeg not found notice, and delivers voice notes only in formats the TTS provider already emits — see Send and receive voice notes on a channel.

Quick start (single service)​

The single-service profile is the fastest path: one service, one volume, one command. Put a key in .env next to the compose file, then bring it up.

echo "ANTHROPIC_API_KEY=sk-ant-…" > docker/.env
docker compose -f docker/docker-compose.single.yml up

Watch for two lines in the boot output: ✓ Config validated from provisioning, then an open: line printed shortly after by the serve sub-process.

✓ Config validated — web UI: http://localhost:3000
...
open: http://localhost:3000/auth/exchange?t=<token>

Open that auth/exchange URL first, not the bare http://localhost:3000. docker-compose.single.yml binds the web server to 0.0.0.0, which marks the auth cookie Secure — the browser can only get it by exchanging the token. This is one-time: the exchange sets the cookie and redirects to /, and the bare URL works on every visit after that as long as the volume and cookie aren't cleared.

Token mode is the default steady state — nothing forces you past it. Optionally, claim the instance at http://localhost:3000/welcome (enter the token, set a username and password); after that the exchange URL stops signing browsers in and instead opens the reset form at /welcome/reset with the token prefilled, and the web UI asks for the username and password.

The provider key from .env is never re-asked once validated and written — but that's separate from the token exchange above, which is still required on the first visit. Type a message and the first reply streams back.

At boot the entrypoint runs ethos setup --from-env (gated by ETHOS_PROVISION_FROM_ENV=1): it validates the provider key, writes config.yaml into the volume once, and re-syncs secrets from .env on every restart. A rejected key (401) stops the boot with an actionable final line — for example ANTHROPIC_API_KEY rejected (401) — check the key in .env and re-run docker compose up.

Three-service topology (operators)​

For separate web and gateway processes — the operator setup — use the three-service compose file. It runs a one-shot init service, then long-running ethos-web (port 3000) and ethos-gateway services.

echo "ANTHROPIC_API_KEY=sk-ant-…" > docker/.env
docker compose -f docker/docker-compose.yml up

The init service runs the same ethos setup --from-env provisioning, validates the key, writes config.yaml, then exits. Both runtime services wait for it (depends_on: service_completed_successfully) so they never start against a missing config. Subsequent runs re-sync secrets from .env and preserve any edits you made to config.yaml in the volume.

Verify​

curl -fsS http://localhost:3000/healthz
# {"status":"ok","uptime":42.1}

Check the init service exited cleanly:

docker compose -f docker/docker-compose.yml ps init
# State should be "exited (0)"

Required env vars​

At least one must be set. ethos setup --from-env uses the first one it finds, in priority order:

VariableProviderNotes
AZURE_API_KEYAzure OpenAIrequires AZURE_ENDPOINT; optional AZURE_OUTPUT_CAP_PARAM (max_tokens or max_completion_tokens) provisions providers.0.outputCapParam — set max_completion_tokens for a reasoning model deployed under a non-family name; any other value stops the boot
ANTHROPIC_API_KEYAnthropic (Claude)—
OPENAI_API_KEYOpenAI—
OPENROUTER_API_KEYOpenRouter—
GOOGLE_API_KEYGoogle (Gemini)—

Set TELEGRAM_BOT_TOKEN (and optionally TELEGRAM_OWNER_ID) to provision a Telegram bot in the same pass. Tokens are validated before they are written: a rejected token (401) stops the boot; an unreachable endpoint is saved unverified with a warning. Set ETHOS_SKIP_VALIDATION=1 to bypass all probes on air-gapped boots.

Mode-aware healthcheck​

The image bakes one healthcheck script (docker-healthcheck.sh) that probes the right endpoint for the container's ETHOS_MODE, so both Compose services and raw docker run users get a check that can actually pass.

ETHOS_MODEProbeServes
all:3000/healthzweb UI + supervised gateway
ui:3000/healthzweb UI only
gateway:3002/healthzgateway's own health server
boot (default):3002/healthzmerged scale-to-zero single-process profile — same health server as gateway

A hardcoded :3000 check could never pass in gateway mode — nothing listens there. The script fixes that.

Liveness is deliberate: only a definitive local failure flips a container unhealthy. A running process with an upstream outage (a Telegram blip, an adapter reporting not-ok) reports degraded over HTTP 503 but stays healthy — an upstream hiccup must not fail a fresh compose up. In all mode the check also fails if the supervised gateway's heartbeat goes stale or missing, which catches the gateway child dying while the web process stays up. This chains the existing gateway heartbeat — it is not a second health mechanism.

For readiness rather than liveness — a Kubernetes readinessProbe, or a load balancer deciding whether to route to a replica — gateway and boot also serve :3002/readyz. It returns 503 when any adapter reports not-ok, when any of sessions.db, delivery-ledger.db, inbound-dedup.db or inbound-spool.db will not open read-only, or when the event loop's p99 delay is over one second, and lists each check in the JSON body (createReadinessCheck in health-server.ts). Adapter health is cached for 60 seconds, so a probe never costs an adapter round trip such as an IMAP login. The baked healthcheck stays on /healthz: a 503 from /readyz means "do not route to me", not "restart me".

Gateway opt-in​

The channel gateway (Telegram, Slack, Discord, Email) is off by default. Activate it with the gateway profile:

ANTHROPIC_API_KEY=sk-ant-… docker compose -f docker/docker-compose.yml --profile gateway up

The gateway service connects to each configured channel bot and routes inbound messages to the appropriate personality. Configure channel bots in config.yaml inside the volume — see config.yaml reference.

Persisting data​

Both Compose files declare a named volume ethos-data, mounted at /home/ethos/.ethos. This volume holds config.yaml, the session database, personality data, and memory files.

:::warning Keep state off Docker Desktop bind mounts On Docker Desktop (macOS and Windows), a host directory mounted into the container is a virtiofs or gRPC-FUSE mount (macOS) or a 9p mount (Windows). SQLite's file locking is not reliable on those filesystems, and the databases in the state directory can corrupt. Keep the default named volume there. ethos doctor inside the container warns under Data directory when the state directory sits on FUSE, 9p, NFS or SMB (checkStateDirFilesystem in apps/ethos/src/commands/doctor.ts). :::

If you run native Docker on Linux and want the state in a host directory, set ETHOS_DATA_DIR in docker/.env to its path. Compose then bind-mounts that path instead of the volume.

Move an existing ethos-data/ folder into the volume​

Before this release both Compose files bind-mounted a host folder (../ethos-data for docker-compose.yml, docker/ethos-data for the single-service file). If you have one and have not set ETHOS_DATA_DIR, copy it into the volume once. From the docker/ folder, with the stack stopped:

docker compose -f docker-compose.single.yml run --rm --no-deps --user root \
-v "$(pwd)/ethos-data:/from:ro" --entrypoint sh ethos \
-c 'cp -a /from/. /home/ethos/.ethos/ && chown -R 1000:1000 /home/ethos/.ethos'

For docker-compose.yml, run the same command against the init service with -f docker-compose.yml and -v "$(pwd)/../ethos-data:/from:ro". To keep the bind mount instead, set ETHOS_DATA_DIR=../ethos-data (or ./ethos-data) in docker/.env.

The volume survives docker compose down. To back it up:

docker run --rm -v ethos-data:/data -v "$(pwd)":/backup alpine tar czf /backup/ethos-backup.tar.gz -C /data .

To restore:

docker run --rm -v ethos-data:/data -v "$(pwd)":/backup alpine tar xzf /backup/ethos-backup.tar.gz -C /data
warning

Do NOT run multiple containers sharing the same ethos-data volume. CronScheduler uses file-based locking and is not safe for concurrent multi-host access. scale=1 per service is the only supported configuration. Running multiple replicas against the same volume will cause lock contention, data corruption, and undefined behaviour.

Advanced: custom config.yaml​

To skip the init auto-generation, write your own config.yaml into the volume before the first run:

# Create the volume and populate it
docker volume create ethos-data
docker run --rm -v ethos-data:/data -v "$(pwd)":/src alpine cp /src/config.yaml /data/config.yaml

The init service is idempotent — it detects the existing config and exits immediately.

See config.yaml reference for every supported field.

Advanced: manual docker run​

If you prefer not to use Compose, run the image directly. Both services bind to 127.0.0.1 inside the container by default, so you must set ETHOS_WEB_HOST=0.0.0.0 (web dashboard, port 3000) and ETHOS_SERVE_HOST=0.0.0.0 (the run-all health server, port 3004) for -p to reach them from the host.

That non-loopback bind means the same first-visit token-exchange step from Quick start applies here too — watch docker logs ethos for the open: http://localhost:3000/auth/exchange?t=<token> line and visit that URL before the bare one.

docker run -d --name ethos \
--restart unless-stopped \
-e ETHOS_MANAGED=1 \
-e ETHOS_WEB_HOST=0.0.0.0 \
-e ETHOS_SERVE_HOST=0.0.0.0 \
-e ANTHROPIC_API_KEY=sk-ant-… \
-v ethos-data:/home/ethos/.ethos \
-p 3000:3000 \
ethosagent/ethos:latest

ethosagent/ethos:latest always tracks the newest published release, so no version pinning is needed. To pin a specific version, use ethosagent/ethos:<version> (for example ethosagent/ethos:0.4.18).

Option B — build from source then run​

git clone https://github.com/ethosagent/ethos.git
cd ethos
docker build -t ethos:local -f docker/Dockerfile docker/
docker run -d --name ethos \
--restart unless-stopped \
-e ETHOS_MANAGED=1 \
-e ETHOS_WEB_HOST=0.0.0.0 \
-e ETHOS_SERVE_HOST=0.0.0.0 \
-e ANTHROPIC_API_KEY=sk-ant-… \
-v ethos-data:/home/ethos/.ethos \
-p 3000:3000 \
ethos:local

The image runs as ethos:1000 and exposes port 3000. ETHOS_MANAGED=1 skips the interactive setup wizard and exits with code 2 if config.yaml is missing.

To run only the gateway (no web API), pass -e ETHOS_MODE=gateway. To run only the web API, pass -e ETHOS_MODE=ui.

The image's default when ETHOS_MODE is unset — as in Option A/B's commands above — is now boot, not all. boot still gives a talking web UI at the same :3000, just via the merged single-process profile rather than two supervised subprocesses. Pass -e ETHOS_MODE=all to opt back into the two-subprocess topology with independent crash isolation.

Kubernetes​

Ethos ships a first-party Helm chart at helm/ethos in the repo. It is the supported Kubernetes path — do not hand-write manifests. The chart is not yet published to a chart registry; install it from a clone of the repo.

Create a Secret holding your provider key, install the chart against it, then port-forward to reach the UI:

kubectl create secret generic ethos-keys --from-literal=ANTHROPIC_API_KEY=sk-ant-…
helm install ethos ./helm/ethos --set existingSecret=ethos-keys
kubectl port-forward svc/ethos 3000
Forwarding from 127.0.0.1:3000 -> 3000

Open http://localhost:3000. The same first-visit token exchange from Quick start applies — watch kubectl logs ethos-0 for the open: .../auth/exchange?t=<token> line. To keep the token out of pod logs, set the chart's webAuth.bootstrapToken.existingSecret (and optionally key) values: they inject the token from a pre-created Secret as ETHOS_WEB_TOKEN (at least 24 characters), the URL is then never printed, and you sign in by pasting the token — or claim the instance at /welcome with a username and password.

The chart handles what the raw-manifest approach gets wrong. It deploys a single-replica StatefulSet — the gateway singleton lock means a second pod exits with code 3, so there is no scaling knob to misuse. A persistent volume at /home/ethos/.ethos holds config.yaml and the databases; config.yaml is rewritten at runtime (secret re-sync, web UI settings), which is why it must not be mounted from a ConfigMap — earlier versions of this page advised exactly that, and it breaks every runtime write. First boot self-provisions from env (ethos setup --from-env, the same provisioning the Compose files run), health probes hit the gateway's health server on :3002, a NetworkPolicy is on by default, and no Kubernetes API token is mounted into the pod.

For real clusters, start from the overlays in helm/ethos/examples/: EKS (ALB + EBS + IRSA), nginx + cert-manager, and a minimal profile. The chart README covers the full values reference, network security notes, and the backup/restore (ethos import) runbook.

Troubleshoot​

Container exits with code 2. config.yaml is missing from the mounted volume. Confirm the mount target is /home/ethos/.ethos and config.yaml is at the root of that directory.

Healthcheck fails but logs show "gateway started". The web API failed to bind. Check whether port 3000 is in use, or set ETHOS_MODE=gateway to skip the web API entirely.

Telegram bot is silent. Inspect docker logs ethos for adapter errors. The most common cause is a malformed token in .env — the value should have the form <digits>:<base64-ish> and the botKey suffix (if any) must match the one used in config.yaml.

Permission denied writing to the volume. If you bind-mount a host directory (ETHOS_DATA_DIR), it must be owned by uid 1000. sudo chown -R 1000:1000 ~/ethos-data fixes this.

ethos doctor warns the state directory is on fuse, 9p, nfs or smb. The state directory is a bind mount on a filesystem where SQLite locking is unsafe. Unset ETHOS_DATA_DIR to use the ethos-data named volume, and move the data with the command in Move an existing ethos-data/ folder into the volume.

Web UI loads but API calls return 401 Unauthorized. You opened the bare http://localhost:3000 before ever visiting the open: .../auth/exchange?t=<token> URL from the boot output. Open that exact URL once — it sets the auth cookie — then the bare URL works normally on subsequent visits. If you have claimed the instance (set a username and password), that URL no longer signs you in — use the login screen, or /welcome/reset with the token if the password is lost.

See also​