Homepage widget credentials belong in a Secret, and git history remembers them
How to feed Homepage widget passwords from a SOPS Secret with HOMEPAGE_VAR placeholders, why annotation widgets cannot use them, and why rewriting a public repo's history does not undo a leak.
My Homepage dashboard had been running for a while when I looked at its ConfigMap and remembered that the repository it lives in is public. The widget credentials were sitting in it in plain text: the router, the document manager, the DNS servers, a Proxmox API token, the NAS. Eleven services in all. Moving them out was easy. Finding out what “out” means for a public repository was the more useful part.
Homepage can read secrets from the environment
Homepage substitutes {{HOMEPAGE_VAR_NAME}} in its config files with the
value of the environment variable HOMEPAGE_VAR_NAME. It does this in any
of its config files, not only services.yaml; the Proxmox settings file
takes placeholders too. There is also a HOMEPAGE_FILE_NAME form that reads
the value from a file path, if you would rather mount the Secret as files.
So the change is a Secret with one key per credential, loaded with
envFrom on the Deployment, and placeholders where the literals used to be:
- Pi-hole:
widget:
type: pihole
url: pihole-web:80
key: '{{HOMEPAGE_VAR_PIHOLE_KEY}}'Quote the placeholder. Unquoted, {{ starts a YAML flow mapping and the
whole file stops parsing.
I made one variable per service rather than one per distinct value. Five distinct passwords covered twelve fields, and one of them was shared by seven services. Separate variables cost nothing and let me rotate them one service at a time later.
Widgets defined by annotation can’t do this
Some of my widgets were declared as gethomepage.dev/widget.* annotations on
the IngressRoutes, which is convenient because the widget lives next to the
service it describes. It is also where the Pi-hole and Grafana widgets broke
with 401s. Their credentials were hard-coded in those annotations, they had
gone stale, and an annotation has no clean way to reference a secret. The
values would have ended up in git again.
The fix was to define any widget that needs a secret in services.yaml,
where placeholders work. Two small things I tripped over on the way: the
Grafana widget’s option is alerts, not alert, and the widgets only call
their APIs while somebody has the dashboard open, so the logs stay quiet
until you load the page and then show the 401s all at once.
The pod won’t notice the change
The ConfigMap is mounted with subPath, and a subPath mount never updates
in a running pod. After a config change you have to restart the Deployment
yourself. I lost a few minutes wondering why a merged fix changed nothing.
What a public git history keeps
Before trusting the new setup I checked how long the old values had been exposed. Take the old secret values, put them in a file, and search every revision:
git rev-list --all | while read rev; do
git grep -lF -f values.txt "$rev" && echo " ^ $rev"
doneThe values were in 178 of the repository’s 272 commits, all in one file, and
the first one was about ten weeks old. Cleaning that up with git filter-repo --replace-text is possible, but it doesn’t buy what I hoped.
GitHub keeps refs/pull/*/head for every pull request and you can’t rewrite
those. Old commits stay reachable by hash until GitHub Support purges them,
and any mirror has its own full copy. A rewrite also needs a force-push of
main, which Flux follows, and every clone has to be re-cloned afterwards.
So a rewrite mostly stops casual discovery. Rotating the credentials is the actual fix. I moved them to the Secret first and treated the rotation as its own job.
Checks that were worth doing
After the move, the decrypted Secret’s values matched the originals, the placeholders in the rendered ConfigMap matched the Secret’s keys (11 and 11), and none of the old values appeared anywhere in the working tree or in the rendered output. That last check is the one that would have caught a widget I forgot to convert.