One CLI, one URL per git worktree. No per-project DNS or proxy plumbing.
Pier gives every git worktree a stable URL on a local dev TLD. Bootstrap traefik + dnsmasq + host DNS once, then pier up per worktree returns a clickable URL. Designed for the agentic workflow: each agent works on its own worktree, deploys to its own ephemeral env, returns a URL.
Pier is local by default. It requires Docker, but it does not require Tailscale or Headscale. LAN and Tailscale access are optional choices offered during installation; the standard local installation binds Pier to the development machine.
$ pier worktree add ../myapp-feat-x -b feat/x
$ cd ../myapp-feat-x
$ pier up
→ http://feat-x.myapp.testArchitecture and roadmap live in DESIGN.md. This README is the practical "how do I use it" guide.
Phase 1 MVP and most of Phase 2 are shipped. Compose adapter, local-first install wizard, BYO-traefik, optional LAN/Tailscale access, selective LAN sharing, optional Headscale split-DNS patching, dashboard/API server, doctor, materialize, worktree wrapper, and AI agent skill install — all in. Backlog: MCP shim, dockerfile adapter (synthesized compose), gc, watch, macOS DNS support. See DESIGN.md §8.
Pier is intentionally docker-coupled — even projects that aren't otherwise containerized declare a minimal docker-compose.dev.yml. See the snippet in Per-repo setup below.
They overlap, but solve different problems. Pier turns local git worktrees and Compose stacks into isolated, reachable preview environments. Kubernetes is a general-purpose platform for running containerized workloads across a cluster.
| Pier | Kubernetes | |
|---|---|---|
| Best fit | Local development and agent previews on one Docker host | Shared or production environments, especially when Kubernetes is already in use |
| Advantages | Compose-native; one command and URL per worktree; includes local DNS, data materialization, hooks, and cleanup | Scheduling, scaling, self-healing, rolling deployments, access controls, and a large production ecosystem |
| Trade-offs | Development-only; no multi-node scheduling, high availability, or production parity with Kubernetes | More infrastructure and manifests; image builds, external DNS/routing, and git-worktree lifecycle need separate tooling |
Most Pier behavior can be recreated around Kubernetes with namespaces, Services/Gateway, DNS, storage, image-building, and custom automation. Kubernetes alone does not provide Pier's git-aware local workflow. Choose Pier for the shortest path from a Compose worktree to a preview URL; choose Kubernetes when cluster-level capabilities or production parity matter more than local simplicity.
curl -fsSL https://raw.githubusercontent.com/dguerizec/pier/main/install.sh | shThe script picks the right archive from the latest GitHub release, verifies its
sha256 against the published checksums.txt, and installs into ~/.local/bin
(or falls back to /usr/local/bin via sudo). Set PIER_VERSION=v0.x.y to pin a
specific release, or PIER_INSTALL_DIR=/some/path to override the destination.
Audit the script before piping it to a shell — it's a plain POSIX shell script
in this repo at install.sh.
git clone https://github.com/dguerizec/pier.git
cd pier
go build -o ~/.local/bin/pier ./cmd/pier
pier --versionGo 1.26+ required. Homebrew tap (brew install dguerizec/pier/pier) will follow.
pier installOn the first installation, the wizard starts with the safe local-only choice:
URL reachability
> Local only (recommended) — reachable from this machine
LAN (optional) — reachable from devices on your local network
Tailscale (optional, when detected) — reachable from your tailnet
LAN is always offered. The Tailscale choice appears only when an active Tailscale IPv4 is detected. Headscale integration is offered later only when Headscale is present and Tailscale access was selected.
The wizard also detects an existing dockerized Traefik and can use its network
instead of spawning pier-traefik. With the default local choice, Pier runs
Traefik + dnsmasq on loopback and installs a systemd-resolved route for .test.
Output looks like this:
$ pier install
Detected:
no optional integrations detected; local mode is ready
Plan:
--mode local --tld test
Apply this plan? [Y/n]
Pass -y to accept the local default silently on that first installation.
Once config.toml exists, plain pier install reuses the active mode, TLD,
bind/answer IPs, DNS policy, and integrations without reopening the wizard;
-y cannot silently reset an existing Tailscale or LAN installation. Use
pier install --reconfigure when you explicitly want to choose a different
installation shape; unchanged TLD and manual-DNS settings are retained.
Explicit shape flags such as --mode, --bind-ip, and
--answer-ip still skip wizard planning. --tld can customize a first-time or
explicitly reconfigured wizard; changing an existing TLD requires
--reconfigure so related DNS integrations are reconsidered.
The infrastructure installer also writes the bundled AI-agent skill to
~/.agents/skills/pier. To update only that skill after upgrading the binary,
without touching Traefik, dnsmasq, or host DNS, run:
pier skill install --yesInteractive first-time installation also asks for your default
pier worktree add <name> directory.
pier uninstall reverses everything (containers, network, host DNS drop-in, config dir). BYO mode leaves the user's traefik + network alone. The pier binary itself stays in place — pass --purge to also delete it (pier uninstall --purge). --purge declines when the binary lives under a brew prefix or system path; let the package manager remove it in that case.
pier serve installpier serve exposes the dashboard at / and the REST API at /api/v1/. pier serve install installs it as a systemctl --user unit and publishes the dashboard at pier.<tld> by default, which is covered by the same split-DNS wildcard as workloads.
If Headscale has extra_records_path configured, pier serve install can place the dashboard under the Headscale base_domain instead:
pier serve install --dashboard-fqdn pier.nebulaThat records adapter is for the dashboard hostname only. Workload URLs still use Pier's TLD and split-DNS route.
$ cd ~/dev/myapp
$ pier init
Detected: docker-compose.dev.yml
? Project name [myapp]:
? Base domain [myapp.{pier.tld}]:
? Compose service: web
? Service port: 3000
? Share manifest with team (commit to git)? [Y/n]:
✓ .pier.toml writtenDefaults to committing the manifest so secondary worktrees get it for free via git checkout. Pass --private to gitignore it instead.
pier init is non-interactive when you pass the fields:
pier init -y --service web --port 3000[project]
name = "myapp"
base_domain = "myapp.{pier.tld}"
[stack]
kind = "compose" # compose only today
file = "docker-compose.dev.yml"
service = "web"
port = 3000
match_host_uid = true # opt-in: container runs as host UID/GID
# (resolves EACCES on bind-mounts when
# the image uses distroless/nonroot)
# applies to every exposed service
[[expose]]
service = "web"
port = 3000
preserve_ports = [2223] # optional: keep selected TCP host
# bindings from compose (for SSH,
# databases, or other non-HTTP TCP)
[service.worker]
match_host_uid = true # same override for one compose service,
# exposed or not
[materialize]
symlinks = [".env", "secrets/"] # symlinked from primary on first up
snapshots = ["data-dev/"] # copied per worktree (own mutable copy).pier.local.toml next to it is always gitignored for per-developer overrides.
[env.<service>] tables merge variable by variable with the tracked manifest;
local values win without hiding unrelated tracked variables.
preserve_ports keeps a matching Compose ports: entry for protocols that cannot
go through Traefik's HTTP routing. It does not allocate a different host port by
itself; make the Compose published port configurable when multiple worktrees must
run at once:
services:
web:
ports:
- "${SSH_HOST_PORT:-2223}:2223"Then set SSH_HOST_PORT=2224 in that worktree's local .env. The manifest can
stay shared as preserve_ports = [2223] because pier matches either side of the
Compose binding and keeps the resolved 2224:2223 entry.
For values that must be computed per worktree, hooks.resolve_values prints a
JSON object before pier performs the final manifest parse:
[stack.env]
PICKATUBE_OAUTH_RELAY_PORT = "{value.oauth_callback_port}"
[[expose]]
service = "web"
port = 3000
preserve_ports = [{value.oauth_callback_port}]
[env.web]
OAUTH_CALLBACK_URL = "http://127.0.0.1:{value.oauth_callback_port}/callback"
[hooks]
resolve_values = "./scripts/resolve-pier-values"[stack.env] maps the resolved value to a project-owned variable passed to
Docker Compose. The source Compose file can remain Pier-agnostic and retain its
vanilla default:
services:
web:
ports:
- "${PICKATUBE_OAUTH_RELAY_PORT:-8765}:8765"This mechanism is not port-specific: [stack.env] participates in normal
Compose interpolation anywhere in the source model, while [env.<service>]
injects variables into a container. Each returned scalar is still exported as
PIER_VALUE_<UPPERCASE_NAME> for hooks and direct Compose use. Pier caches the
resolved object in the worktree's .pier/resolved-values.json; the hook owns
allocation policy and collision handling.
Pier requires a docker-compose.dev.yml even when your project isn't otherwise containerized — same execution path on every host, no host port/PID/log juggling. For Python / Node / Rust projects the file is ~10 lines:
# docker-compose.dev.yml
services:
app:
image: python:3.13-slim # or node:20, rust:1, etc.
working_dir: /app
volumes:
- ./:/app
command: sh -c "pip install uv && uv sync && uv run python run.py"
ports:
- "${PORT:-3000}:3000"Adjust the image, command, and port for your stack. pier init then detects it like any other compose file.
# spawn an isolated environment for a feature branch
pier worktree add ../myapp-feat-x -b feat/x
cd ../myapp-feat-x
# materialize already ran via worktree add; just bring it up
pier up
→ http://feat-x.myapp.test
# after changing source, Compose, .pier.toml, or .pier.local.toml
pier up # build, reconcile, and wait for readiness
# inspect
pier ls
pier ls --wide # include each workload's worktree path
pier url # current worktree URL
pier logs -f # tail logs
# tear down
pier down # stop, keep snapshots
pier down --purge # also wipe snapshot copies (data-dev/)
# clean cleanup
pier worktree rm ../myapp-feat-x --purgepier up is idempotent: it builds images while the current workload keeps
running, reconciles changed services, removes Compose orphans, and waits for
services to be running or healthy before returning. The default readiness
deadline is two minutes; override it with --wait-timeout 5m.
Exposed services keep their source Compose networks. When a service relies on
Compose's implicit default network, Pier declares it alongside the shared
Traefik network so service-to-service DNS continues to work.
pier down targets the last configuration Pier successfully applied, not a
hypothetical workload reconstructed from the current manifest. It therefore
still stops the right Compose project after changing project.name,
stack.file, the branch slug, or even when the current manifest no longer
parses. Applied state is local, mode 0600, and lives under .pier/applied/.
Slug is derived from the branch name (DESIGN §5.1): feat/foo-bar → foo-bar, main → main. Override with --slug or PIER_SLUG=....
Pier is local unless the user explicitly chooses a broader reach:
| Wizard choice | Required software/network | URL example | DNS routing |
|---|---|---|---|
| Local only (default) | Docker | feat-x.myapp.test |
pier-dnsmasq on 127.0.0.1, local systemd-resolved route |
| LAN (optional) | A trusted LAN with an assigned IPv4 | feat-x.myapp.test |
pier-dnsmasq on the chosen LAN IP; clients route .test to that IP |
| Tailscale (optional) | Active Tailscale client | feat-x.myapp.test |
pier-dnsmasq on the detected Tailscale IP; tailnet split-DNS |
Headscale is never required. When detected for the selected Tailscale option,
Pier can patch its split-DNS configuration. If that Headscale instance already
uses extra_records_path, Pier can also use it for an optional dashboard FQDN;
workload URLs do not use that records adapter.
pier doctor # diagnose infra + state
pier doctor --fix # restart down containers, prune dead workload rowsdoctor adapts to the active mode: it skips pier-traefik checks in BYO mode, warns about stale workload rows, and reports legacy system-level pier.service units left behind by older installs.
For a LAN, select the LAN option during pier install, then route the Pier TLD
on each client to the chosen server address:
pier client add --tld test --resolver 192.168.1.42For Tailscale, select the Tailscale option when it is offered. Tailnet peers can then reach Pier through split-DNS:
# on a peer machine
pier client tailscale # prints exact split-DNS / extra_records snippets
# for both Tailscale.com and headscale config.yamlIf Headscale is detected, the wizard can auto-apply its split-DNS rule when the
TLD is outside base_domain. extra_records_path is only needed when you
choose a dashboard FQDN under the Headscale base_domain.
Test peer resolution with resolvectl query <slug>.<base_domain> rather than dig. Dig may bypass systemd-resolved per-link routing on Linux and produce false negatives.
Pier installs can publish individual workload hosts on a LAN without exposing
Pier's wildcard DNS or every active worktree. This is the recommended approach
when a local-first install only needs to share a few URLs. Server mode also
works when its main proxy is bound to a distinct, specific address rather than
0.0.0.0:
cd /path/to/jobo
pier share add backend
pier share add '*' --persistWith no host or address flags, share add interactively asks which exact hosts
to publish and which assigned LAN address to bind. In scripts, pass
--interface enp3s0 or --bind-ip 192.168.1.42. Shell globs must be quoted.
They are expanded once against the current worktree's known URLs; Pier writes
exact Traefik Host rules, so a later admin service is not silently shared.
Session shares survive pier down / pier up but disappear when their
dedicated LAN gateway restarts. --persist saves the route and gives the
gateway an unless-stopped restart policy so it returns after a machine
restart. This persists the route, not the Compose workload: a stopped workload
returns an unavailable response until pier up (or its own Compose restart
policy) brings it back.
pier share list
pier share hosts # paste-ready /etc/hosts lines
pier share url --default # exactly one entry-point URL
pier share url --all
pier share remove backendThe client needs no router or DNS change. Copy the pier share hosts output
into its /etc/hosts; the Pier host firewall must allow inbound TCP/80 on the
selected interface. Sharing is hostname-selective, not user authentication:
any LAN peer that knows a shared hostname can request it.
- Linux only for host DNS auto-config in MVP. macOS support is on the v0.2 list.
- No TLS — HTTP only on the reserved
.testTLD, or for the dashboard under an optional tailnet base domain when configured. mkcert + Let's Encrypt is post-v1. - Local by default; trusted networks only when shared. LAN or Tailscale server mode exposes every Pier URL to peers that can reach the selected address. Use
pier sharefor a finite LAN allowlist. A[security].basic_authmiddleware is a post-MVP nice-to-have. - Compose only. Even raw-process stacks (uv/npm/cargo) declare a
docker-compose.dev.yml— see the minimal snippet below. The dockerfile adapter (which synthesizes a compose file from a Dockerfile) lands in Phase 3.
Pier was originally created by @LeoPartt.
Pier is built around a sharp layer separation (CLI / infra / workload / dashboard — see DESIGN.md §4). Adding a new adapter is internal/adapter/<kind>.go implementing the adapter interface. Adding a new infra component goes in internal/infra/. The CLI surface in internal/cli/ should stay a thin shim over those packages.
Run go test ./.... I/O-heavy paths in infra and cli have targeted unit tests, but still need smoke testing on real Linux hosts.
MIT. See LICENSE.
