- Sep 5, 2026
- 4 min read
A Bind-Mounted Repo Turns Runtime Files Into Git Files
My web-audit tool runs on the homelab with a compose file that is convenient and slightly dangerous:
services:
auditmagnet-prod:
build: .
command: make prod # npm install + build + node server.js
volumes:
- .:/app # the whole git checkout
- /app/node_modules
env_file:
- .env
The code isn’t baked into the image: the git checkout is /app. Deploying
means git pull and restarting the container, which reinstalls, rebuilds the
frontend and starts the server. It’s fast and simple.
It also means anything the server writes under /app lands inside the git
checkout.
What the server was writing
Besides its database, the backend kept a CSV fallback of every audit request:
const csvWriter = createObjectCsvWriter({
path: path.join(__dirname, 'leads.csv'),
append: true,
// timestamp, name, email, website, score, consent
});
__dirname is the repo root, and leads.csv was a tracked file. It
was added months earlier and git’s log shows it changed in a handful of
commits since — so rows did end up in history (a private repo, but history
is forever). In production, people’s names and email addresses were being
appended to a file git was watching; one git commit -a on the server would
have pushed them too. A folder of hand-written prospect notes
(manual-leads/) was tracked the same way.
Private repo or not, “don’t commit from the server” is a habit, not a guarantee — and the history already proved habits slip.
The fix in the repo
-
Runtime data goes to an ignored folder, with the path configurable:
const leadsPath = process.env.LEADS_CSV_PATH || path.join(__dirname, 'data', 'leads.csv'); fs.mkdirSync(path.dirname(leadsPath), { recursive: true }); -
Ignore it, and the old locations too:
# Personal data never goes to git data/ leads.csv manual-leads/ -
Stop tracking what was already committed, keeping the files on disk:
git rm --cached leads.csv git rm -r --cached manual-leads git commit -m "Personal data out of git; CSV fallback → data/"
While I was there, the CSV started recording the real consent choice
instead of a constant YES. That story is in
unbundling consent on a lead-magnet form.
The trap: pulling an untracking commit
On the laptop, git rm --cached keeps the file. On the server, the same
commit arrives through git pull, and git applies it as “this file is no
longer in the repo”. For a tracked file that’s unchanged locally, that means
deleting it from the working tree, which here is the live CSV. If the file
has changed locally (and a live log always has), git refuses the pull instead.
Neither outcome is what you want. So on the server, the order matters:
cd ~/webaudit
# 1. Copy the live files to the new, ignored location
mkdir -p data && cp leads.csv data/leads.csv && cp -r manual-leads data/manual-leads
# 2. Drop the local modifications so the pull can apply cleanly
git checkout -- leads.csv manual-leads 2>/dev/null
# 3. Pull: the old copies leave the working tree, the backups stay in data/
git pull
After the pull, the server writes to data/leads.csv, which git ignores.
Restart, not rebuild
Because the code is mounted, not copied into the image, there’s no image
rebuild after git pull. Recreating the container is enough, and it also
re-reads .env:
docker compose -f docker-compose.prod.yml up -d --force-recreate
Compose printed a warning about an orphan container from an older service
name in the same project. It’s harmless, but worth a look: after checking
with docker ps -a that nothing in it is needed, --remove-orphans cleans it
up.
What’s still in history
git rm --cached stops tracking from now on. Every earlier commit still
contains the old leads.csv and the notes folder. For a private repo that’s
a judgement call: rewriting history (git filter-repo, then a force push and
re-cloning everywhere) removes them for good, at the cost of disrupting every
existing clone. I’ve written it down as an open decision rather than doing it
in the same breath as the fix.
Takeaways
- A bind-mounted checkout makes every runtime file a candidate git file.
Give runtime data its own ignored directory from day one (
data/). - Make data paths configurable (
LEADS_CSV_PATH) so they can live outside the repo entirely. - Untracking a file is a deletion for every other clone. On a server, back up first, discard local changes, then pull.
- With mounted code, deploying is
git pullplus a recreate, not an image rebuild. - History keeps what you untrack. Cleaning it is a separate, deliberate step.
Related: deploying a direct-upload Pages site from a git worktree,
which avoids the opposite problem (untracked files sneaking into a deploy),
and the pull-only homelab mirror,
which keeps its database in an ignored data/ folder for the same reason.