Backing Up a Homelab as Code: Docker Configs to a Private Git Repo, Without Leaking Secrets
- 5 minutes read - 1052 wordsIf my main Docker host died tomorrow, how fast could I rebuild it? For a long time the honest answer was “slowly, and from memory.” The fix is to treat the box’s configuration as code: every docker-compose.yml, Dockerfile, and config file committed to a private git repo, so reimaging is a git clone and a handful of docker compose up commands.
The trap is that a live Docker tree is a minefield of secrets — .env files, TLS private keys, API tokens hardcoded into compose files — and pushing any of them to GitHub, even a private repo, is a bell you can’t fully un-ring. This post is the repeatable procedure I landed on: what to commit, what to never commit, and the automated checks that keep the second category out.
The shape of the problem #
The ~/docker tree on my .21 host is 259 GB. Almost none of that should be in git. The backup-worthy part is the infra-as-code — compose files, Dockerfiles, config, and docs. The rest is runtime data (databases, media, caches), secrets, and other people’s git repos (app projects with their own remotes) — all of which must be excluded.
So step one is never “git init in ~/docker.” It’s inventory and classify:
# what are the actual deploy units?
find . -maxdepth 3 \( -iname 'docker-compose*.y*ml' -o -iname 'Dockerfile*' \)
# where are the secrets?
find . -maxdepth 3 \( -iname '*.env' -o -iname '*.pem' -o -iname '*.key' \
-o -iname '*secret*' -o -iname '*.p12' \)
# which subdirs are their OWN git repos (must be skipped)?
find . -maxdepth 3 -name '.git' -type d
That triage split my tree into three buckets:
- Commit (sanitized): ~40 services’ compose + non-secret config + docs, one dir per service under a
portainer/<service>/convention. - Skip — nested git repos: app projects (
pyquestgame,linuxcoloradoblog,mediastore, …) that have their own remotes. Backing them up here would just create confusing duplicates. - Skip — runtime data & secrets: databases, media,
*_data/dirs, and every real credential.
The convention: one directory per service #
The repo uses a flat, obvious layout — portainer/<service>/ holding that service’s compose and non-secret config. It reads like the host does, so “where’s the config for X?” is always answerable. New services slot in the same way. Keeping the structure boring is a feature: a backup you can’t navigate under pressure isn’t a backup.
Secrets: sanitized templates, never raw values #
The rule I settled on: commit *.env.example templates with keys but no values; the real .env is gitignored. A tiny awk sanitizer does the conversion — preserve comments and blank lines, blank out every value:
/^[[:space:]]*#/ { print; next } # keep comments
/^[[:space:]]*$/ { print; next } # keep blanks
/^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=/ {
sub(/=.*/, "="); print; next # KEY=value -> KEY=
}
So POSTGRES_PASSWORD=hunter2 becomes POSTGRES_PASSWORD= — the shape of the config is documented (a future me knows exactly which variables to fill in) without a single secret landing in git.
Then verify the sanitizer actually worked — never trust it blind:
# any KEY=value with a non-empty value left in the templates? (must be empty)
grep -rEn '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=[^[:space:]]' \
portainer/*/*.env.example
The lesson: secrets hide inside compose files, not just in .env
#
Sanitizing .env files feels like enough. It isn’t. When I scanned the non-env files I was about to commit, I found live OPNsense API credentials hardcoded inline in a compose file’s environment: block — a key and secret sitting in plaintext YAML, nowhere near a .env. If I’d only sanitized .env files, I’d have pushed real firewall credentials to GitHub.
So the procedure has a mandatory scan-everything pass before committing — env-var-looking assignments, long base64-ish blobs, Slack webhook URLs, BEGIN PRIVATE KEY:
grep -rEinH \
-e '(password|secret|api[_-]?key|token|webhook|bearer)[[:space:]]*[:=][[:space:]]*[^"'"'"' ]?[A-Za-z0-9+/_.-]{6,}' \
-e 'https://hooks\.slack\.com/services/[A-Za-z0-9/]+' \
-e '\b[A-Za-z0-9+/]{40,}={0,2}\b' \
<files-to-be-committed>
The hardcoded key got externalized to a ${VAR} reference plus an .env.example, and the scan came back clean. (Redacting the obvious: the real key/secret don’t appear here — they were rotated-worthy the moment they’d almost been published.) The scan flags false positives too — volume paths, commented ${...} refs — so it’s a read-and-judge step, not a fully automated gate.
The gotcha that bites everyone: .gitignore doesn’t untrack committed files
#
Hardening .gitignore protects you going forward. It does nothing about files already in the repo. My existing repo had, from an earlier less-careful pass, real secrets already committed: a TLS private key (acme.json), a couple of .env files, an app config with an API key. Adding *.env and *.key to .gitignore leaves all of those exactly where they are — tracked, and in the history forever.
Removing them for real means rewriting history (git-filter-repo) and rotating the exposed credentials, because anyone who cloned the repo already has them. The .gitignore I wrote stops new leaks:
# secrets: never commit real values; commit *.env.example templates instead
.env
*.env
!*.env.example
*secret*
!*secret*.example
*.key
*.pem
acme.json
# runtime data / state, never config
**/*_data/
But the pre-existing exposure is a separate cleanup, and pretending .gitignore fixed it would be the dangerous mistake.
Make it repeatable #
Because I’ll want to do this on other hosts, the whole procedure lives as a BACKUP-RUNBOOK.md in the repo itself — inventory → classify → clone (over HTTPS with a token, since SSH clone failed in my environment) → copy non-data config → sanitize → scan → harden .gitignore → verify nothing secret is staged → commit → delete the token-bearing temp clone. It bakes in the real lessons (the SSH-clone failure, the inline API key) so the next host doesn’t repeat them.
Takeaways #
- Never
git initthe live tree. Inventory and classify first — commit infra-as-code, skip data, secrets, and nested repos. - Sanitize secrets to
*.env.exampletemplates and verify no values leaked. - Scan the non-env files too — API keys hide hardcoded inside compose files, not just in
.env. .gitignoredoesn’t untrack what’s already committed. Fixing a past leak needs history rewriting and credential rotation, not just an ignore rule.- Write the runbook into the repo so the backup is a repeatable procedure, not a one-off heroic afternoon.
Now the box is a git clone away from rebuildable, and the one thing I was most afraid of — quietly publishing a credential — is caught by a scan instead of by luck.