Run 2+ Docker compose worktrees on the same service ports at the same time without conflicts.
Terminal A (feature-1 worktree) | Terminal B (feature-2 worktree) |
|---|---|
|
|
npm serve) with Traefik routing*.port domainsservice.<branch>.portport up# Port is published on npm, but requires Bun at runtime
npm install -g @jdtzmn/port
# or install globally with Bun
bun add -g @jdtzmn/port
port executes with a Bun shebang (#!/usr/bin/env bun), so Bun must be installed and available on PATH even when the package is installed via npm.
Port ships an installable Agent Skill that teaches compatible coding agents the safe Port workflow: setup, entering worktrees, starting services, viewing URLs, running host processes, and cleaning up.
npx skills add jdtzmn/port --skill port-cli -g
# or from a local checkout
npx skills add . --skill port-cli --copy -y
Want a guided workflow in the CLI?
port onboard
port init
This scaffolds .port/config.jsonc, hooks, and templates. Core worktree commands can run without this step.
Create .port/config.jsonc in your project:
{
// Optional, defaults to "port"
"domain": "port",
// Optional, defaults to "docker-compose.yml"
"compose": "docker-compose.yml",
}
port install
Configures your system to resolve your configured wildcard domain (default *.port) to 127.0.0.1.
On macOS, port install runs privileged steps through a centralized elevation helper: it uses the native admin credential dialog when a GUI session is available and falls back to terminal sudo in headless/non-GUI environments.
You can optionally specify a custom IP address:
# Resolve to a specific IP (useful for Docker networks, etc.)
port install --dns-ip 172.25.0.2
# Skip confirmation prompt
port install --yes
# Combine options
port install --yes --dns-ip 192.168.1.100
# Explicit custom domain
port install --domain custom
On Linux systems with systemd-resolved running (most modern Ubuntu/Debian systems), the install command automatically:
systemd-resolved is using port 53dnsmasq on port 5354 to avoid conflictssystemd-resolved to forward your wildcard domain queries to dnsmasqThis "dual-mode" setup allows both services to coexist without conflicts.
Add this to your shell profile so port enter and port exit can change your working directory:
# ~/.bashrc
eval "$(port shell-hook bash)"
# ~/.zshrc
eval "$(port shell-hook zsh)"
# ~/.config/fish/config.fish
port shell-hook fish | source
Without shell integration, port enter and port exit will print a cd command for you to run manually.
Enable tab completion for port commands and options:
# Bash - add to ~/.bashrc
eval "$(port completion bash)"
# Zsh - add to ~/.zshrc
eval "$(port completion zsh)"
# Fish - add to ~/.config/fish/config.fish
port completion fish | source
This enables tab completion for all port commands, options, and branch names.
port feature-1
port enter feature-1
This creates a new worktree and changes into it (with shell integration) or prints the path to cd into.
Use port enter <branch> when your branch name collides with a command (for example status or install).
If a branch and command collide, running port <command> shows a hint to use port enter <branch>.
If the branch is already checked out in another worktree, port enter shows you where it is and asks whether to enter that worktree instead (non-interactive terminals reuse it automatically).
port exit
Returns to the repository root and clears the PORT_WORKTREE environment variable.
port rename feature-new
# or use the shorthand
port mv feature-new
Run this from inside the worktree after stopping its services. It renames the current worktree and branch in place and keeps Port state aligned.
port up [services...]
Starts docker-compose services and makes them available at feature-1.port:PORT.
Omit services... to start everything, or pass specific service names to start a subset and their dependencies.
For services with published ports, the first one is also reachable at service.feature-1.port.
If .port/hooks/post-up.sh is executable, Port runs it after services are up. You can manually
rerun that hook with:
port open
port down [services...]
Stops all services by default, or a selected subset when service names are provided. The selected containers are removed without tearing down the whole worktree.
port run 3000 -- npm run dev
Runs a host process (not in Docker) and routes traffic through Traefik. The command receives the PORT environment variable set to an ephemeral port, while users access it via <branch>.port:3000.
This is useful for:
port status
Shows per-service status grouped by worktree, host services, and Traefik.
Show URLs for services in the current worktree:
port urls
port urls ui-frontend
port urls works in either a worktree or the main repository.
port remove feature-1
# Skip confirmation for non-standard/stale worktree entries
port rm -f feature-1
# Keep the local branch name unchanged
port rm --keep-branch feature-1
# Clean up Docker images without prompting
port rm --cleanup-images feature-1
Stops services, removes the worktree, and soft-deletes the local branch by archiving it under archive/<name>-<timestamp>.
Use --keep-branch to preserve the local branch name.
Docker Cleanup Behavior:
--cleanup-images to clean up images without promptingport cleanup
# Clean up images without prompting
port cleanup --cleanup-images
Shows archived branches created by port remove and asks for confirmation before deleting all of them.
Docker Cleanup Behavior:
--cleanup-images to clean up images without prompting| Command | Description |
|---|---|
port init | Initialize .port/ directory structure |
port onboard | Print recommended workflow and command usage guide |
port install [--dns-ip IP] [--domain DOMAIN] | Set up DNS for wildcard domain (default from config) |
port shell-hook <bash|zsh|fish> | Print shell integration code for automatic cd |
port completion <bash|zsh|fish> | Generate shell completion script for tab completion |
port enter <branch> | Enter a worktree explicitly (including command names) |
port <branch> | Enter a worktree (creates if doesn't exist) |
port exit | Exit the current worktree and return to repo root |
port rename <branch> / port mv <branch> | Rename the current worktree and branch in place |
port up [services...] | Start docker-compose services in current worktree |
port open | Re-run the post-up hook in the current repo/worktree context |
port down [services...] | Stop docker-compose services and host processes selectively |
port run <port> -- <command...> | Run a host process with Traefik routing |
port kill [port] | Stop host services (optionally by logical port) |
port remove <branch> [--force] [--keep-branch] [--cleanup-images] | Remove worktree, archive branch, clean up Docker resources |
port prune [--dry-run] [--force] [--cleanup-images] | Remove worktrees for merged branches, clean up Docker resources |
port cleanup [--cleanup-images] | Delete archived branches and their Docker resources |
port compose <args...> (alias: dc) | Run docker compose with auto -f flags |
port list | Print worktree names, one per line |
port status | Show service status across all worktrees |
port urls [service] | Show service URLs for current worktree |
port uninstall [--yes] [--domain DOMAIN] | Remove DNS configuration for wildcard domain |
port hook [hook-name] [--list] | List or manually run a configured lifecycle hook |
port status and port list may warn when the repo has 10 or more stale Port worktrees:
You have X stale port worktrees. Consider running port prune.
port enter <branch> shows the same warning only when the stale count is extreme (25+), and it never blocks worktree creation.
Port supports executable shell hooks in .port/hooks/:
post-create.sh: runs after a new worktree is created by port enter <branch>post-up.sh: runs after port up [services...] successfully starts servicespre-run.sh: runs before port run <port> -- <command...> starts the host processHooks receive these environment variables:
PORT_ROOT_PATHPORT_WORKTREE_PATHPORT_BRANCHPORT_DOMAINPORT_HOSTNAME_LABEL: sanitized branch name, truncated to 63 characters (the same label Traefik routes on) — use this instead of re-deriving a hostname from PORT_BRANCHPORT_HOSTNAME: PORT_HOSTNAME_LABEL + . + PORT_DOMAINpre-run.sh also receives PORT_LOGICAL_PORT, PORT_ACTUAL_PORT, and PORT_ENV_FILE. Append KEY=VALUE lines to $PORT_ENV_FILE to override environment variables for the command spawned by port run:
if [ -n "${DATABASE_URL:-}" ]; then
echo "DATABASE_URL=${DATABASE_URL/localhost:5432/$PORT_HOSTNAME:5432}" >> "$PORT_ENV_FILE"
fi
Manual hook commands:
port hook --list
port hook post-create
port hook post-up
# shorthand for `port hook post-up`
port open
┌─────────────────────────────────┐
│ CLI Tool: port │
│ (installed globally) │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Your Project: ~/projects/my-app │
│ ├── .port/ │
│ │ ├── config.jsonc │
│ │ └── trees/ │
│ │ ├── feature-1/ │
│ │ └── feature-2/ │
│ └── docker-compose.yml │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Traefik (global) │
│ ~/.port/traefik/ │
│ - Routes by hostname │
│ - Manages all services │
└─────────────────────────────────┘
Multiple worktrees can run simultaneously because:
Example:
# feature-1 worktree
port feature-1
port up
# Available at: feature-1.port:3000
# In another terminal, feature-2 worktree (same ports!)
port feature-2
port up
# Available at: feature-2.port:3000
# No conflicts! Traefik routes both to the same internal port on different containers
Port includes a custom 404 handler that helps you discover what's actually running when you visit an unknown URL.
When you navigate to a URL that doesn't match any running worktree (e.g., http://nonexistent.port:3000), instead of seeing a generic Traefik error, you'll receive a plain-text response showing:
Example responses:
404 - Worktree Not Found
Running worktrees:
feature-1
feature-2
main
Or when nothing is running:
404 - Worktree Not Found
No running worktrees
The handler works by:
traefik.enable=true labelsHost() routing rulesThis makes it easy to:
The port run command enables running non-Docker processes with Traefik routing:
# In .port/trees/feature-1 directory
port run 3000 -- npm run dev
# Service available at http://feature-1.port:3000
# In another terminal, .port/trees/feature-2 directory
port run 3000 -- npm run dev
# Service available at http://feature-2.port:3000
# No port conflicts! Both run simultaneously.
How it works:
PORT=49152 environment variable for the commandfeature-1.port:3000 → localhost:49152Most frameworks (Express, Next.js, Vite, etc.) respect the PORT environment variable automatically.
Port automatically manages Docker resources when removing worktrees or cleaning up archived branches.
Docker resources are cleaned up in two phases with different safety levels:
1. Low-Risk Cleanup (Automatic)
Always runs without prompting for:
These resources are safe to remove because they're project-specific and can be recreated.
2. High-Risk Cleanup (Opt-In)
Prompts before removing:
Images require confirmation because:
node:20, postgres:15) are often sharedInteractive Mode (default when running commands manually):
Clean up 3 image(s) (150.0 MB)? (y/N)Non-Interactive Mode (CI, scripts, --force flag):
--cleanup-images flag to clean up imagesport remove feature-1 --cleanup-imagesAll three cleanup commands follow the same safety pattern:
# port remove - cleans up single worktree
port remove feature-1 # Prompts for images (default No)
port remove feature-1 --cleanup-images # Cleans images without prompt
# port prune - cleans up merged worktrees
port prune # Shows aggregate image estimate
port prune --force --cleanup-images # Non-interactive with images
# port cleanup - cleans up archived branches
port cleanup # Shows aggregate across all branches
port cleanup --cleanup-images # Cleans images for all archived branches
Images default to No because:
--cleanup-imagesThe conservative default prevents accidental removal of shared resources while still providing automatic cleanup of project-specific resources.
port/
├── package.json
├── tsconfig.json
├── eslint.config.ts
├── prettier.config.js
├── src/
│ ├── index.ts # Entry point
│ ├── commands/
│ │ ├── init.ts
│ │ ├── install.ts
│ │ ├── enter.ts
│ │ ├── exit.ts
│ │ ├── shell-hook.ts # Shell integration (bash/zsh/fish)
│ │ ├── up.ts
│ │ ├── down.ts
│ │ ├── run.ts # Host process runner
│ │ ├── remove.ts
│ │ ├── list.ts
│ │ └── status.ts
│ ├── lib/
│ │ ├── config.ts
│ │ ├── git.ts
│ │ ├── compose.ts
│ │ ├── shell.ts # Shell command generation
│ │ ├── traefik.ts
│ │ ├── registry.ts
│ │ ├── hostService.ts # Host service management
│ │ ├── dns.ts
│ │ ├── sanitize.ts
│ │ └── worktree.ts
│ └── types.ts
├── skills/
│ └── port-cli/
│ └── SKILL.md # Installable Agent Skill for coding agents
├── traefik/
│ └── docker-compose.yml
└── README.md
port CLI)See PLAN.md for detailed configuration options and examples.
# Install dependencies
bun install
# Run in development
bun run dev init
# Build
bun run build
# Type check
bun run typecheck
# Format code
bun run format
# Lint
bun run lint
# Test
bun run test
# Run a single integration shard (matches CI sharding)
bunx vitest --shard=1/4
bunx vitest --shard=2/4
bunx vitest --shard=3/4
bunx vitest --shard=4/4
The project includes a Docker container running Ubuntu 24.04 with systemd for testing the CLI in a Linux environment. This is useful for testing DNS configuration and other Linux-specific features.
# Start the container and open a bash shell
make ubuntu
# Stop the container
make down
Once inside the container, you can test the CLI:
# Set up DNS for *.port domains
port install --yes
# Test DNS resolution
dig test.port
Note: The container overrides
/etc/resolv.confto use systemd-resolved for DNS, which allows*.portdomain resolution to work. However, this means the container does not have access to the outside network (e.g.,apt-get updateorcurlto external URLs will fail).
Port isolates worktrees in layered compose files:
docker-compose.yml by default).port/override.yml).port/override.user.yml)Port runs compose with user overrides last so local customization wins:
docker compose -p <project-name> -f docker-compose.yml -f .port/override.yml -f .port/override.user.yml up -d
.port/override.user.yml is generated at runtime from .port/override-compose.yml if that file exists.
Here are all Port-managed overrides/compose controls and why they exist:
| Port-managed change | Why it is necessary |
|---|---|
-p <project-name> (compose flag) | Namespaces compose resources per repo/worktree so similarly named stacks do not collide. |
-f .port/override.yml (compose flag) | Applies Port's deterministic runtime adjustments without mutating your source compose file. |
-f .port/override.user.yml (compose flag, optional) | Applies user-provided overrides rendered from .port/override-compose.yml, after Port defaults, so user rules win. |
services.<name>.ports: !override [] (for services with published ports) | Removes host port binds so two worktrees can both run services that declare the same host ports. |
services.<name>.labels: [...] | Adds Traefik router/service metadata so requests route by hostname (<branch>.port) and service aliases (<service>.<branch>.port) instead of host port ownership. |
services.<name>.networks: [traefik-network] | Ensures Traefik can reach exposed services on the shared network. |
services.<name>.container_name rewrite (only when upstream sets one) | Prevents global Docker container name conflicts when upstream hard-codes a fixed container_name. |
networks.traefik-network.external: true | Connects project services to the globally managed Traefik network instead of creating per-project duplicates. |
Notes:
image, build, environment, volumes, depends_on, or command..port/override-compose.yml is optional and user-editable; if missing, Port skips the user layer.PORT_ROOT_PATH, PORT_WORKTREE_PATH, PORT_BRANCH, PORT_DOMAIN, PORT_HOSTNAME_LABEL, PORT_HOSTNAME, PORT_PROJECT_NAME, PORT_COMPOSE_FILE.Example generated shape:
services:
web:
container_name: my-repo-feature-1-web
ports: !override []
networks:
- traefik-network
labels:
- traefik.enable=true
- traefik.http.routers.feature-1-web-3000.rule=Host(`feature-1.port`)
- traefik.http.routers.feature-1-web-3000.entrypoints=port3000
- traefik.http.routers.feature-1-web-3000.service=feature-1-web-3000
- traefik.http.services.feature-1-web-3000.loadbalancer.server.port=3000
networks:
traefik-network:
external: true
name: traefik-network
MIT
220 commits
2 commits
TypeScript
99.3%
Run 2+ Docker compose worktrees on the same service ports at the same time without conflicts.
Terminal A (feature-1 worktree) | Terminal B (feature-2 worktree) |
|---|---|
|
|
npm serve) with Traefik routing*.port domainsservice.<branch>.portport up# Port is published on npm, but requires Bun at runtime
npm install -g @jdtzmn/port
# or install globally with Bun
bun add -g @jdtzmn/port
port executes with a Bun shebang (#!/usr/bin/env bun), so Bun must be installed and available on PATH even when the package is installed via npm.
Port ships an installable Agent Skill that teaches compatible coding agents the safe Port workflow: setup, entering worktrees, starting services, viewing URLs, running host processes, and cleaning up.
npx skills add jdtzmn/port --skill port-cli -g
# or from a local checkout
npx skills add . --skill port-cli --copy -y
Want a guided workflow in the CLI?
port onboard
port init
This scaffolds .port/config.jsonc, hooks, and templates. Core worktree commands can run without this step.
Create .port/config.jsonc in your project:
{
// Optional, defaults to "port"
"domain": "port",
// Optional, defaults to "docker-compose.yml"
"compose": "docker-compose.yml",
}
port install
Configures your system to resolve your configured wildcard domain (default *.port) to 127.0.0.1.
On macOS, port install runs privileged steps through a centralized elevation helper: it uses the native admin credential dialog when a GUI session is available and falls back to terminal sudo in headless/non-GUI environments.
You can optionally specify a custom IP address:
# Resolve to a specific IP (useful for Docker networks, etc.)
port install --dns-ip 172.25.0.2
# Skip confirmation prompt
port install --yes
# Combine options
port install --yes --dns-ip 192.168.1.100
# Explicit custom domain
port install --domain custom
On Linux systems with systemd-resolved running (most modern Ubuntu/Debian systems), the install command automatically:
systemd-resolved is using port 53dnsmasq on port 5354 to avoid conflictssystemd-resolved to forward your wildcard domain queries to dnsmasqThis "dual-mode" setup allows both services to coexist without conflicts.
Add this to your shell profile so port enter and port exit can change your working directory:
# ~/.bashrc
eval "$(port shell-hook bash)"
# ~/.zshrc
eval "$(port shell-hook zsh)"
# ~/.config/fish/config.fish
port shell-hook fish | source
Without shell integration, port enter and port exit will print a cd command for you to run manually.
Enable tab completion for port commands and options:
# Bash - add to ~/.bashrc
eval "$(port completion bash)"
# Zsh - add to ~/.zshrc
eval "$(port completion zsh)"
# Fish - add to ~/.config/fish/config.fish
port completion fish | source
This enables tab completion for all port commands, options, and branch names.
port feature-1
port enter feature-1
This creates a new worktree and changes into it (with shell integration) or prints the path to cd into.
Use port enter <branch> when your branch name collides with a command (for example status or install).
If a branch and command collide, running port <command> shows a hint to use port enter <branch>.
If the branch is already checked out in another worktree, port enter shows you where it is and asks whether to enter that worktree instead (non-interactive terminals reuse it automatically).
port exit
Returns to the repository root and clears the PORT_WORKTREE environment variable.
port rename feature-new
# or use the shorthand
port mv feature-new
Run this from inside the worktree after stopping its services. It renames the current worktree and branch in place and keeps Port state aligned.
port up [services...]
Starts docker-compose services and makes them available at feature-1.port:PORT.
Omit services... to start everything, or pass specific service names to start a subset and their dependencies.
For services with published ports, the first one is also reachable at service.feature-1.port.
If .port/hooks/post-up.sh is executable, Port runs it after services are up. You can manually
rerun that hook with:
port open
port down [services...]
Stops all services by default, or a selected subset when service names are provided. The selected containers are removed without tearing down the whole worktree.
port run 3000 -- npm run dev
Runs a host process (not in Docker) and routes traffic through Traefik. The command receives the PORT environment variable set to an ephemeral port, while users access it via <branch>.port:3000.
This is useful for:
port status
Shows per-service status grouped by worktree, host services, and Traefik.
Show URLs for services in the current worktree:
port urls
port urls ui-frontend
port urls works in either a worktree or the main repository.
port remove feature-1
# Skip confirmation for non-standard/stale worktree entries
port rm -f feature-1
# Keep the local branch name unchanged
port rm --keep-branch feature-1
# Clean up Docker images without prompting
port rm --cleanup-images feature-1
Stops services, removes the worktree, and soft-deletes the local branch by archiving it under archive/<name>-<timestamp>.
Use --keep-branch to preserve the local branch name.
Docker Cleanup Behavior:
--cleanup-images to clean up images without promptingport cleanup
# Clean up images without prompting
port cleanup --cleanup-images
Shows archived branches created by port remove and asks for confirmation before deleting all of them.
Docker Cleanup Behavior:
--cleanup-images to clean up images without prompting| Command | Description |
|---|---|
port init | Initialize .port/ directory structure |
port onboard | Print recommended workflow and command usage guide |
port install [--dns-ip IP] [--domain DOMAIN] | Set up DNS for wildcard domain (default from config) |
port shell-hook <bash|zsh|fish> | Print shell integration code for automatic cd |
port completion <bash|zsh|fish> | Generate shell completion script for tab completion |
port enter <branch> | Enter a worktree explicitly (including command names) |
port <branch> | Enter a worktree (creates if doesn't exist) |
port exit | Exit the current worktree and return to repo root |
port rename <branch> / port mv <branch> | Rename the current worktree and branch in place |
port up [services...] | Start docker-compose services in current worktree |
port open | Re-run the post-up hook in the current repo/worktree context |
port down [services...] | Stop docker-compose services and host processes selectively |
port run <port> -- <command...> | Run a host process with Traefik routing |
port kill [port] | Stop host services (optionally by logical port) |
port remove <branch> [--force] [--keep-branch] [--cleanup-images] | Remove worktree, archive branch, clean up Docker resources |
port prune [--dry-run] [--force] [--cleanup-images] | Remove worktrees for merged branches, clean up Docker resources |
port cleanup [--cleanup-images] | Delete archived branches and their Docker resources |
port compose <args...> (alias: dc) | Run docker compose with auto -f flags |
port list | Print worktree names, one per line |
port status | Show service status across all worktrees |
port urls [service] | Show service URLs for current worktree |
port uninstall [--yes] [--domain DOMAIN] | Remove DNS configuration for wildcard domain |
port hook [hook-name] [--list] | List or manually run a configured lifecycle hook |
port status and port list may warn when the repo has 10 or more stale Port worktrees:
You have X stale port worktrees. Consider running port prune.
port enter <branch> shows the same warning only when the stale count is extreme (25+), and it never blocks worktree creation.
Port supports executable shell hooks in .port/hooks/:
post-create.sh: runs after a new worktree is created by port enter <branch>post-up.sh: runs after port up [services...] successfully starts servicespre-run.sh: runs before port run <port> -- <command...> starts the host processHooks receive these environment variables:
PORT_ROOT_PATHPORT_WORKTREE_PATHPORT_BRANCHPORT_DOMAINPORT_HOSTNAME_LABEL: sanitized branch name, truncated to 63 characters (the same label Traefik routes on) — use this instead of re-deriving a hostname from PORT_BRANCHPORT_HOSTNAME: PORT_HOSTNAME_LABEL + . + PORT_DOMAINpre-run.sh also receives PORT_LOGICAL_PORT, PORT_ACTUAL_PORT, and PORT_ENV_FILE. Append KEY=VALUE lines to $PORT_ENV_FILE to override environment variables for the command spawned by port run:
if [ -n "${DATABASE_URL:-}" ]; then
echo "DATABASE_URL=${DATABASE_URL/localhost:5432/$PORT_HOSTNAME:5432}" >> "$PORT_ENV_FILE"
fi
Manual hook commands:
port hook --list
port hook post-create
port hook post-up
# shorthand for `port hook post-up`
port open
┌─────────────────────────────────┐
│ CLI Tool: port │
│ (installed globally) │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Your Project: ~/projects/my-app │
│ ├── .port/ │
│ │ ├── config.jsonc │
│ │ └── trees/ │
│ │ ├── feature-1/ │
│ │ └── feature-2/ │
│ └── docker-compose.yml │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Traefik (global) │
│ ~/.port/traefik/ │
│ - Routes by hostname │
│ - Manages all services │
└─────────────────────────────────┘
Multiple worktrees can run simultaneously because:
Example:
# feature-1 worktree
port feature-1
port up
# Available at: feature-1.port:3000
# In another terminal, feature-2 worktree (same ports!)
port feature-2
port up
# Available at: feature-2.port:3000
# No conflicts! Traefik routes both to the same internal port on different containers
Port includes a custom 404 handler that helps you discover what's actually running when you visit an unknown URL.
When you navigate to a URL that doesn't match any running worktree (e.g., http://nonexistent.port:3000), instead of seeing a generic Traefik error, you'll receive a plain-text response showing:
Example responses:
404 - Worktree Not Found
Running worktrees:
feature-1
feature-2
main
Or when nothing is running:
404 - Worktree Not Found
No running worktrees
The handler works by:
traefik.enable=true labelsHost() routing rulesThis makes it easy to:
The port run command enables running non-Docker processes with Traefik routing:
# In .port/trees/feature-1 directory
port run 3000 -- npm run dev
# Service available at http://feature-1.port:3000
# In another terminal, .port/trees/feature-2 directory
port run 3000 -- npm run dev
# Service available at http://feature-2.port:3000
# No port conflicts! Both run simultaneously.
How it works:
PORT=49152 environment variable for the commandfeature-1.port:3000 → localhost:49152Most frameworks (Express, Next.js, Vite, etc.) respect the PORT environment variable automatically.
Port automatically manages Docker resources when removing worktrees or cleaning up archived branches.
Docker resources are cleaned up in two phases with different safety levels:
1. Low-Risk Cleanup (Automatic)
Always runs without prompting for:
These resources are safe to remove because they're project-specific and can be recreated.
2. High-Risk Cleanup (Opt-In)
Prompts before removing:
Images require confirmation because:
node:20, postgres:15) are often sharedInteractive Mode (default when running commands manually):
Clean up 3 image(s) (150.0 MB)? (y/N)Non-Interactive Mode (CI, scripts, --force flag):
--cleanup-images flag to clean up imagesport remove feature-1 --cleanup-imagesAll three cleanup commands follow the same safety pattern:
# port remove - cleans up single worktree
port remove feature-1 # Prompts for images (default No)
port remove feature-1 --cleanup-images # Cleans images without prompt
# port prune - cleans up merged worktrees
port prune # Shows aggregate image estimate
port prune --force --cleanup-images # Non-interactive with images
# port cleanup - cleans up archived branches
port cleanup # Shows aggregate across all branches
port cleanup --cleanup-images # Cleans images for all archived branches
Images default to No because:
--cleanup-imagesThe conservative default prevents accidental removal of shared resources while still providing automatic cleanup of project-specific resources.
port/
├── package.json
├── tsconfig.json
├── eslint.config.ts
├── prettier.config.js
├── src/
│ ├── index.ts # Entry point
│ ├── commands/
│ │ ├── init.ts
│ │ ├── install.ts
│ │ ├── enter.ts
│ │ ├── exit.ts
│ │ ├── shell-hook.ts # Shell integration (bash/zsh/fish)
│ │ ├── up.ts
│ │ ├── down.ts
│ │ ├── run.ts # Host process runner
│ │ ├── remove.ts
│ │ ├── list.ts
│ │ └── status.ts
│ ├── lib/
│ │ ├── config.ts
│ │ ├── git.ts
│ │ ├── compose.ts
│ │ ├── shell.ts # Shell command generation
│ │ ├── traefik.ts
│ │ ├── registry.ts
│ │ ├── hostService.ts # Host service management
│ │ ├── dns.ts
│ │ ├── sanitize.ts
│ │ └── worktree.ts
│ └── types.ts
├── skills/
│ └── port-cli/
│ └── SKILL.md # Installable Agent Skill for coding agents
├── traefik/
│ └── docker-compose.yml
└── README.md
port CLI)See PLAN.md for detailed configuration options and examples.
# Install dependencies
bun install
# Run in development
bun run dev init
# Build
bun run build
# Type check
bun run typecheck
# Format code
bun run format
# Lint
bun run lint
# Test
bun run test
# Run a single integration shard (matches CI sharding)
bunx vitest --shard=1/4
bunx vitest --shard=2/4
bunx vitest --shard=3/4
bunx vitest --shard=4/4
The project includes a Docker container running Ubuntu 24.04 with systemd for testing the CLI in a Linux environment. This is useful for testing DNS configuration and other Linux-specific features.
# Start the container and open a bash shell
make ubuntu
# Stop the container
make down
Once inside the container, you can test the CLI:
# Set up DNS for *.port domains
port install --yes
# Test DNS resolution
dig test.port
Note: The container overrides
/etc/resolv.confto use systemd-resolved for DNS, which allows*.portdomain resolution to work. However, this means the container does not have access to the outside network (e.g.,apt-get updateorcurlto external URLs will fail).
Port isolates worktrees in layered compose files:
docker-compose.yml by default).port/override.yml).port/override.user.yml)Port runs compose with user overrides last so local customization wins:
docker compose -p <project-name> -f docker-compose.yml -f .port/override.yml -f .port/override.user.yml up -d
.port/override.user.yml is generated at runtime from .port/override-compose.yml if that file exists.
Here are all Port-managed overrides/compose controls and why they exist:
| Port-managed change | Why it is necessary |
|---|---|
-p <project-name> (compose flag) | Namespaces compose resources per repo/worktree so similarly named stacks do not collide. |
-f .port/override.yml (compose flag) | Applies Port's deterministic runtime adjustments without mutating your source compose file. |
-f .port/override.user.yml (compose flag, optional) | Applies user-provided overrides rendered from .port/override-compose.yml, after Port defaults, so user rules win. |
services.<name>.ports: !override [] (for services with published ports) | Removes host port binds so two worktrees can both run services that declare the same host ports. |
services.<name>.labels: [...] | Adds Traefik router/service metadata so requests route by hostname (<branch>.port) and service aliases (<service>.<branch>.port) instead of host port ownership. |
services.<name>.networks: [traefik-network] | Ensures Traefik can reach exposed services on the shared network. |
services.<name>.container_name rewrite (only when upstream sets one) | Prevents global Docker container name conflicts when upstream hard-codes a fixed container_name. |
networks.traefik-network.external: true | Connects project services to the globally managed Traefik network instead of creating per-project duplicates. |
Notes:
image, build, environment, volumes, depends_on, or command..port/override-compose.yml is optional and user-editable; if missing, Port skips the user layer.PORT_ROOT_PATH, PORT_WORKTREE_PATH, PORT_BRANCH, PORT_DOMAIN, PORT_HOSTNAME_LABEL, PORT_HOSTNAME, PORT_PROJECT_NAME, PORT_COMPOSE_FILE.Example generated shape:
services:
web:
container_name: my-repo-feature-1-web
ports: !override []
networks:
- traefik-network
labels:
- traefik.enable=true
- traefik.http.routers.feature-1-web-3000.rule=Host(`feature-1.port`)
- traefik.http.routers.feature-1-web-3000.entrypoints=port3000
- traefik.http.routers.feature-1-web-3000.service=feature-1-web-3000
- traefik.http.services.feature-1-web-3000.loadbalancer.server.port=3000
networks:
traefik-network:
external: true
name: traefik-network
MIT
220 commits
2 commits
TypeScript
99.3%