Deploying Chimera on a server (VPS)
Chimera runs as a long-lived gateway process. Add --cron and it also fires scheduled
jobs on a real clock, so it acts on time (not just when messaged). This guide covers a
$5 VPS deployment two ways: Docker Compose (recommended) or systemd.
State β long-term memory, cron jobs, trajectories, the audit log β lives in CHIMERA_HOME
(a directory). Persist it (a Docker volume or a real path) and the agent survives restarts.
0. Prerequisites
- A Linux VPS (1 vCPU / 1 GB RAM is plenty for a single agent).
- At least one provider key. The cheapest start is an OpenRouter key.
- For public inbound webhooks (WhatsApp Cloud API,
POST /webhook/<hook>), a domain + a reverse proxy with TLS (Caddy or nginx). Not needed for Discord/Telegram/Slack/Signal, which connect outbound.
Create your env file from the template and fill in a key:
cp .env.example .env
# edit .env β e.g. set CHIMERA_OPENROUTER_KEYS=sk-or-...
1. Docker Compose (recommended)
docker compose up -d # build + run; restarts on crash and on reboot
docker compose logs -f # watch it
docker compose ps # status + health
That runs chimera serve --host 0.0.0.0 --cron: the HTTP gateway (/chat, /webhook/<hook>,
/health) plus the cron daemon. State persists in the chimera-data volume.
Serve a chat platform (Discord shown) β set the token in .env, then override the command
in docker-compose.yml:
command: ["serve", "--host", "0.0.0.0", "--cron", "--discord"]
and docker compose up -d again. (Telegram/Slack/Signal work the same via their flags; each
needs its matching CHIMERA_* token β see .env.example.)
Update to a new version:
git pull && docker compose up -d --build
2. systemd (no Docker)
Install into a virtualenv on the host:
git clone https://github.com/brcampidelli/chimera-agent.git /opt/chimera
cd /opt/chimera
python3 -m venv .venv && . .venv/bin/activate
pip install '.[messaging,mcp]'
cp .env.example .env # then edit it
Create /etc/systemd/system/chimera.service:
[Unit]
Description=Chimera Agent gateway + cron daemon
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/chimera
EnvironmentFile=/opt/chimera/.env
Environment=CHIMERA_HOME=/opt/chimera/state
ExecStart=/opt/chimera/.venv/bin/chimera serve --host 0.0.0.0 --cron
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now chimera
sudo systemctl status chimera
journalctl -u chimera -f
3. Scheduling proactive work (the --cron daemon)
--cron only runs the jobs you've scheduled. Add them with the CLI (they persist in
CHIMERA_HOME):
chimera cron add "morning-brief" "0 8 * * *" "Summarize overnight news and post it."
chimera cron add "nightly-backup" "0 3 * * *" "Back up the important files."
chimera cron list
Inside Docker:
docker compose exec chimera chimera cron add "morning-brief" "0 8 * * *" "..."
The daemon ticks every --cron-tick seconds (default 30) and dispatches each job's action
through the agent when it's due. A failing job is logged and never stops the daemon.
Asking what the schedule is not telling you
chimera cron doctor
A schedule can go quiet in two ways, and until you ask, both look the same β like a schedule with nothing due:
- Nothing ran. The daemon died, the container was never restarted, the host slept. No exception, no log line, no verdict. Every other honesty mechanism here sits downstream of a run having happened, so none of them gets a turn.
- Everything ran and lost. The daemon is alive,
last_runis a minute ago, the schedule is advancing β and every dispatch has failed for a month. This one reads as healthier than the first, because the field that looks like health is recording the attempt, not the outcome.
cron doctor asks both questions and gives different advice, because the fixes have nothing in
common: overdue is about the daemon, failing is about the job. chimera cron list prints a line
when anything is failing, so you do not have to know the command exists to find out.
What it is not. It is a question, not a watcher: nothing here notices while the process is down, for the same reason a crashed process cannot log its own crash. It answers honestly the moment anything asks β a shell, the app, the next start. A real watchdog needs its own clock and its own liveness, which is a separate decision and is tracked as issue #26. If you want an alert rather than an answer, run it from the host's own cron:
*/30 * * * * cd /opt/chimera && out=$(.venv/bin/chimera cron doctor --check) || echo "$out" | mail -s "chimera" you@example.com
That works because it is supervised by something other than Chimera β which is the whole point.
--check exits 1 only when a job is late or failing, so the mail arrives only then; plain cron doctor always exits 0, because answering is not a failure.
The daemon's heartbeat closes the window the jobs cannot. Every tick, the daemon writes its
sign of life to <CHIMERA_HOME>/scheduler/heartbeat.json (atomic, beside jobs.json). The
doctor reads it first and answers a question the jobs alone cannot: a dead daemon with a daily
job looks healthy for ~23 hours from the jobs β the job is not yet late β while the beat stops
the moment the daemon dies. The verdict is judged against three ticks of the beat's own
interval, and is printed with the ceiling used, so you can disagree with the number rather than
wonder where it came from. --check exits 1 on a stale beat too, so the host-cron line above
now catches a dead daemon on its next run, whatever the job's schedule. Three honest readings:
alive/stale are judged; unknown means a beat exists but carried no tick interval (an older
writer), so freshness cannot be judged without inventing a number β and the reader refuses;
none means no beat at all, which is "no signal", not "dead" β a daemon that never ran left no
evidence either way. This is liveness, not taint: the scheduler surface still does not track
taint (see SECURITY.md).
4. Health, backups, security
- Health:
GET /healthreturns{"ok": true}. Compose has a healthcheck wired. - Backups: back up the
chimera-datavolume (Docker) orCHIMERA_HOMEdir (systemd) β that's all the durable state. Example:docker run --rm -v chimera-data:/d -v $PWD:/b busybox tar czf /b/chimera-state.tgz -C /d . - Secrets: keep keys in
.env(git-ignored); never bake them into the image. - Exposure: bind the gateway to
0.0.0.0only behind a firewall/reverse proxy. SetCHIMERA_SERVER_TOKENto requireAuthorization: Bearer <token>on the HTTP gateway and the desktop API (the desktop UI is handed the token automatically only for loopback clients, so a remotely-exposed instance stays behind your own auth). Auth is opt-in and empty by default, so without that variable there is none β restrict the port, or expose only the webhook path. To reach this instance from the desktop app, see Β§5. - Sandboxing: set
CHIMERA_SANDBOX=dockerto run the shell/code tools in a throwaway container instead of the host. - Unattended host execution: since 2026-07-20 a headless run refuses host commands under the
default
CHIMERA_HOST_EXEC=ask(there is no TTY to confirm on). A deployment that genuinely needs the agent to run shell on the host setsCHIMERA_HOST_EXEC=allowdeliberately; the safer option isCHIMERA_SANDBOX=docker, where the gate is skipped because the container really isolates. Likewise the API server arms taint narrowing (CHIMERA_TAINT_NARROW=1): after the agent reads untrusted content, execution/write/outbound tools fail closed. Set it to0to keep acting autonomously.
5. Reaching this instance from the desktop app
The desktop app talks to the Chimera it starts on your own machine by default. From v0.44 it can also point at one you run yourself β this VPS β so the app becomes a window onto the agent that is already up all night doing your cron jobs.
Read this part before opening a port. What you are exposing is not a dashboard. Every screen in that app is a command surface: it runs shell, edits files, dispatches a board of autonomous tasks and changes settings. An instance reachable from the internet without a token is not "a Chimera someone could look at" β it is a machine anybody who finds the address can run commands on, with your provider keys paying for it.
Three things have to be true, and the app refuses to connect unless the first two are:
1 β TLS. Put it behind a reverse proxy with a real certificate (Caddy gets one for you):
chimera.seudominio.com {
reverse_proxy 127.0.0.1:8765
}
The app refuses a non-https address outside your own machine, because the token travels in an
Authorization header on every request β over plain http that is a credential handed to every
hop between you and the server, and nothing on screen would look wrong while it happened.
2 β A token. Auth is opt-in and empty by default:
CHIMERA_SERVER_TOKEN=$(openssl rand -hex 32)
Put it in the .env, restart, and paste the same value into the app. The app refuses a remote
address with no token for the reason above: an instance without one is open to whoever finds it.
Note what the server deliberately does not do: when a remote client asks for the UI, it serves the page without the token. The token is never handed out over the network β you copy it to your own client, once, out of band. That is why the app has a field for it.
3 β Your app's origin. The app is served by its own local sidecar, so its requests to this instance are cross-origin and a browser discards the responses unless this instance names that origin:
CHIMERA_ALLOWED_ORIGINS=http://127.0.0.1:45813
The app shows you the exact value when a connection fails β it is on the error message, ready to copy. The port is stable per install (it is remembered between launches since v0.43), so you set this once per machine you connect from. Several are comma-separated.
This setting is not a security boundary and must not be read as one. CORS decides which page
may read a response; it decides nothing about who may call. The token is the gate. Naming an
origin without setting a token does not protect anything β it just makes an unprotected instance
reachable from a browser as well as from curl.
Empty by default, so an instance nobody configured behaves exactly as it did before.
What the app tells you when it fails
- "The token was refused" β the address and the origin are right; the value is wrong.
- "Could not reach it" β either the address is wrong or the origin is not allowed. The browser refuses to say which, on purpose, so the app names both rather than guessing and hands you the origin to allow.
- A version warning β the app compares its own backend's version against this one and says both numbers. It does not refuse: a server one release behind usually works, and refusing would strand you on the screen you would need to fix it. Some endpoints may not exist on the older side.
Safer still
Skip the public port entirely: reach the VPS over WireGuard or a Tailscale tailnet and point the app at the private address. The token still matters β a tailnet is a smaller room, not an empty one.
6. Proxy, custom CA, and looking inside a running instance
Proxy and custom CA
Outbound traffic β provider calls and integrations β reads the standard proxy and certificate
variables from the environment of the Chimera process. What is verifiable today (httpx 0.28,
LiteLLM 1.99): httpx honours HTTP_PROXY, HTTPS_PROXY and NO_PROXY, and takes its CA bundle
from SSL_CERT_FILE / SSL_CERT_DIR; LiteLLM reads SSL_CERT_FILE. Neither reads
REQUESTS_CA_BUNDLE, which only affects code built on the requests library β set it as well
only if some integration of yours uses requests. Provider adapters inside LiteLLM vary, so prove
the path you depend on with the probe below rather than assuming. None of this changes Chimera's
inbound listener or its API authorization.
For a shell or systemd environment:
export HTTP_PROXY=http://proxy.example:8080
export HTTPS_PROXY=http://proxy.example:8080
export NO_PROXY=localhost,127.0.0.1,.internal.example
export SSL_CERT_FILE=/etc/ssl/certs/company-ca.pem
# Optional, only for requests-based code:
# export REQUESTS_CA_BUNDLE=/etc/ssl/certs/company-ca.pem
chimera doctor --probe
In docker-compose.yml, under the service's environment: (the CA file must exist inside the
container):
environment:
HTTP_PROXY: http://proxy.example:8080
HTTPS_PROXY: http://proxy.example:8080
NO_PROXY: localhost,127.0.0.1,.internal.example
SSL_CERT_FILE: /etc/ssl/certs/company-ca.pem
# REQUESTS_CA_BUNDLE: /etc/ssl/certs/company-ca.pem # optional, requests-based code only
volumes:
- ./company-ca.pem:/etc/ssl/certs/company-ca.pem:ro
chimera doctor --probe makes one real provider call through the configured route and certificate
chain. A passing probe verifies that provider and that call; it does not certify every integration.
Keep credentials out of proxy URLs where you can: environment values are visible to anyone who can
inspect the process.
chimera api and chimera logs
Two terminal commands for a running instance, neither of which starts a server:
chimera api GET /api/health # the app's API: status and body, token never printed
chimera api POST /api/... -d '{}' --yes # changing state needs --yes
docker exec chimera chimera api GET /health # the HTTP gateway of plain `chimera serve`
docker exec chimera chimera logs --level ERROR --lines 50
docker exec chimera chimera logs --follow --run <run-id>
chimera api sends CHIMERA_SERVER_TOKEN from the environment. It finds chimera app through the
URL the app announces in CHIMERA_HOME; otherwise it uses http://127.0.0.1:8765, the default
port of chimera serve, whose gateway answers GET /health and POST /chat (there is no /api
there). serve --discord/--telegram/--slack/--signal opens no HTTP port at all, so there is
nothing for chimera api to reach in that deployment; chimera logs still works. --url
overrides the address. A loopback call ignores the proxy variables above, so the token never
reaches a proxy, and the command refuses to send the token to a remote http:// URL. The server's
own authorization and approvals still decide every request.
chimera logs reads CHIMERA_HOME/logs/backend.log: warnings and errors written by chimera serve
and chimera app, rotated at 1 MB with secrets masked. A traceback stays with the record it belongs
to under --level and --run, and --follow keeps reading across a rotation.
7. Honest status
Chimera is alpha. This deploys and runs, and the cron daemon makes it proactive. Its
production mileage is one deployment: the author's own VPS, running crons and a Discord bot
around the clock since July 2026. That is real use, not a fleet, and it has not met your workload.
Start with low-stakes crons, watch logs, and keep the
governance guardrails (--guard on solve, CHIMERA_SANDBOX=docker) in mind for anything
that touches real systems.
Where these pages are published
These files are the source for the documentation on chimeraagent.space, which renders them straight from this directory at build time. Edit the markdown here and the site follows; there is no second copy to keep in step.
The MkDocs configuration that used to live at mkdocs.yml has been removed. It was complete β
theme, navigation, ten pages β and it was never published: there was no workflow and no
gh-pages branch, so the deploy instructions that used to sit in this spot described a site that
did not exist. A configuration nobody runs is worse than no configuration, because the next person
edits its navigation and cannot work out why nothing changes.