Tunnels & PZ_TUNNEL
PZ_TUNNEL is the single environment variable that opts a process or Docker container
into Port Zero. Set it, bind to port 0, and the daemon does the rest.
The full-domain rule
The value must be a full domain name, including its suffix. Nothing is ever appended implicitly — the daemon takes the value verbatim.
PZ_TUNNEL=hello.portzero.local # valid (Local tunnel)
PZ_TUNNEL=web--alice.tunnel.portzero.cloud # valid (Cloud tunnel)
PZ_TUNNEL=hello # not valid — no suffix
The suffix decides the target
| Suffix | Target |
|---|---|
.portzero.local | Local tunnel — served entirely on this machine, free and open source. |
<name>--<username>.tunnel.portzero.cloud | Cloud tunnel — reachable from the internet, requires portzero login and a subscription. |
See How it works for what happens on each path, and Cloud tunnels for the hosted product.
Must be set BEFORE launch
The daemon reads each process’s environment from outside the process — the
kernel’s frozen copy of the environment captured at process start (/proc/<pid>/environ
on Linux, sysctl KERN_PROCARGS2 on macOS, the process’s PEB on Windows). Setting the
variable inside a running process (Python os.environ[...], Node
process.env[...], Go os.Setenv(...)) only updates the in-process copy — the
daemon never sees it, and discovery silently fails with no error.
Correct ways to set it before launch:
# direnv (recommended): export in .envrc, then `direnv allow`
export PZ_TUNNEL=hello.portzero.local
# shell one-off
export PZ_TUNNEL=hello.portzero.local && python3 server.py
# docker (passed at container start)
docker run -e PZ_TUNNEL=hello.portzero.local -p 0:8080 myimage
Children inherit the variable from their parent shell or launcher, so the actual listening process is discovered even when the tag was applied a level up.
Bind to port 0
Always bind your listener to port 0 so the OS assigns a free port at random — this is
what makes port conflicts disappear across projects, shells, and machines. The daemon
discovers the real port; you never hardcode it.
Template variables
The value can contain placeholders, resolved automatically at runtime, so parallel copies of a project each get a distinct, stable URL:
| Placeholder | Resolves to |
|---|---|
{service} | Service or executable name |
{project} | Git repository directory name |
{branch} | Current git branch |
{worktree} | Git worktree name (falls back to {branch}) |
{user} | Your OS login username |
{machine} | Hostname |
{uid} | Short identifier from your account (first 8 characters of your account UUID; falls back to {user} when not logged in) |
{username} | Your portzero.cloud account username. Recommended as the trailing -- segment (the namespace) of the single label before .tunnel. Falls back to {uid} → {user} |
PZ_TUNNEL="{project}-{branch}--{username}.tunnel.portzero.cloud" python3 -m http.server 0
# → e.g. myapp-main--alice.tunnel.portzero.cloud
Note the -- before {username}: on the shared apex the name and the username
namespace are joined with a double hyphen, not a dot (see below).
Run a different branch in a separate worktree and it gets its own stable URL at the same time, with no conflict.
Single-label names on the shared cloud domain
On the shared tunnel.portzero.cloud apex a tunnel name stays a single DNS label.
Express hierarchy with a double hyphen -- inside that label, not with extra dots:
PZ_TUNNEL=pr-142--myapp--alice.tunnel.portzero.cloud
_ is not usable (invalid in hostnames, banned in certificate SANs), and a segment
must not contain an ambiguous internal -- (no leading/trailing hyphen inside a
---separated part). Dotted/nested names are supported only under a verified custom
domain that you own.
Companion environment variables
These optional variables are read from the same process/container environment as
PZ_TUNNEL, and like it, must be set before launch:
| Variable | Purpose |
|---|---|
PZ_TUNNEL_HTTP_PORT | Choose which HTTP port to forward when a process listens on more than one: an explicit port, or CHOOSE_LOWEST (default) / CHOOSE_HIGHEST. |
PZ_TUNNEL_PORTS | Extra raw port mappings for non-HTTP forwarding: local:tunnel[;local:tunnel...] (e.g. 9222:9222). |
PZ_HEALTH_PATH | HTTP path that returns 2xx once the endpoint is ready to serve traffic (e.g. /health). Bare paths are rooted automatically. |
PZ_HEALTH_PATH is stored on the discovered tunnel and shown in the HEALTH column of
portzero status. portzero wait <domain> --healthy polls this path until it returns
2xx, which is how CI and Playwright webServer blocks gate on real readiness rather
than mere port-up — see Dev-to-production commands
for the full command set.