TL;DR: Putting ~/.openclaw in a private git repo is mostly about writing the right .gitignore and keeping every secret outside the tree. A systemd EnvironmentFile carries the real keys to the gateway at startup, so openclaw.json can hold references or empty fields instead of credentials.
Why track it Link to heading
After the upgrade gotchas, I wanted version history for openclaw.json and a way to rebuild on a new VM without reassembling config by hand. Putting ~/.openclaw in a private repo solves both, but only if nothing sensitive ever lands in git. Private repos get forks, clones, CI caches, and the occasional visibility flip. Treat them as if they could leak tomorrow.
What gets tracked, what gets ignored Link to heading
The ~/.openclaw directory mixes three kinds of content:
- Config and content worth versioning.
openclaw.json,cron/jobs.json(scheduled job prompts),memory/(long-term memory),telegram/thread-bindings-default.json,completions/,scripts/, and workspace files. - Secrets and identity material. OAuth tokens in
agents/*/agent/auth*.json, device keys inidentity/, pairing data incredentials/. These never hit git, private repo or not. - Runtime state. Session transcripts, gateway logs, delivery queues, update-offset cursors,
.baksnapshots, SQLite lock files. Churns constantly, useless in history.
My .gitignore after a few rounds of discovery:
# Secrets
credentials/
identity/
agents/*/agent/auth*.json
agents/*/agent/models.json
# Transient per-session state
agents/*/sessions/
cron/runs/
cron/*.tmp
telegram/update-offset-default.json*
delivery-queue/
browser/
canvas/
tasks/
subagents/
devices/
media/
flows/
# Logs
logs/
*.log
# Backups + temp
*.bak
*.bak.*
*.bak-*
*.tmp
*.swp
update-check.json
exec-approvals.json
Before the first commit, dry-run git add -A and grep-scan for credential shapes (sk-, gsk_, AIza, eyJ, xoxb-, ghp_, long base64 strings in fields called token or apiKey). My first pass caught two literal API keys in openclaw.json and a device private key in identity/ I would have committed otherwise. Re-run the same scan every time the .gitignore changes.
Secrets outside the tree Link to heading
The goal is to make openclaw.json readable by a casual browser of the repo without exposing anything. OpenClaw supports a credential-reference syntax:
{ "source": "env", "provider": "default", "id": "GEMINI_API_KEY" }
Some paths honour it, others ignore it. channels.telegram.botToken resolves against the env var and the bot polls normally. The google provider’s models.providers.google.apiKey, the openrouter equivalent, and gateway.auth.token all refused the reference in my testing: the google path demanded a literal apiKey or a per-agent auth profile, and the gateway-auth path skipped the secrets provider during its own auth handshake.
Working shape:
- Real keys live in
/etc/openclaw/secrets.env, root-owned, mode0640. - Systemd loads that file via an
EnvironmentFile=drop-in, so the gateway process has the secrets in its env at startup. models.providers.*.apiKeyfields stay unset inopenclaw.json. The provider code falls back to the matching env var by convention.channels.telegram.botTokenuses aSecretRef. It resolves at runtime.gateway.auth.tokenstays as a literal. It is a local-only token with no secure alternative.
The drop-in at ~/.config/systemd/user/openclaw-gateway.service.d/secrets.conf:
[Service]
EnvironmentFile=/etc/openclaw/secrets.env
Drop-ins survive openclaw doctor --fix rewriting the main unit file. A drop-in under service.d/ merges on top at parse time, so the EnvironmentFile= directive sticks across upgrades.
Verifying the gateway sees the secrets Link to heading
systemctl --user show openclaw-gateway.service prints the merged environment. The authoritative check is /proc/<pid>/environ on the live gateway process:
PID=$(systemctl --user show -p MainPID --value openclaw-gateway.service)
tr '\0' '\n' < /proc/$PID/environ | grep -E 'GEMINI|OPENROUTER|TELEGRAM|GROQ' | sed 's/=.*/=***/'
The gateway spawns child helpers whose env is trimmed, so resolve to systemd MainPID first. pgrep -f 'openclaw.*gateway' | head -1 often returns a child.
openclaw secrets audit prints the other half: plaintext findings, unresolved refs, and precedence drift. Running it after the migration surfaced one legitimate plaintext (gateway.auth.token) and OAuth residue in auth-profiles.json that falls outside the static SecretRef model. Both are expected, neither needs action.
Recovery shape Link to heading
A fresh install is: clone the config repo into ~/.openclaw, recreate /etc/openclaw/secrets.env from a password manager, put the systemd drop-in back in place, run openclaw doctor --fix, start the gateway. No config reassembly by hand, and nothing sensitive ever landed in git.
Further reading Link to heading
- OpenClaw upgrade gotchas (the earlier post these notes build on)
- Migrating OpenClaw from Jetson Nano to a VPS (the original setup)
- systemd unit drop-in files (man page)
- git-secrets (pre-commit hooks that scan for credential shapes)