Separation of Repo and State
code for this post srewohs/homelab @ v0.1.0
New here? The About page covers what the lab is and why this blog exists.
“Paranoid is what they call people who imagine threats against their life. I have threats against my life.”
Garak, Star Trek: Deep Space Nine
I wanted to share my homelab setup with others. More specifically, I wanted to share how my services are configured in a meaningful and reproducible way. This is part of the reason I decided to move toward keeping my configuration as code, and it has the added benefit of serving as a form of self-documentation and timeline for my work.
My goal was to have a place I could point someone to after talking about a new service I had set up or a specific approach I took. Making my Ansible code public is also a good way to keep myself honest about writing things properly.
The issue I ran into was that the code lived in the same repo as everything that identifies my network, plus the credentials that unlock it. My understanding is that this is normal for an Ansible repo, but it’s also why I felt that “publish and pray” wasn’t an acceptable solution.
Copies don’t work (for me at least)#
The first solution I considered was a sanitized public copy of the repo. You can strip out the addresses and secrets, push the result somewhere public, and then repeat whenever the code changes (and of course never get lazy or forget to do this step). I wasn’t really a fan of this approach because it would mean a manual scrubbing step before every push, and the two copies would inevitably get out of sync.
I know myself well enough to see that a step I have to remember every time is a step I’ll eventually skip.
Split by kind of content instead#
So instead of two copies, the approach is two repos that hold different things:
- Public: roles, playbooks, a portable
ansible.cfg, example inventory, docs. This is everything someone would need to get started on their own without anything specific to my lab. - Private: the inventory,
group_vars, the vault, lab runbooks and notes. Everything that’s personal to my setup that I don’t want public.
In hindsight this is pretty obvious. It’s effectively how shared Ansible roles already work, where the code is public and everyone brings their own inventory. I just had a Miles Morales moment and had to do my own thing.
This works largely because of how Ansible finds variables. It loads
group_vars/ and host_vars/ from the directory the inventory file is in,
so pointing a playbook at the private inventory brings every lab value along
with it. The two repos sit next to each other:
<parent>/
homelab/ public: roles, playbooks, examples
homelab-private/ private: inventory.yml, group_vars/, the vault
and playbooks run from the public one:
cd homelab
ansible-playbook -i ../homelab-private/inventory.yml playbooks/deploy-traefik.yml
Because there is nothing sensitive being kept in the public repo, there’s nothing to sanitize and nothing to keep in sync. There is only one copy of each item, and the risk of leaking secrets or lab details is minimized. The README has the setup if you’d like to do the same. If you find something I’ve overlooked, issues and pull requests are welcome.
What had to change in the code#
Splitting the files was quick, but the code itself had my lab baked into it
in a few places. The old history also mixed code and lab data in every
commit, so the public repo started fresh from git init.
Role defaults. Some roles used my real domain, addresses and user names
as defaults. Those moved into private group_vars, leaving each role with a
neutral placeholder or nothing at all. Where there’s no safe default, the
role checks that the value is set and fails with a message saying what’s
missing, rather than running with a placeholder that silently breaks
something. From the Traefik role’s
defaults:
# Required, no defaults (the role asserts they are set):
# traefik_acme_email Let's Encrypt account email
# traefik_base_domains list of domains; each gets a wildcard certificate
# traefik_services list of {name, url}; routed as <name>.<domain>
ansible.cfg. Mine had personal settings in it, including the inventory
path, the vault password file, and host key checking turned off. Ansible
reads only one config file and doesn’t merge them, so personal settings
can’t be layered on top of a shared file. The public ansible.cfg now holds
only portable settings, and the personal ones are environment variables in a
gitignored .envrc, loaded by direnv:
export HOMELAB_PRIVATE="$PWD/../homelab-private"
export ANSIBLE_INVENTORY="$HOMELAB_PRIVATE/inventory.yml"
export ANSIBLE_VAULT_PASSWORD_FILE="$HOME/.ansible-vault-pass"
With ANSIBLE_INVENTORY set, the -i flag goes away.
Examples. The
examples/
directory has a fake inventory with every variable and vault variable the
roles expect, using documentation addresses and CHANGEME values, ready to
copy into your own private repo.
Guardrails#
This split is only as good as the next commit to the public repo, and unfortunately it’s very easy to paste a real address into a README or a role default without noticing. The quote above might be slightly dramatic, but every commit gets the TSA special anyway.
A private denylist. Before the split, I ran a secret scanner over the old repo’s entire history. It found nothing, which didn’t mean much: scanners generally look for things shaped like secrets, and my addresses, host names, and domain don’t fit that description. So a pre-commit hook checks staged files, and the commit’s author and committer, against a list of those strings. The list lives in the private repo, since publishing a list of the things you’re trying to hide would be a bit counterproductive. The hook fails closed: if it can’t find the list, it blocks the commit.
gitleaks^2. gitleaks runs as a pre-commit hook and again in GitHub Actions over the full history on every push. A local hook can be skipped, but CI can’t. Since CI can’t see the private denylist, it only catches generic secrets like API keys.
A vault leak check. Yes, I’m wearing a tinfoil hat. No, I won’t take it off. The last check before any push lives in the private repo, because it needs the vault. It decrypts the vault in memory and searches the public repo’s files and every commit in its history for each secret value. A hit prints only the variable name and file, so the secret itself never appears on screen. There is also a self-test mode that plants a value to prove the check still catches it.
What’s not done#
- Host key checking is still off. The
.envrctemplate disables it, with a comment saying it’s a shortcut and that populatingknown_hostsis the real fix. - ansible-lint hasn’t been run over the roles yet. yawn
Most of the lab is still set up by hand, and those parts will come over to Ansible one at a time. The benefit of this work now is that they land somewhere I can share with peace of mind.
Links#
- Code at the version this post describes: github.com/srewohs/homelab @ v0.1.0
- About the lab
Found a mistake, or something I overlooked? Open an issue on the repo.