The Problem / Motivation
Post 14 ended on a high note: OpenClaw deployed to a dedicated Proxmox VM, sandboxed, firewalled, accessible via SSH tunnel. Infrastructure as Code, the way we like it.
But we left two loose threads dangling:
-
Plaintext secrets —
openclaw_anthropic_api_keyandopenclaw_gateway_tokenwere sitting invault.ymlas bare strings, only protected by file-level encryption. Fine in practice, but inconsistent with how we handle every other secret in the vault. -
Caddy ACME failures — the Cloudflare DNS-01 challenge was throwing HTTP 403s, and we’d deferred investigating why.
Day two was supposed to be a quick cleanup session. It turned into something more interesting: a debugging rabbit hole, a hard revert, and a reminder about the value of accurate documentation.
What We Found
Three Cloudflare Tokens and a Ghost
The 403 on ACME was the first thing we pulled on. Diagnosing it meant auditing how Caddy actually gets its Cloudflare credentials — and we discovered we had three distinct tokens in play:
| Token | Location | State |
|---|---|---|
cloudflare_acme_token | Ansible vault | Expired (error code 1000) |
cloudflare_api_token | Ansible vault | Expired (error code 1000) |
| Hardcoded in old compose | stacks/caddy/docker-compose.yml | Active, wrong permissions |
The root cause: the active token had Zone:DNS:Read but not Zone:DNS:Edit, which is
what DNS-01 requires. The vault tokens — which had the right permissions — had expired
and never been rotated.
Here’s the twist: the Caddy we were testing against wasn’t the one having problems.
During the investigation we did a deeper audit of the infrastructure — reading through
the full Ansible repo, checking all the roles — and confirmed that the Caddy DMZ VM
(10.0.10.10) had been fully operational for weeks, using a separate working token
deployed via Ansible. The Docker-based Caddy on docker-host was the interim setup we’d
never fully cleaned up.
The 403s were on the old stack. The production reverse proxy was fine. We were chasing a ghost.
Documentation Drift Is a Real Cost
This discovery exposed something uncomfortable: the agent system prompt we use to give
Claude Code context about the homelab was significantly out of date. It still described
Caddy as a Docker container on docker-host. It didn’t mention the openclaw or caddy VM
hosts. The IaC directory structure it showed bore no resemblance to what was actually
in the repo.
For AI-assisted infrastructure work, stale context is dangerous. The agent was reasoning about the wrong architecture — which directly contributed to the confusion about the ACME errors. We spent time investigating the wrong Caddy instance.
The fix was a systematic pass through every section of the system prompt: hosts table, Caddy configuration details, directory structure, key operational notes, Caddyfile update workflow. Everything cross-checked against the actual repo and live infrastructure.
We also added the agent definition file itself to the repo (homelab-agent.md),
so it gets the same version-controlled treatment as everything else. Documentation that
lives outside git is documentation that drifts.
The OpenClaw Stability Problem
With the environment fully mapped, we decided to try exposing the OpenClaw Control UI
via Caddy — adding openclaw.example.com as a local_only subdomain pointing to
10.0.0.14:18789. That meant also updating the UFW rules on the openclaw VM to
allow inbound on 18789 from the LAN.
It failed. Not because of the Caddy config or the firewall rules — those were fine.
The gateway kept not binding, or binding and then going away. We’d see ss -tlnp
show port 18789 for a few seconds, then nothing.
The culprit was subtle: running openclaw daemon status to check health was itself
triggering a daemon restart. When the RPC probe fails — which it does within the
3-second startup window — the status command interprets that as a crashed gateway and
attempts a restart. Run status while the gateway is starting, and you reset the clock.
This is a bug in OpenClaw’s daemon management. The distinction between “is the gateway healthy” and “start the gateway if it’s not running” should not live in the same command. But that’s what we were dealing with.
We also found a stale lock file at /tmp/openclaw-1000/gateway.*.lock that prevented
clean restarts even after clearing the symptom.
The Revert Decision
At this point we had two options:
-
Keep debugging — trace the full startup sequence, find a reliable health check that doesn’t trigger restarts, potentially patch around the lock file issue.
-
Revert to the original design — gateway on loopback, SSH tunnel access only — and wait for a more stable OpenClaw release.
We chose option 2. The reasoning:
- The SSH tunnel works.
ssh -L 18789:localhost:18789 <user>@openclawcosts five seconds and provides a stable, encrypted channel to the gateway. - Exposing an unstable service through Caddy adds a layer of complexity that masks the underlying problem rather than solving it.
- The
openclaw-gateway.servicesystemd unit is more reliable when nothing is probing it via commands that have restart side effects. - OpenClaw is research infrastructure. It doesn’t need to be always-on or conveniently accessible from every browser on the LAN — it needs to be stable enough to run experiments.
The Caddyfile change was reverted, the UFW rule additions were removed, and the gateway
bind was confirmed back to 127.0.0.1. The right time to expose OpenClaw via Caddy is
after the upstream daemon management story stabilizes.
Closing the Vault Security Debt
With the infrastructure investigation settled, we returned to the original goal: encrypting the plaintext openclaw secrets.
Understanding the Vault Structure
Our vault.yml uses two layers of protection:
- File-level encryption: The entire file is AES256-encrypted with
ansible-vault. Anyone who gets the file without the vault password sees an opaque blob. - Per-variable
!vaultencryption: Individual secrets within the file are also encrypted as!vaultinline strings.
The cloudflare and GitHub tokens already had per-variable encryption. The openclaw secrets, added during the initial deployment sprint, were plaintext inside the encrypted file — protected by the outer layer, but inconsistent with how everything else was handled.
The Encryption Process
ansible-vault encrypt_string is the right tool, but it has a quirk in our setup: the
ansible.cfg references <vault-password-file> via vault_password_file, which causes the
vault-ids to be registered twice (default,default). The fix is to always pass
--encrypt-vault-id default explicitly:
ansible-vault encrypt_string 'your-secret-value' \
--name 'your_variable_name' \
--encrypt-vault-id default \
--vault-password-file <vault-password-file>
We ran this for both secrets, then did the replacement non-interactively:
# Decrypt to temp file
ansible-vault decrypt inventory/group_vars/all/vault.yml \
--output=/tmp/vault_decrypted.yml \
--vault-password-file <vault-password-file>
# Python substitution (regex replace the plaintext section)
python3 << 'EOF'
import re
# ... replace plaintext openclaw lines with !vault blocks
EOF
# Re-encrypt
ansible-vault encrypt /tmp/vault_new.yml \
--vault-password-file <vault-password-file> \
--encrypt-vault-id default
# Replace original
cp /tmp/vault_new.yml inventory/group_vars/all/vault.yml
rm /tmp/vault_decrypted.yml /tmp/vault_new.yml
Important: delete the temp decrypted file immediately after use. A plaintext secrets
file sitting in /tmp is exactly the kind of footgun that defeats the purpose of vault
encryption.
Verification
Before committing, we confirmed Ansible could decrypt both secrets correctly:
ansible all -m debug -a "var=openclaw_anthropic_api_key" --limit openclaw
ansible all -m debug -a "var=openclaw_gateway_token" --limit openclaw
Both returned the expected values. The vault is clean.
Lessons Learned
1. Three tokens is two too many. When you have multiple Cloudflare API tokens in different places serving the same purpose, confusion is inevitable. The fix isn’t to debug the 403 — it’s to consolidate to a single authoritative token in vault and make sure everything uses it. That’s a cleanup item for a future session.
2. Documentation that drifts costs real time. Stale context in the agent system prompt caused us to investigate the wrong Caddy instance for longer than necessary. For AI-assisted infrastructure work specifically, accurate context is not optional — the AI reasons about what you tell it, and if what you tell it is wrong, the reasoning is wrong too.
3. Know when to stop and revert. The instinct to push through and make a feature work is strong, especially when the fix should be straightforward. But OpenClaw’s daemon management quirks are upstream problems we can’t control. The SSH tunnel is a perfectly good solution for research infrastructure. Recognizing that and reverting was the right call — and the revert itself took about five minutes.
4. File-level and per-variable vault encryption serve different purposes. File-level
encryption protects the whole file from unauthorized access. Per-variable !vault
encryption means individual secrets can live in non-encrypted files and still be
protected, and it makes the encryption granularity explicit in code review. Using both
is belt-and-suspenders, but it’s also consistent: every secret in the vault looks the
same, which makes auditing easier.
5. Temp files with decrypted secrets are a hazard. The decrypt-edit-encrypt workflow
is necessary when you can’t use ansible-vault edit non-interactively, but it creates
a window where secrets exist in plaintext on disk. Delete them immediately, don’t leave
decrypted files sitting around between steps.
What’s Next
- Cloudflare token consolidation: One token, in vault, with correct Zone:DNS:Edit permissions. Rotate and verify. Remove the hardcoded token from the old compose file.
- OpenClaw stability monitoring: Wait for a more stable release, then reconsider whether LAN-accessible Control UI via Caddy makes sense.
- OpenClaw vault token rotation: The
openclaw_gateway_tokenwas auto-generated during initial setup. It should be rotated periodically since it controls gateway access. - DNS server migration: The planned move from the edge firewall as DHCP/DNS to a dedicated LXC DNS server is still pending. That would give us proper split-horizon DNS and make local-only subdomains available without going through Cloudflare.