• 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

  1. 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 });
  2. Ignore it, and the old locations too:

    # Personal data never goes to git
    data/
    leads.csv
    manual-leads/
  3. 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 pull plus 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.