Port-collision-free project switcher for parallel development.
Lane is a small CLI that lets you run multiple development projects in parallel without their ports stepping on each other. It does one thing: it remembers which ports each of your projects has reserved, and exports those ports as environment variables into your current shell — the same pattern used by direnv and asdf.
Lane never edits your project files. It does not start, stop, or proxy anything. It is a thin coordination layer over the convention your tools already understand:
# docker-compose.yml (the convention)
services:
app:
ports:
- "${APP_PORT:-80}:80" # APP_PORT supplied by Lane$ eval "$(lane use my-laravel-app)"
$ docker compose up # uses Lane's reserved APP_PORTIf you have ever tried to run a Laravel/Sail project, a Vite dev server, and a Postgres container in parallel — across two or three different projects — you have seen this dance:
- Project A and project B both want
:80,:3306,:5173. - You hand-edit
docker-compose.ymlor.envin one of them. - You forget. Two weeks later you boot both, ports collide, things break.
Lane fixes the port assignment part of that problem. It does not try to be a process manager, a service mesh, or an orchestrator. It does not isolate filesystems or contexts — your OS and your editor already do that. It just makes sure that when you say "I am working on project A", your shell exports the ports project A is allowed to use, and nothing else.
Lane works out of the box with the stacks most fullstack developers
touch every day. "Out of the box" means: lane init recognizes the
project, allocates the right environment variables, and there is a
matching lane <framework> runner that wires the port into the dev
server automatically.
| Stack | Detected from | Default port | Runner |
|---|---|---|---|
| Laravel (with or without Sail) | composer.json requiring laravel/framework |
8080 |
lane serve → php artisan serve |
| Django | manage.py, or django in requirements.txt / pyproject.toml |
8000 |
lane django → python manage.py runserver |
| Flask | flask in requirements.txt / pyproject.toml |
5000 |
lane flask → flask run |
| Next.js | next in package.json dependencies |
3000 |
lane next → npx next dev |
| Stack | Detected from | Default port | Runner |
|---|---|---|---|
| Vite | vite in package.json dependencies |
5173 |
lane vite → npx vite |
| Stack | Detected from | Default port | Runner |
|---|---|---|---|
| MySQL | mysql in docker-compose.yml |
33060 |
(none needed — Compose reads the env var) |
| PostgreSQL | postgres in docker-compose.yml |
54320 |
(none needed) |
| Redis | redis in docker-compose.yml |
63790 |
(none needed) |
- Docker / Docker Compose / Laravel Sail — Compose reads
${APP_PORT:-80}:80from the env Lane exports.docker compose up,sail up, anything that uses Compose substitution: zero changes. - Multiple instances of the same framework — two Laravel projects,
two Django projects, etc. Each gets a unique port (8080, 8081, 8082,
…) at
lane inittime. - Mixed stacks in one terminal session — auto-activation switches
the exported vars whenever you
cdinto a different project.
Lane is intentionally conservative — runners only exist for frameworks
where the dev-server invocation is unambiguous. If you need one of
these, the port is still allocated (set lane init and read $APP_PORT
from your shell) but you wire the flag yourself:
- Rails (
bin/rails server -p $APP_PORT) - Express / Fastify / Hono — most read
process.env.PORT; map it with a one-liner in your code orPORT=$APP_PORT npm run dev - Go services — same idea, read
os.Getenv("APP_PORT")inmain.go - Phoenix, ASP.NET, Spring Boot, anything else — same pattern
If you want a runner for one of these, open an issue (or grep for
cmdServe in internal/cmd/runners.go — each runner is ~15 lines).
- It does not rewrite your
docker-compose.yml,.env,vite.config.js, or any other file in your project. - It does not manage Docker networks, volumes, or images.
- It does not orchestrate process lifecycles (start/stop/restart).
- It does not proxy network traffic.
Lane reserves ports and exposes them as env vars. Everything downstream of that is the framework's job.
Lane stores one TOML file (~/.config/lane/projects.toml by default,
honoring $XDG_CONFIG_HOME) describing each registered project, the
tech stack it uses, and the ports it has reserved.
When you lane use <project>, Lane prints a sequence of export
statements to stdout. You feed those into your shell with eval:
eval "$(lane use my-project)"Your shell now has APP_PORT, VITE_PORT, FORWARD_DB_PORT, etc.
exported. Any docker compose up, php artisan serve, or npm run dev you run after that picks them up automatically — as long as your
project's config follows the standard Docker/Sail convention of
${APP_PORT:-80}:80.
To clear them when you switch projects:
eval "$(lane unuse)"That is the whole model.
Requires Go 1.25 or newer.
git clone git@github.com:tincke10/Lane.git
cd Lane
make installThat installs lane into $(go env GOPATH)/bin. Make sure that
directory is on your PATH.
Or build the binary locally:
make build
./bin/lane help# 1. Register a project. Lane detects the stack and allocates free ports.
$ cd ~/code/my-laravel-app
$ lane init
registered "my-laravel-app" at /Users/me/code/my-laravel-app
stack: [docker laravel mysql node php redis vite]
APP_PORT=8080
FORWARD_DB_PORT=33060
FORWARD_REDIS_PORT=63790
VITE_PORT=5173
# 2. Switch to it in any shell.
$ eval "$(lane use my-laravel-app)"
$ echo $APP_PORT
8080
# 3. List everything Lane knows about.
$ lane list
my-laravel-app
path: /Users/me/code/my-laravel-app
stack: docker, laravel, mysql, node, php, redis, vite
APP_PORT=8080
FORWARD_DB_PORT=33060
FORWARD_REDIS_PORT=63790
VITE_PORT=5173
# 4. Clean up when you are done.
$ eval "$(lane unuse)"| Command | What it does |
|---|---|
lane init [--name N] [--path P] |
Register the project in P (defaults to cwd). Detects the stack, allocates free ports, persists to the registry, and warns if a present docker-compose.yml does not reference the env vars Lane just allocated. |
lane use [--shell posix|fish] <name> |
Print activation statements for the project. POSIX (bash/zsh) by default, fish syntax via --shell fish. Aborts (with empty stdout and a stderr message) if any reserved port is currently in use. |
lane unuse [--shell posix|fish] |
Print deactivation statements for the project named in LANE_ACTIVE_PROJECT. Safe to run when nothing is active. |
lane list (alias ls) |
List all registered projects with their paths, stacks, and ports. |
lane rm <name> (alias remove) |
Remove a project from the registry. |
lane doctor |
Diagnose registry health: missing paths, cross-project port collisions, currently bound ports, stack drift, orphaned active project. Exits non-zero only on errors; warnings are informational. |
lane hook <bash|zsh|fish> |
Print the shell hook code for auto-activation on cd. Run once during shell setup. |
lane export [--shell posix|fish] |
Hook-driven activation diff. Called by the installed hook on every prompt; not typically invoked directly. |
lane serve [extras...] |
Run php artisan serve on Lane's $APP_PORT (falls back to 8000 outside any project). Extras forwarded to artisan. |
lane vite [extras...] |
Run npx vite on Lane's $VITE_PORT (falls back to 5173). Extras forwarded to vite. |
lane next [extras...] |
Run npx next dev -p $APP_PORT (falls back to 3000). Extras forwarded to next. |
lane flask [extras...] |
Run flask run --port=$APP_PORT (falls back to 5000). Requires FLASK_APP in env. Extras forwarded. |
lane django [extras...] |
Run python manage.py runserver $APP_PORT (falls back to 8000). Falls back to python3 if python is absent. Extras forwarded. |
lane help |
Show usage. |
lane version |
Print the version. |
lane init --name <name>— Override the project name. Defaults to the basename of--path.lane init --path <path>— Project path. Defaults to the current working directory. The path is stored absolute.
Lane inspects the top level of the project directory (no recursion) and looks for known marker files. Detection is best-effort and intentionally conservative.
| Marker file(s) | Base tag | Deeper tag |
|---|---|---|
composer.json |
php |
laravel (if require."laravel/framework" is present) |
package.json |
node |
vite, nextjs (if the respective package is in dependencies or devDependencies) |
pyproject.toml or requirements.txt |
python |
flask, django (substring, case-insensitive) |
manage.py (in addition to the above) |
python |
django |
docker-compose.yml / docker-compose.yaml / compose.yml / compose.yaml |
docker |
mysql, postgres, redis (substring match) |
A malformed composer.json or package.json still yields the base tag
(php, node) and silently skips the deeper detection — Lane will not
fail because of a transient broken JSON file.
When lane init finds a docker-compose.yml (or compose.yml /
compose.yaml / docker-compose.yaml), it cross-references every env
var it just allocated against the file's contents. If a var is not
referenced — for example, the compose file hardcodes "80:80" instead
of "${APP_PORT:-80}:80" — Lane prints a warning at the end of
registration:
warning: docker-compose.yml does not reference these env vars:
- APP_PORT (allocated 8080, but compose will ignore it)
use the ${VAR:-default}:default pattern in your compose ports to opt in.
The warning never fails lane init. You can still register the project
and decide later whether to update the compose file. Lane recognizes
the standard shell forms: ${VAR}, ${VAR:-default}, ${VAR:?error},
and bare $VAR. Prefix collisions like ${VAR_EXTRA} do not count as
references to VAR.
The first time you lane init, Lane allocates the lowest free port at
or above the base for each env var your stack needs. Reserved ports
from other registered projects are skipped, so two projects never get
the same port.
| Stack marker | Env var | Base port |
|---|---|---|
laravel |
APP_PORT |
8080 |
django |
APP_PORT |
8000 |
flask |
APP_PORT |
5000 |
nextjs |
APP_PORT |
3000 |
vite |
VITE_PORT |
5173 |
mysql |
FORWARD_DB_PORT |
33060 |
postgres |
FORWARD_DB_PORT |
54320 |
redis |
FORWARD_REDIS_PORT |
63790 |
When multiple frameworks compete for APP_PORT, precedence is
laravel > django > flask > nextjs. Likewise mysql wins over
postgres for FORWARD_DB_PORT. Projects that legitimately combine
two backends (rare) can override at runtime via lane serve --port=...
or by editing projects.toml manually.
Lane scans up to 1000 consecutive ports above the base before giving
up. The check uses a TCP listen on 127.0.0.1, which is inherently
racy — another process can grab the port between the check and your
actual use. lane use re-checks at activation time and refuses to
export if any port is busy.
Lane follows the direnv / asdf playbook: it prints, your shell evals. Nothing magic, no daemon, no hooks installed unless you add them yourself.
bash / zsh:
eval "$(lane use my-project)"
# ... work ...
eval "$(lane unuse)"fish:
lane use --shell fish my-project | source
# ... work ...
lane unuse --shell fish | sourceAdd to your ~/.bashrc or ~/.zshrc:
lane-use() { eval "$(command lane use "$@")"; }
lane-unuse() { eval "$(command lane unuse "$@")"; }Then:
lane-use my-project
lane-unuseInstall the hook once and Lane keeps your env in sync automatically as
you cd around. Each prompt, Lane walks up from the current directory,
finds the nearest registered project, and emits the diff against what
your shell currently has activated.
Add to ~/.zshrc:
eval "$(lane hook zsh)"Or to ~/.bashrc:
eval "$(lane hook bash)"Or to ~/.config/fish/config.fish:
lane hook fish | sourceWhat you get:
cd ~/code/my-laravel-app→APP_PORT,VITE_PORT, etc. exported.cd ~/code/other-project→ previous project's vars unset, new project's exported.cd ~(no project) → everything unset, shell back to clean state.cd deep/inside/my-laravel-app/src/components→ still activatesmy-laravel-app. Lane walks up the tree until it finds a match.
The hook is silent and fast: it does not check port availability on
every prompt (lane doctor is the place for that), it does not call out
to the network, and it stays quiet on transient errors so a broken
registry never breaks your prompt.
Because activation lives in environment variables, each tab/pane/window
keeps its own active project. The auto-activation hook works
independently in each one — open a new tab, cd into a different
project, and that tab activates that project without touching the
others.
If a reserved port is already bound by some other process when you run
lane use, Lane prints nothing to stdout and writes the conflict to
stderr with a non-zero exit code. That way eval "$(lane use ...)"
never silently activates a broken environment.
$ eval "$(lane use my-project)"
lane: port collision detected for "my-project" — not activating:
APP_PORT=8080 is in use
$ echo $?
1Some frameworks read their port from a CLI flag rather than the env vars
Lane exports (Sail/Docker do the right thing automatically via the
${APP_PORT:-80}:80 convention; standalone php artisan serve and npx vite do not). Lane ships runners that bridge the gap:
# Laravel
lane serve # = php artisan serve --port=$APP_PORT
lane serve --host=0.0.0.0
# Vite (any frontend project with vite installed)
lane vite # = npx vite --port=$VITE_PORT
lane vite --open
# Next.js
lane next # = npx next dev -p $APP_PORT
lane next --turbo
# Flask (requires FLASK_APP env or .flaskenv)
lane flask # = flask run --port=$APP_PORT
lane flask --debug
# Django
lane django # = python manage.py runserver $APP_PORT
lane django --noreload # falls back to python3 if `python` is unavailableOutside a Lane project the runners fall back to the framework's own
default (8000 for artisan, 5173 for vite), so they remain safe to
alias to plain serve / vite-dev in your shell if you want.
For Sail / Docker Compose users: keep using docker compose up /
sail up as before. The compose file's ${APP_PORT:-80}:80 pattern
picks up Lane's exported env vars automatically — no runner needed.
Lane stores its registry as TOML at:
$XDG_CONFIG_HOME/lane/projects.toml # if $XDG_CONFIG_HOME is set
$HOME/.config/lane/projects.toml # fallback
The file is written atomically (*.tmp + rename) so an interrupted
write cannot corrupt your registry. It is plain text — feel free to
edit it by hand if you need to bulk-rename or relocate things.
XDG_CONFIG_HOME— overrides the default registry location.LANE_ACTIVE_PROJECT— set bylane use, read bylane unuseto know what to unset.
LANE_ACTIVE_PROJECT— name of the currently active project.- One
exportper reserved port (e.g.,APP_PORT,VITE_PORT,FORWARD_DB_PORT,FORWARD_REDIS_PORT).
make build # compile to ./bin/lane
make install # install to GOPATH/bin
make test # run tests with race detector
make test-cover # run tests + open HTML coverage report
make lint # golangci-lint
make fmt # gofmt + go vet
make tidy # go mod tidy
make clean # remove build artifactscmd/lane/ Entry point: parses --version, delegates to internal/cmd.
internal/registry/ TOML-backed registry of projects and reserved ports.
internal/ports/ Free-port detection and collision-aware allocator.
internal/stack/ Filesystem-based stack detection (composer/package.json/etc).
internal/activator/ Generates POSIX `export` / `unset` statements.
internal/doctor/ Diagnostic checks for registry health (used by `lane doctor`).
internal/convention/ Validates that docker-compose references the env vars Lane allocates.
internal/cmd/ CLI dispatcher and subcommand handlers.
Each internal package is independently testable. ports and stack
have no dependencies on the registry; cmd is the only layer that
wires them together.
Each project gets its own lane on the road. They run in parallel, they
do not cross each other, and you can switch lanes whenever you want.
The name avoids the saturated nautical metaphors (harbor, berth,
pier, regatta) that already exist in this space.
MIT. See LICENSE.