packages/docs/deployment-bare-metal.mdx
The Docker-based deployment in Deployment Guide is the right default for most operators. Bare-metal systemd is the better fit when:
claude CLI access),
and you want the bot to use the same OAuth credentials the claude
CLI creates when you log in.claude itself) to run
natively on the host so there is no container-in-container plumbing.For multi-tenant hosts, managed cloud containers (ECS, Cloud Run), or API-key-first deployments, stay with Docker.
All files live under deploy/systemd/ in the repo:
| File | Role |
|---|---|
units/eliza.service | the bot, Restart=always, capped restart burst, OAuth refresh before launch |
units/eliza-refresh.{service,timer} | runs the OAuth helper every 6h to keep the refresh token rolling |
units/eliza-probe.{service,timer} | active health probe every 5 min — API + agent state + auth log tail |
bin/eliza-refresh-oauth.sh | reads ~/.claude/.credentials.json, only calls claude auth status if near expiry |
bin/eliza-health-probe.sh | restarts the unit on failure, exits 0 (restart is the remediation) |
eliza.env.example | environment template copied to ~/.config/eliza/env on first install |
install.sh | idempotent installer |
bun in the installing user's PATH.claude CLI installed and logged in (claude auth login once).git clone https://github.com/elizaOS/eliza.git
cd eliza
./deploy/systemd/install.sh
Pass an absolute path as the first argument if the checkout lives somewhere other than where you want the service to run:
./deploy/systemd/install.sh /opt/eliza
The installer substitutes the resolved workdir, your bun binary path,
and your log file path into the unit files, places them under
~/.config/systemd/user/, enables linger (will prompt for sudo if
needed), and starts the service and timers.
On first install it also copies eliza.env.example to
~/.config/eliza/env. Edit that file to change port, log path, or the
OAuth refresh threshold — the bot and helpers read from it on every
start.
Claude Code OAuth uses rolling refresh tokens: as long as the refresh
token is exercised periodically, it never expires. The refresh helper
reads the expiresAt field in ~/.claude/.credentials.json and only
calls claude auth status --json (which hits the auth endpoint and
rolls the refresh token) when fewer than 60 minutes remain. Otherwise
it is a no-op. The helper never invokes any model.
This means:
ExecStartPre line triggers the helper before launch.If the bot has been offline long enough that the refresh token itself
has expired, you need to run claude auth login once interactively.
Nothing automated can replace that one-time step.
Every 5 minutes the probe checks:
GET /api/health returns within 5s."agentState":"running".Authentication failed.Any failure triggers systemctl --user restart eliza.service. The
probe itself always exits 0 — a restart is a normal recovery action,
not a unit failure.
| path | |
|---|---|
| Bot output | ~/.local/share/eliza/bot.log (override via ELIZA_LOG) |
| OAuth refresh activity | ~/.local/share/eliza/oauth-refresh.log |
| Probe activity | ~/.local/share/eliza/probe.log (sparse — only restarts and hourly heartbeats) |
| systemd journal | journalctl --user -u eliza.service |
Authentication failed but rate-limit-specific throttling would benefit from backoff logic (not implemented).claude auth login.systemctl --user disable --now eliza.service eliza-refresh.timer eliza-probe.timer
rm ~/.config/systemd/user/eliza{,-refresh,-probe}.{service,timer}
rm ~/bin/eliza-refresh-oauth.sh ~/bin/eliza-health-probe.sh
systemctl --user daemon-reload
loginctl disable-linger "$USER" # optional