Monorepos
Tips for using workmux with monorepos containing multiple services.
Nested configuration
Place a .workmux.yaml in any subdirectory to configure that project independently. When you run workmux from a subdirectory, it walks upward to find the nearest config:
monorepo/
├── .workmux.yaml # Root config (used from monorepo/)
├── backend/
│ ├── .workmux.yaml # Backend config (used from backend/)
│ └── src/
└── frontend/
├── .workmux.yaml # Frontend config (used from frontend/)
└── src/cd monorepo/backend
workmux add api-feature # Uses backend/.workmux.yamlWhen using a nested config:
- Working directory: The tmux window opens in the subdirectory (e.g.,
backend/) within the new worktree, not the worktree root - File operations:
files.copyandfiles.symlinkpaths are relative to the config directory - Hooks: Run with the subdirectory as the working directory
Example nested config
# backend/.workmux.yaml
agent: claude
files:
copy:
- .env # Copies backend/.env to worktree's backend/.env
post_create:
- cargo build # Runs in worktree's backend/ directoryEnvironment variables
Hooks receive WM_CONFIG_DIR pointing to the config directory in the new worktree:
# backend/.workmux.yaml
post_create:
- echo "Config dir: $WM_CONFIG_DIR" # /path/to/worktree/backend
- echo "Worktree root: $WM_WORKTREE_PATH" # /path/to/worktreeNo inheritance
Nested configs completely replace the root config - they are not merged. If you need shared settings across projects, use the global config (~/.config/workmux/config.yaml) with the <global> placeholder.
Port isolation
When running multiple services (API, web app, database) in a monorepo, each worktree needs unique ports to avoid conflicts. For example, if your .env has hardcoded ports like API_PORT=3001 and VITE_PORT=3000, running two worktrees simultaneously would fail because both would try to bind to the same ports.
One strategy is to generate a .env.local file with unique ports for each worktree. Many frameworks (Vite, Next.js, CRA) automatically load .env.local and merge it with .env, with .env.local taking precedence.
Example
Create a script at scripts/worktree-env:
#!/usr/bin/env bash
set -euo pipefail
port_in_use() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN &>/dev/null
}
find_port() {
local port=$1
while port_in_use "$port"; do
((port++))
done
echo "$port"
}
# Hash the handle to get a deterministic port offset (0-99)
hash=$(echo -n "$WM_HANDLE" | md5 | cut -c1-4)
offset=$((16#$hash % 100))
# Find available ports starting from the hash-based offset
api_port=$(find_port $((3001 + offset * 10)))
vite_port=$(find_port $((3000 + offset * 10)))
# Generate .env.local with port overrides
cat >.env.local <<EOF
API_PORT=$api_port
VITE_PORT=$vite_port
VITE_PUBLIC_API_URL=http://localhost:$api_port
EOF
echo "Created .env.local with ports: API=$api_port, VITE=$vite_port"Configure workmux to copy .env and generate .env.local:
# .workmux.yaml
files:
copy:
- .env # Copy secrets (DATABASE_URL, API keys, etc.)
post_create:
- ./scripts/worktree-env # Generate .env.local with unique portsPlain Node.js
For Node.js without framework support, load both files with later overriding earlier:
{
"scripts": {
"api": "node --env-file=.env --env-file=.env.local api/server.js",
"web": "node --env-file=.env --env-file=.env.local web/server.js"
}
}Using direnv
You can also use direnv to load the generated .env.local:
# .envrc
dotenv
dotenv_if_exists .env.localUse the same worktree-env script to generate .env.local. When you enter the directory, direnv automatically loads .env and .env.local, with the latter taking precedence.
# .workmux.yaml
files:
copy:
- .envrc
- .env
post_create:
- ./scripts/worktree-envHow it works
The worktree handle is hashed to get a deterministic starting port, so feature-auth always starts at the same offset. If that port is taken, lsof finds the next available one.
$ workmux add feature-auth
Running setup commands...
Created .env.local with ports: API=3471, VITE=3470
✓ Setup complete
✓ Successfully created worktree and tmux window for 'feature-auth'