The 12-Line .env That Runs 3 Client Repos From One Terminal

One wrong cd at 2AM and I would have shipped invoicing logic into a Telegram bot repo. Three live client projects, one WSL box, one Claude Code session. If you run more than one client through Claude Code, you already know the fear: Claude edits the wrong repo, MCP servers point at the wrong database, and your git history turns into a crime scene.
Here's the exact 12-line .env file — plus the ~40 lines of shell around it — that took my cross-project bugs to zero for 60 straight days.
The failure mode nobody warns you about at client three
Most tutorials teach you how to start a solo business. Nobody teaches you what breaks at client three. Here's the scenario, cleanly stated: you have client A open in Claude Code. Slack pings from client B. You cd into B's repo without restarting the session. Claude still has A's CLAUDE.md in context, still thinks the DB URL is A's Postgres, still has A's MCP servers connected. You ask for "a small feature." Claude writes forty lines referencing tables that don't exist in B's schema, imports helpers from A's namespace, and if you're really unlucky, commits before you notice.
I've watched this happen to three solo builders in the last month. Same root cause every time: Claude Code inherits whatever the shell has loaded — working directory, environment variables, MCP config path, DB URLs, API keys, log destinations. If those don't atomically swap when you change projects, Claude uses the wrong ones. And Claude is fast enough now that by the time you see it, the damage is written.
The fix isn't better prompting. It isn't a better CLAUDE.md. It's environment isolation at the shell level, and it takes twelve lines.
Why Docker, workspaces and direnv alone don't cut it
Before the file, the alternatives, and why I stopped using them:
- Docker per project. Cold-start pain, MCP servers get weird across the container boundary (stdio transports especially), and mounting three repos as volumes with correct permissions on WSL is its own weekend.
- Separate machines per client. You're a solo operator. You don't have three laptops on your desk, and syncing SSH keys / secrets / dotfiles across them multiplies the problem.
- VSCode workspaces. They isolate the editor's file tree. They do not isolate the shell Claude Code runs in.
CLAUDE.mdstill gets picked up from the wrong place if you launch from the wrong terminal. - direnv alone. Closer. It swaps env vars on
cd. But out of the box it doesn't touch your MCP config path, doesn't verify the git remote before Claude writes files, and doesn't recolor your prompt so your eyes catch the mistake before your brain does.
| Approach | Startup cost | MCP-aware | Git-remote guard | Solo-friendly |
|---|---|---|---|---|
| Docker per project | 8–30s | Fragile | No | No |
| Separate machines | Minutes | Yes | Manual | No |
| VSCode workspaces | Instant | No | No | Yes |
| direnv alone | Instant | No | No | Yes |
.env + switch-client + hook |
~0.4s | Yes | Yes | Yes |
The 12-line .env file, one per client
One .env per client, stored in ~/client-envs/. Twelve lines. No magic.
# ~/client-envs/acme.env
PROJECT_NAME=acme
PROJECT_ROOT=/home/lazar/work/acme-invoicing
CLAUDE_CONTEXT_FILE=/home/lazar/work/acme-invoicing/CLAUDE.md
MCP_CONFIG_PATH=/home/lazar/client-envs/mcp/acme.mcp.json
DB_URL=postgres://acme_ro@localhost:5432/acme_prod
LOG_PREFIX=[acme]
GIT_EXPECTED_REMOTE=git@github.com:acme-inc/invoicing.git
API_KEY_FILE=/home/lazar/client-envs/secrets/acme.env
CLAUDE_MODEL=claude-opus-4
TELEMETRY_TAG=acme-prod
NODE_ENV=development
PROJECT_COLOR=33
What each line actually earns its place for:
PROJECT_NAME— canonical short name, used by the pre-commit hook and log lines.PROJECT_ROOT— absolute path.switch-clientcds here so relative paths are never ambiguous.CLAUDE_CONTEXT_FILE— absolute path to that project'sCLAUDE.md. Claude Code reads exactly one file per session.MCP_CONFIG_PATH— points to a per-clientmcp.jsonso Claude only sees this client's MCP servers.DB_URL— the DB Claude's tools connect to. Wrong URL here is the single most expensive mistake possible.LOG_PREFIX— prepended to every log line. Makesgrep -r "\[acme\]"across all projects trivial after the fact.GIT_EXPECTED_REMOTE— the tripwire. Compared againstgit remote get-url originbefore anything runs.API_KEY_FILE— path to a gitignored secrets file, sourced separately so keys never sit in the main.env.CLAUDE_MODEL— some clients pay for Opus, some don't. Hard-code it per project so you don't burn budget by accident.TELEMETRY_TAG— usage tracking bucket. When the monthly bill arrives I know exactly which client owes what.NODE_ENV— or the equivalent for your stack. Prevents "why are dev seeds in prod again."PROJECT_COLOR— an ANSI color code. Prompt changes color per client. My eyes catch mistakes before my brain does.
The switch-client function that atomically swaps context
Twelve env vars are useless if swapping between them isn't a single command. This lives in ~/.bashrc:
switch-client() {
local client="$1"
local envfile="$HOME/client-envs/${client}.env"
if [[ ! -f "$envfile" ]]; then
echo -e "\033[31mNo env file for '$client' at $envfile\033[0m"
return 1
fi
set -a; source "$envfile"; set +a
[[ -f "$API_KEY_FILE" ]] && { set -a; source "$API_KEY_FILE"; set +a; }
cd "$PROJECT_ROOT" || return 1
local actual_remote
actual_remote="$(git remote get-url origin 2>/dev/null)"
if [[ "$actual_remote" != "$GIT_EXPECTED_REMOTE" ]]; then
echo -e "\033[31mREMOTE MISMATCH\033[0m"
echo " expected: $GIT_EXPECTED_REMOTE"
echo " actual: $actual_remote"
return 1
fi
# Symlink the canonical context file so Claude Code always reads the right one
ln -sfn "$CLAUDE_CONTEXT_FILE" "$HOME/.claude/CLAUDE.md"
export PS1="\[\033[${PROJECT_COLOR}m\][$PROJECT_NAME]\[\033[0m\] \w \$ "
echo "loaded: $PROJECT_NAME | db: $DB_URL | model: $CLAUDE_MODEL"
}
The logic is short but every line pays rent. It sources the .env, sources the secrets file separately, cds into the project root, then does the one check that has saved me twice: it compares git remote get-url origin against GIT_EXPECTED_REMOTE. If they don't match, the function refuses to continue and prints a red error. No environment gets loaded, no symlink gets updated, nothing.
Then it flips the CLAUDE.md symlink at ~/.claude/CLAUDE.md to point at the currently active client's context. Claude Code reads exactly one file. That file is always the right one, because switch-client just repointed it.
Prompt turns yellow, cyan, magenta depending on PROJECT_COLOR. Then a one-line summary: which project, which DB, which model. About 400ms end to end.
The pre-commit hook that catches manual cd disasters
Because I don't trust myself at 2AM, there's a safety rail. A pre-commit hook, ~15 lines of bash, installed in each repo's .git/hooks/pre-commit:
#!/usr/bin/env bash
set -e
if [[ -z "$CLAUDE_CONTEXT_FILE" ]]; then
echo "CLAUDE_CONTEXT_FILE not set — did you run switch-client?"
exit 1
fi
ctx_project="$(grep -iE '^# project:' "$CLAUDE_CONTEXT_FILE" | head -1 | awk '{print $3}')"
repo_project="$(basename "$(git rev-parse --show-toplevel)")"
if [[ "$ctx_project" != "$repo_project" ]]; then
echo -e "\033[31mCONTEXT MISMATCH — refusing commit\033[0m"
echo " CLAUDE.md says project: $ctx_project"
echo " current repo: $repo_project"
exit 1
fi
It reads the # Project: header line from the currently loaded CLAUDE.md and compares it against the basename of the git repo root. If they don't match, the commit is rejected with a loud message telling you which context is loaded and which repo you're in.
This has fired twice in 60 days. Both times I had switched directories manually without running switch-client, both times Claude generated correct-looking code for the wrong project, and both times the hook stopped it before anything shipped. If you take one thing from this post, take this hook.
Per-client MCP config split
The last piece is the MCP config split. One global mcp.json with every server for every client is how databases get cross-connected. Instead:
~/client-envs/mcp/
acme.mcp.json # postgres → acme_prod, filesystem → /work/acme
beacon.mcp.json # postgres → beacon_stg, telegram MCP
delta.mcp.json # supabase MCP, stripe MCP (read-only key)
switch-client exports MCP_CONFIG_PATH, and Claude Code launches with --mcp-config "$MCP_CONFIG_PATH". Client A's session literally cannot see client B's Postgres MCP, because it isn't in the config the process was started with. Filesystem MCPs get scoped to PROJECT_ROOT for the same reason — if the tool can't traverse to ../other-client, it can't read from it.
What this stack blocks in practice
- Claude editing files in the wrong repo (blocked by
PROJECT_ROOT+ filesystem MCP scope). - Claude connecting to the wrong database (blocked by
MCP_CONFIG_PATHswap). - Committing to the wrong git remote (blocked by
switch-client's remote check). - Committing with stale context (blocked by the pre-commit hook).
- Burning Opus budget on a Sonnet-tier client (blocked by per-project
CLAUDE_MODEL).
Sixty days, zero cross-project bugs. Before this: three near-misses in the month prior, one of which required a git reset --hard and a slightly awkward email.
Where bizflowai.io fits
This post is the DIY version, and if you run three client repos yourself you should build it — it's about ninety minutes of setup and it pays back the first time it catches you. The commercial version of the same idea is what bizflowai.io automates for client work end-to-end: per-client environment provisioning, MCP-scoped tool access, audit logs by project, and guardrails that stop an agent from touching the wrong data source. Same principle — atomic context swaps and hard tripwires — packaged so a small team doesn't have to maintain the shell scripts.
Want more like this?
I publish practical AI automation, GenAI engineering, and faceless content workflows on YouTube every week.
Subscribe to bizflowai.io on YouTube — never miss a new tutorial.
Planning an AI automation project or need a second opinion on your architecture?
Connect with me on LinkedIn — Lazar Milicevic, GenAI Engineer & bizflowai.io Founder.
Visit bizflowai.io for our services, case studies, and AI consulting.
Frequently asked questions
What is Claude Code context bleed?
Context bleed happens when Claude Code inherits stale environment settings from a previous project after you cd into a new repo without restarting. It keeps the old CLAUDE.md, MCP servers, database URLs, and API keys loaded, causing it to write code referencing tables or helpers from the wrong client. The fix is shell-level environment isolation, not better prompting.
How do I isolate environments between client projects in Claude Code?
Create one .env file per client in a ~/client-envs folder containing variables like PROJECT_ROOT, CLAUDE_CONTEXT_FILE, MCP_CONFIG_PATH, DB_URL, and GIT_EXPECTED_REMOTE. Add a switch-client shell function to your .bashrc that sources the matching .env, cds into the project, verifies the git remote matches, exports Claude Code variables, and changes prompt color. Add a pre-commit hook as a safety rail.
Why don't Docker containers or VSCode workspaces solve this problem?
Docker containers per project are too heavy, slow to start, and MCP servers behave strangely across container boundaries. Separate machines aren't realistic for solo operators. VSCode workspaces only isolate the editor, not the shell that Claude Code runs in. Direnv is closer but doesn't touch MCP config paths or verify the git remote before Claude modifies files.
What should a per-client .env file contain for Claude Code isolation?
Twelve variables: PROJECT_NAME, PROJECT_ROOT, CLAUDE_CONTEXT_FILE, MCP_CONFIG_PATH, DB_URL, LOG_PREFIX, GIT_EXPECTED_REMOTE, API_KEY_FILE, CLAUDE_MODEL, TELEMETRY_TAG, NODE_ENV, and PROJECT_COLOR. PROJECT_COLOR is an ANSI code that changes your terminal prompt color per client so visual mistakes get caught before Claude writes wrong code. Each file is gitignored and stored in ~/client-envs.
How does a pre-commit hook prevent Claude Code from committing to the wrong repo?
A roughly fifteen-line bash pre-commit hook reads the CLAUDE_CONTEXT_FILE environment variable and compares the project name inside it against the current directory's repo name. If they don't match, the commit is rejected with a loud error message identifying which context is loaded versus which repo you're in. This catches cases where context bleed slipped past the switch-client function.