Cloud tunnels

A Cloud tunnel (<name>--<username>.tunnel.portzero.cloud) reaches your process or container from the LAN and the internet, not just this machine. It requires portzero login and a subscription. See Tunnels & PZ_TUNNEL for the full naming syntax.

portzero login
PZ_TUNNEL=myapp--alice.tunnel.portzero.cloud python3 -m http.server 0

Open https://myapp--alice.tunnel.portzero.cloud — TLS is handled automatically.

Search engines never index a Cloud tunnel

Every response on *.tunnel.portzero.cloud carries X-Robots-Tag: noindex, nofollow, and https://<your-tunnel>/robots.txt answers with a blanket Disallow: /. Crawlers that honour either signal will leave your tunnel out of search results, so a link you shared for an afternoon does not become a permanent search hit.

Two things this is not. It is not access control — anyone who has the URL can still open it. See Restricting who can reach a tunnel below to actually restrict who gets in. And it is not a guarantee about badly-behaved crawlers, which are free to ignore both signals.

Two consequences worth knowing:

  • /robots.txt is reserved on tunnel hosts, alongside /__portzero/*, /__portzero_auth/*, and /.well-known/portzero-health. A robots.txt your own app serves is not reachable through a tunnel on the shared apex; requests for that path are answered by Port Zero.
  • An X-Robots-Tag your app sets on the shared apex is replaced, not merged — including index. If you are serving something you want indexed, run it on a custom domain (below), where Port Zero adds neither the header nor the robots.txt.

Restricting who can reach a tunnel

A Cloud tunnel is public by default: anyone with the URL can open it. Three controls narrow that, set per tunnel in the dashboard under Access control, and all three are enforced at the edge before your process sees the request.

HTTP Basic auth. A username and password. The password itself is never stored — we keep a SHA-256 of user:password and the edge recomputes it from the incoming Authorization header.

An IP allowlist. Comma-separated CIDRs or bare IPs. Anything not matching gets 403. If the client’s address can’t be determined, the request is denied rather than allowed.

Visitor auth. Require a verified identity — an OIDC provider or an emailed code — before the request is proxied, and pass the verified email through to your app as X-PortZero-User. Any X-PortZero-* header a client sends is stripped first, so it can’t be spoofed.

One path is always exempt: /.well-known/portzero-health. That is what lets portzero wait confirm liveness without credentials, and it never reaches your process.

Letting a webhook sender through

Visitor auth is the problem case for webhooks: a webhook can’t accept an emailed invite or complete a login redirect. An IP allowlist is the way through, but pasting a provider’s ranges by hand means the list quietly rots and deliveries start failing as 403s the provider reports back to you as “your endpoint is down”.

So you can select a provider by name instead, and we refresh its ranges from the provider’s own published endpoint:

ProviderSource
Stripestripe.com/files/ips/ips_webhooks.json
GitHubapi.github.com/meta (the hooks set)

Selected provider ranges are added to your hand-entered allowlist, so you can mix both.

Twilio and Shopify are deliberately absent. Neither publishes a usable list — Twilio’s pool is, in their words, highly dynamic and spread across a very large range, and Shopify declines on purpose so merchants aren’t broken by a rotation. Both recommend verifying the request signature instead. Offering a preset for either would mean shipping a list we invented, which would fail closed the first time it rotated. For those senders, verify X-Twilio-Signature or X-Shopify-Hmac-SHA256 in your own app — and if the tunnel also requires visitor auth, declare their path exempt (below) so the request reaches your app at all.

Letting a sender through visitor auth specifically

An IP allowlist and visitor auth are two different gates, and a webhook sender fails the second one even if it passes the first: it can’t click an emailed sign-in link or complete an OIDC redirect. If the tunnel requires visitor auth, give the sender’s path an exemption under Access control → Skip sign-in for these paths:

/webhooks/stripe
/webhooks/*

One per line — an exact path, or a prefix ending in /* that also covers everything under it. This is the mechanism for any automated caller that can’t sign in, not just Stripe and GitHub — it’s what makes Twilio and Shopify usable on a tunnel that requires visitor auth, since neither can be admitted by IP range.

Exemption only lifts the visitor-auth check. The review gate, the pause gate, and any IP allowlist or Basic auth you’ve also set still apply to that path — so pairing an exemption with an allowlist (or the provider’s signature check in your own app) is how you narrow “logged-in humans only, except this one automated path” down further.

If an allowlist has no ranges, nothing gets in

Worth knowing before you rely on it: an allowlist that is configured but currently resolves to nothing denies every request. It does not fall back to allowing everyone.

That state is reachable if you select a provider whose ranges we have not managed to fetch. Saving such a selection is rejected up front — with an explanation, rather than a tunnel that mysteriously 403s — and if a fetch fails later we keep serving the last ranges we successfully retrieved rather than emptying the list. The health path above stays exempt throughout, so you can still tell a fetch problem from a dead tunnel.

Custom domains

Availability: custom domains are still rolling out and are off by default. If your dashboard has no Custom Domains page, the feature is not on for your account yet and the commands below return “custom domains aren’t available on this account” — nothing is wrong with your plan or your DNS. Email support@portzero.net to have it switched on. Everything else on this page works regardless.

A wildcard custom domain lets a Cloud tunnel run on a domain you own (e.g. main.staging.acme.com) instead of the shared tunnel.portzero.cloud apex:

portzero domains add '*.staging.acme.com'

This prints two DNS records to create: a CNAME that proves ownership of the parent domain and delegates certificate issuance to Port Zero (no DNS credentials are ever shared), and a CNAME that routes traffic. Once verified —

portzero domains verify '*.staging.acme.com'

— any name under that subtree is routable with one auto-renewed wildcard certificate, and dotted/nested names are allowed there (unlike the shared apex, which stays single-label). portzero domains list and portzero domains remove round out the set. The same flow is available in the dashboard under Custom Domains.

CI and scripts

These commands let test processes and CI jobs discover a tunnel’s URL and gate on its readiness — the same way locally and in GitHub Actions. A tunnel’s domain is its only identity; every command below takes that domain.

portzero url <tunnel-domain>

Prints the resolved URL for a tunnel and nothing else, so it’s safe to capture in a script:

BASE_URL=$(portzero url web.myapp.portzero.local)
# → http://web.myapp.portzero.local

If the tunnel isn’t currently discovered, the command writes an error to stderr (listing the tunnels it does know about) and exits non-zero, so a failed lookup never silently yields an empty BASE_URL.

portzero env and portzero env --github

Prints an export line for every discovered tunnel:

eval "$(portzero env)"
echo "$PZ_URL_WEB_MYAPP_PORTZERO_LOCAL"    # → http://web.myapp.portzero.local

The variable name is PZ_URL_ followed by the domain, upper-cased, with every non-alphanumeric character replaced by _. Inside a GitHub Actions job, portzero env --github appends the same pairs to $GITHUB_ENV, exporting them to later steps:

- run: docker compose up -d
- run: portzero env --github        # exports PZ_URL_* to later steps
- run: npx playwright test          # reads process.env.PZ_URL_*

portzero wait <tunnel-domain> [--healthy] [--timeout <secs>]

Blocks until the tunnel is up, then exits 0 — the readiness gate for smoke tests and Playwright webServer blocks, for both Local and Cloud tunnels:

docker compose up -d
portzero wait web.myapp.portzero.local            # up = discovered & routable
portzero wait web.myapp.portzero.local --healthy  # also polls the health path
portzero wait web.myapp.portzero.local --timeout 120

--healthy polls the endpoint’s PZ_HEALTH_PATH (see Tunnels & PZ_TUNNEL) if it declared one, otherwise /. --timeout caps the wait (default 60s); on timeout the command exits non-zero explaining what it was still waiting for.

// playwright.config.ts
export default defineConfig({
  webServer: {
    command:
      'docker compose up -d && portzero wait web.myapp.portzero.local --healthy',
    url: 'http://web.myapp.portzero.local',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
  use: { baseURL: 'http://web.myapp.portzero.local' },
});

The identical webServer block runs in local dev and in CI — that’s the point.

The local view vs. app.portzero.net

If you compare the “Cloud tunnels” section of the PortZero app against the dashboard at app.portzero.net, the lists and counts sometimes don’t match. This is expected — the two views read from different sources and track different things.

  • The local view shows the cloud domains this daemon discovered on this machine, plus whether this daemon’s connection to the cloud edge is currently open.
  • The dashboard shows every route the cloud has accepted for your account, from every machine you’ve logged in from, and labels each Online, Idle, or Offline based on how recently it saw traffic:
    • less than 2 minutes since last seen → Online
    • less than 10 minutes → Idle
    • 10+ minutes with no traffic → Offline

A tunnel’s row stays on the dashboard (counted as “Active”) until its connection actually closes — so an “Offline” label doesn’t necessarily mean the process died, only that it’s been quiet. Routes are removed, not marked offline: when the local daemon stops seeing the original process, or the cloud edge detects the connection closed, the row is deleted.

Things worth knowing when the two disagree:

  • Multiple machines. The dashboard aggregates every machine you’ve logged in from; the local view shows only this one.
  • Registration rejected. A route can appear locally while the cloud shows an error or a plan-limit message — check the local view’s connection line for that message.
  • Timing. Both sides usually agree within a few seconds of a process starting or stopping, but “Active” on the dashboard means a database row exists, not that the tunnel is receiving traffic right now.

See also

  • Review apps — per-PR preview environments built on tunnel names.
  • Review records — tie reviewer feedback pinned on a tunnel to the commit that fixes it.