Tightening Hermes Secrets with Bitwarden
A practical walkthrough for moving Hermes API keys out of local env files and into Bitwarden Secrets Manager, with verification and cleanup steps.
Tightening Hermes secrets with Bitwarden
I recently tightened up my Hermes setup by moving the provider and tool tokens out of the local .env file and into Bitwarden Secrets Manager.
This was not a big architectural migration. It was the kind of security improvement that is easy to postpone because everything already works. The agent can search the web, call APIs, talk through Telegram, run scheduled jobs, and operate tools. The problem is that those capabilities usually mean one thing: there are API keys somewhere.
For a long-running AI agent, that “somewhere” matters.
A local .env file is convenient. It is also easy to back up accidentally, inspect casually, copy into a debug session, or leave with too many secrets after the system has grown. I wanted Hermes to keep only one bootstrap credential locally and hydrate the rest from a real secret manager at startup.
The final shape looks like this:
1
2
3
4
5
Hermes process
-> reads BWS_ACCESS_TOKEN from local .env
-> asks Bitwarden Secrets Manager for the Hermes project
-> hydrates provider/tool env vars into the process
-> tools and scripts read normal env var names
That keeps the application interface boring. Scripts still use GOOGLE_API_KEY, FIRECRAWL_API_KEY, or MY_APP_API_TOKEN. The storage and rotation story gets better.
This post is the step-by-step version.
Starting point
Before the migration, my Hermes host had the usual kind of setup:
1
2
~/.hermes/.env
~/.hermes/config.yaml
The .env file contained provider and integration credentials. The config also had a few local custom-provider api_key values. None of this is unusual. Most self-hosted agents start this way because it is simple and debuggable.
But over time the list grows:
1
2
3
4
5
6
7
8
GOOGLE_API_KEY
TELEGRAM_BOT_TOKEN
FIRECRAWL_API_KEY
TAVILY_API_KEY
OPENROUTER_API_KEY
SENTRY_AUTH_TOKEN
BUFFER_ACCESS_TOKEN
MY_APP_API_TOKEN
That is the point where I prefer to move from “a file with keys” to “a file with one bootstrap token”.
Step 1: enable Hermes’ Bitwarden integration
Hermes has a native secrets command group:
1
hermes secrets --help
For Bitwarden:
1
hermes secrets bitwarden --help
The built-in setup flow handles the normal pieces: installing/verifying the bws CLI, storing the machine-account token, selecting the project, and enabling the integration.
1
hermes secrets bitwarden setup
On a headless server, I prefer running this directly over SSH rather than pasting the machine-account token into chat. The token is the one secret that must remain local, because Hermes needs it before it can fetch anything else.
After setup, check the status:
1
hermes secrets bitwarden status
A healthy setup should look conceptually like this:
1
2
3
4
5
6
7
Bitwarden Secrets Manager
Enabled: yes
Token env var: BWS_ACCESS_TOKEN
Token in env: yes
Token validation: passed
Project ID: <project-id>
bws binary: ~/.hermes/bin/bws
I also run a dry sync:
1
hermes secrets bitwarden sync
That prints the secret names Hermes would export on the next invocation, without printing the values.
Step 2: decide what belongs in Bitwarden
The rule I used was simple:
- keep
BWS_ACCESS_TOKENlocal; - move provider keys, API tokens, bot tokens, and tool credentials into Bitwarden;
- replace direct
api_keyvalues in config with env-var references; - do not move non-secrets just because they live near secrets.
You can find candidates with a small read-only scan that prints names only:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
python3 - <<'PY'
from pathlib import Path
home = Path.home() / '.hermes'
credential_suffixes = (
'_API_KEY',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_KEY',
)
for line in (home / '.env').read_text().splitlines():
stripped = line.strip()
if not stripped or stripped.startswith('#') or '=' not in stripped:
continue
name = stripped.split('=', 1)[0].strip()
if name.endswith(credential_suffixes):
print(name)
PY
That deliberately avoids printing values. For this migration, the candidates were names like:
1
2
3
4
5
6
7
8
GOOGLE_API_KEY
TELEGRAM_BOT_TOKEN
FIRECRAWL_API_KEY
TAVILY_API_KEY
SENTRY_AUTH_TOKEN
BUFFER_ACCESS_TOKEN
OPENROUTER_API_KEY
MY_APP_API_TOKEN
If your config.yaml contains direct API keys, inspect the paths rather than dumping values:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
python3 - <<'PY'
from pathlib import Path
import yaml
cfg = yaml.safe_load((Path.home() / '.hermes/config.yaml').read_text())
secretish = {'api_key', 'token', 'secret', 'password'}
def walk(obj, path=''):
if isinstance(obj, dict):
for key, value in obj.items():
next_path = f'{path}.{key}' if path else str(key)
if str(key).lower() in secretish and value:
print(next_path)
walk(value, next_path)
elif isinstance(obj, list):
for index, value in enumerate(obj):
walk(value, f'{path}[{index}]')
walk(cfg)
PY
For local OpenAI-compatible model servers, I had config entries like:
1
2
3
4
5
6
fallback_providers:
- provider: custom
model: qwen2.5-coder-3b
base_url: http://127.0.0.1:18081/v1
api_mode: chat_completions
api_key: dummy-local-key
Even if the value is only a dummy local key, I prefer not to train myself to leave api_key literals in config. I moved those to named env vars too.
Step 3: create the secrets in Bitwarden
The safest manual path is the Bitwarden web UI:
- Open Bitwarden Secrets Manager.
- Select the Hermes project.
- Create one secret per env var.
- Use the exact env var name as the secret key.
- Paste the existing value from
.env.
For example:
1
2
Secret key: GOOGLE_API_KEY
Secret value: <existing value>
If the machine account has write permission, you can also use bws:
1
2
~/.hermes/bin/bws secret create GOOGLE_API_KEY '<value>' '<project-id>' \
--note 'Used by Hermes Google/Gemini provider and tools'
I do not like passing real secret values as command arguments unless I control the shell history and process environment carefully. For one-off migrations, the web UI is less error-prone.
If you automate it, make sure the script never prints values. It should print only names and actions:
1
2
3
created: GOOGLE_API_KEY
created: TELEGRAM_BOT_TOKEN
already_present: OPENROUTER_API_KEY
Also expect rate limits. Bitwarden may return:
1
429 Too Many Requests
If that happens, sleep and retry. Do not treat it as a failed secret value.
Step 4: point Hermes config at env vars
For config entries that had direct keys, change them to env-var references.
Before:
1
2
3
4
5
6
custom_providers:
- name: local-qwen-coder
base_url: http://127.0.0.1:18081/v1
model: qwen2.5-coder-3b
api_mode: chat_completions
api_key: dummy-local-key
After:
1
2
3
4
5
6
custom_providers:
- name: local-qwen-coder
base_url: http://127.0.0.1:18081/v1
model: qwen2.5-coder-3b
api_mode: chat_completions
api_key: ${LOCAL_QWEN_CODER_API_KEY}
Do the same anywhere else Hermes expects a configured api_key value:
1
2
3
4
5
6
fallback_providers:
- provider: custom
model: local-gemma4
base_url: http://127.0.0.1:18080/v1
api_mode: chat_completions
api_key: ${LOCAL_GEMMA4_API_KEY}
Then enable Bitwarden in Hermes config:
1
2
3
4
secrets:
bitwarden:
enabled: true
project_id: <project-id>
I usually make these edits with hermes config edit or a small script that preserves the rest of the file as much as possible. Avoid a full YAML rewrite if your config has a lot of comments or hand-formatting.
Step 5: remove migrated keys from .env
Only remove a key from .env after it exists in Bitwarden and hermes secrets bitwarden sync can see it.
Target end state:
1
BWS_ACCESS_TOKEN=<machine-account-token>
That token is the bootstrap. Everything else should come from Bitwarden.
A cautious cleanup script can remove only keys known to exist in Bitwarden:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
python3 - <<'PY'
from pathlib import Path
home = Path.home() / '.hermes'
env_path = home / '.env'
migrated = {
'GOOGLE_API_KEY',
'TELEGRAM_BOT_TOKEN',
'FIRECRAWL_API_KEY',
'TAVILY_API_KEY',
'OPENROUTER_API_KEY',
'SENTRY_AUTH_TOKEN',
'BUFFER_ACCESS_TOKEN',
'MY_APP_API_TOKEN',
}
new_lines = []
for line in env_path.read_text().splitlines():
stripped = line.strip()
if stripped and not stripped.startswith('#') and '=' in stripped:
name = stripped.split('=', 1)[0].strip()
if name in migrated:
continue
new_lines.append(line)
env_path.write_text('\n'.join(new_lines).rstrip() + '\n')
PY
Then check what credential-shaped names remain:
1
2
3
4
5
6
7
8
9
10
11
python3 - <<'PY'
from pathlib import Path
suffixes = ('_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_KEY')
for line in (Path.home() / '.hermes/.env').read_text().splitlines():
stripped = line.strip()
if stripped and not stripped.startswith('#') and '=' in stripped:
name = stripped.split('=', 1)[0].strip()
if name.endswith(suffixes):
print(name)
PY
For my setup, the only remaining credential-shaped name was:
1
BWS_ACCESS_TOKEN
That is the desired result.
Step 6: verify Hermes can hydrate secrets
Run:
1
hermes secrets bitwarden sync
You should see names, not values:
1
2
3
4
5
6
7
8
9
Bitwarden Secrets Manager: applied 13 secrets
Name Action
BUFFER_ACCESS_TOKEN would export
FIRECRAWL_API_KEY would export
GOOGLE_API_KEY would export
OPENROUTER_API_KEY would export
MY_APP_API_TOKEN would export
TELEGRAM_BOT_TOKEN would export
Then check Hermes config:
1
hermes config check
And status:
1
hermes status
The useful thing in hermes status is not the key value. It is that providers and integrations show as configured after Bitwarden hydration:
1
2
3
4
5
OpenRouter ✓
Google / Gemini ✓
Firecrawl ✓
Tavily ✓
Telegram ✓ configured
If you are running Hermes through a gateway process, restart the gateway after migration. The current process may not inherit newly hydrated values until the next Hermes invocation.
1
hermes gateway restart
Or, if you run it through systemd/docker, restart the service in the way you normally do.
Step 7: test one real integration
A secret migration is not finished until at least one real call works.
For example, I can use a small application API smoke test. The token lives in Bitwarden as MY_APP_API_TOKEN, but the test code reads it like a normal env var.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
python3 - <<'PY'
import json
import os
import urllib.request
import urllib.error
url = 'https://my-app.example.com/api/health'
token = os.environ['MY_APP_API_TOKEN']
request = urllib.request.Request(
url,
headers={
'X-AUTH-METHOD': 'token',
'X-API-TOKEN': token,
'Accept': 'application/vnd.api+json',
'Content-Type': 'application/vnd.api+json',
'User-Agent': 'Hermes admin API smoke test',
},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode('utf-8'))
print('status:', response.status)
print('content_type:', response.headers.get('Content-Type'))
print('records:', len(payload.get('data', [])))
if payload.get('data'):
first = payload['data'][0]
print('first_type:', first.get('type'))
print('first_id:', first.get('id'))
except urllib.error.HTTPError as error:
print('status:', error.code)
print(error.read().decode('utf-8')[:500])
raise
PY
The endpoint expects JSON:API headers even for GET. Without this header:
1
Content-Type: application/vnd.api+json
it returns:
1
415 unsupported_content_type
With the correct headers and Bitwarden-hydrated token, the request should return 200 and a small JSON response.
That kind of smoke test is useful after any future API deployment. It proves both halves of the setup:
- Hermes can hydrate the token securely.
- The deployed API still accepts the token and returns the expected shape.
Step 8: commit the safe config change, not the secrets
After the migration, the only code/config change worth committing is the non-secret config wiring:
1
git diff -- config.yaml
The diff should show env-var references, not secret values:
1
2
- api_key: dummy-local-key
+ api_key: ${LOCAL_QWEN_CODER_API_KEY}
Run a secret scan over staged changes if you have one:
1
2
git add config.yaml
git commit -m "Configure Hermes Bitwarden secret refs"
In my case, the staged secret scan passed and the commit only changed config.yaml.
Do not commit .env. Do not commit generated migration scripts if they contain secret-handling assumptions you do not want to maintain. Keep the durable part small: Bitwarden is the source of truth, Hermes config names the variables, and .env holds only the bootstrap token.
Failure modes I hit
The machine account could read but not write
At first, bws secret list worked but bws secret create failed with a 404 Resource not found response. In practice that meant the machine account could read the project but could not create secrets in it.
The fix was to update the machine account permissions in Bitwarden Secrets Manager. After that, a harmless write test succeeded:
1
2
~/.hermes/bin/bws secret create HERMES_MIGRATION_WRITE_TEST test '<project-id>'
~/.hermes/bin/bws secret delete '<secret-id>'
Bitwarden rate-limited bulk creation
During migration, Bitwarden returned:
1
429 Too Many Requests
The right response is boring: sleep and retry. Do not rerun a script blindly if it might create duplicates. List existing secret names first, skip those, then create only missing ones.
Current processes may still have old env
hermes secrets bitwarden sync tells you what a new Hermes invocation will pick up. A long-running gateway may need a restart before its child processes inherit the new secret set.
The security trade-off
This setup does not make secrets magically disappear. It moves the risk to a smaller and more deliberate place.
Before:
1
local .env contains every provider key
After:
1
2
3
local .env contains one bootstrap token
Bitwarden contains provider and tool secrets
Hermes hydrates env vars at startup
That is easier to audit. It is easier to rotate one provider key without editing server files. It is harder to accidentally publish a pile of credentials in a config diff. It also gives the agent a cleaner boundary: use the env var, never display the value.
For an AI agent that can run commands, call APIs, and keep background workflows alive, this is the kind of boring security work I want more of. Not a new capability. Just less loose credential material lying around.