Managing Multiple Git Worktrees with Caddy and devctl2
If you've ever worked on a large project with multiple feature branches, you've probably experienced the juggling act: switching branches, restarting services, losing your flow. Git worktrees solve the branch-switching problem — but they create new ones.
Each worktree needs its own ports, database, and routing. Managing this manually is tedious and error-prone. So I built devctl2 to automate the entire setup.
The Problem with Multiple Worktrees
Git worktrees are fantastic. Instead of stashing changes or committing half-finished work to switch branches, you can have multiple branches checked out simultaneously in separate directories:
git worktree add ../feature-auth feature/auth
git worktree add ../feature-dashboard feature/dashboardNow you've got three copies of your codebase: main, feature-auth, and feature-dashboard. But when you try to run them:
- Port conflicts: All three want port 3000
- Database collisions: All three connect to the same database
- Routing chaos: How do you access each one in the browser?
You end up manually changing .env files, running on random ports, and losing track of what's running where.
The Solution: Automatic Environment Isolation
devctl2 solves this by automatically provisioning each worktree with:
- Unique ports — deterministically allocated, no conflicts
- Isolated database — cloned from a template, per-worktree
- Caddy routing — automatic reverse proxy with subdomain support
Quick Demo
# In your feature branch worktree
cd ../feature-auth
devctl2 setup
# ✓ Allocated ports: API=3042, Web=5042
# ✓ Created database: myproject_feature_auth
# ✓ Updated .env files
# ✓ Configured Caddy route: feature-auth.dev.localNow https://feature-auth.dev.local routes to your feature branch, completely isolated from main.
How It Works
Deterministic Port Allocation
Instead of random ports or manual assignment, devctl2 uses a hash of the worktree path to generate consistent ports:
/projects/myapp/main → ports 3000, 5000
/projects/myapp/feature-auth → ports 3042, 5042
/projects/myapp/feature-dash → ports 3087, 5087
Same worktree always gets the same ports. Different worktrees never conflict.
Database Per Worktree
Each worktree gets its own PostgreSQL database, cloned from a template:
# Template database with your schema + seed data
myproject_dev (template)
# Auto-created per worktree
myproject_main
myproject_feature_auth
myproject_feature_dashboardThis means you can:
- Test migrations without affecting other branches
- Have different data states for different features
- Never worry about schema conflicts
Caddy Reverse Proxy
devctl2 uses Caddy's Admin API to dynamically add routes:
https://main.dev.local/* → localhost:5000 (web)
https://main.dev.local/api/* → localhost:3000 (api)
https://feature-auth.dev.local/* → localhost:5042 (web)
https://feature-auth.dev.local/api/* → localhost:3042 (api)
No manual Caddyfile editing. Routes are added instantly via the API.
Setting Up devctl2
1. Install
npm install -g @adamhancock/devctl22. Configure Caddy
Enable the Admin API in your Caddyfile:
{
admin localhost:2019
}devctl2 handles all routing dynamically via the Admin API — no manual route configuration needed.
3. Initialize Your Project
cd /path/to/your/project
devctl2 init my-projectThis creates .devctl2rc.json:
{
"projectName": "my-project",
"baseDomain": "dev.local",
"databasePrefix": "myproject",
"caddyApi": "http://localhost:2019",
"portRanges": {
"api": { "start": 3000, "count": 1000 },
"web": { "start": 5000, "count": 1000 }
},
"envFiles": {
"api": "packages/api/.env",
"web": "packages/web/.env"
},
"database": {
"host": "localhost",
"port": 5432,
"user": "postgres",
"templateDb": "myproject_dev"
}
}4. Setup Each Worktree
# Create worktree
git worktree add ../feature-x feature/x
cd ../feature-x
# Provision environment
devctl2 setupDone. Your feature branch is running on its own ports with its own database.
Useful Commands
# List all active routes
devctl2 list
# Get ports for a specific worktree
devctl2 ports feature-auth
# Remove a route when done
devctl2 remove feature-auth
# Dump database for backup/sharing
devctl2 dump
# Restore database from dump
devctl2 restore backup.sql
# Health check
devctl2 doctorReal-World Workflow
Here's how I use this daily:
Starting a new feature:
git worktree add ../feature-payments feature/payments
cd ../feature-payments
devctl2 setup
pnpm dev
# Working at https://feature-payments.dev.localReviewing a PR:
git worktree add ../pr-review origin/pr/123
cd ../pr-review
devctl2 setup
# Test the PR at https://pr-review.dev.localCleaning up:
devctl2 remove pr-review
git worktree remove ../pr-reviewNo more "which port is that running on?" No more database conflicts. No more context-switching overhead.
Why Caddy?
I chose Caddy for a few reasons:
- Admin API — Dynamic route management without reloading
- Automatic HTTPS — Even for local development with
tls internal - Simple config — No nginx.conf complexity
- Fast — Written in Go, minimal overhead
The Admin API is the killer feature here. Adding a route is a single HTTP request:
curl -X POST "http://localhost:2019/config/apps/http/servers/srv0/routes" \
-H "Content-Type: application/json" \
-d '{"match":[{"host":["feature.dev.local"]}],"handle":[...]}'devctl2 wraps this in a friendly CLI, but you can also integrate it into other tools.
Get Started
# Install from npm
npm install -g @adamhancock/devctl2
# Initialize and go
cd your-project
devctl2 init
devctl2 setupThe README has full documentation: github.com/adamhancock/cli/tree/main/packages/devctl2
devctl2 is available on npm and open source on GitHub. If you're managing multiple worktrees, give it a try — your future self will thank you.