One command to expose a local service through Cloudflare Zero Trust.
zt up portainer --docker --allow [email protected]
# → https://portainer.yourdomain.com (ZT-protected, live in ~15s)zt up <name> <port> automatically:
- Creates a Cloudflare Tunnel
- Configures ingress rules
- Upserts a CNAME DNS record (replaces any conflicting record)
- Creates a Zero Trust Access application with an access policy
- Installs and starts a systemd (Linux), LaunchAgent (macOS), or Task Scheduler (Windows) service
- Saves state locally
zt down <name> attempts to remove all created resources.
- A domain on Cloudflare
cloudflared≥ 2023.x installed and in PATH- A Cloudflare API token with the following permissions:
Account / Cloudflare Tunnel / EditZone / DNS / EditAccount / Access: Apps and Policies / Edit
zt drives cloudflared but doesn't install or manage it — use your platform's
package manager so future upgrades are a normal upgrade/update, not a manual
re-download:
| Platform | Install | Upgrade |
|---|---|---|
| macOS (Homebrew) | brew install cloudflared |
brew upgrade cloudflared |
| Debian/Ubuntu (apt) | see Cloudflare's apt repo setup | sudo apt update && sudo apt install --only-upgrade cloudflared |
| Other Linux / binary | Releases | re-download and replace the binary |
| Windows | Releases | re-download and replace the binary — see Windows support |
zt doctor checks the installed version and, if it's outdated, prints the
right upgrade command for how it detects cloudflared was installed
(Homebrew/apt) or falls back to the releases link otherwise.
- Cloudflare dashboard → My Profile → API Tokens → Create Token
- Use Custom token, add the permissions above
- Set Account Resources → your account
- Set Zone Resources → your domain
curl -fsSL https://raw.githubusercontent.com/casablanque-code/cfzt/main/install.sh | bashDownloads the correct binary for your OS and architecture, verifies the SHA-256 checksum, and places it in /usr/local/bin/zt.
go install github.com/casablanque-code/cfzt/cmd/zt@latestgit clone https://github.com/casablanque-code/cfzt
cd cfzt
go build -o zt ./cmd/zt
sudo mv zt /usr/local/bin/brew tap casablanque-code/cfzt https://github.com/casablanque-code/cfzt
brew install ztThe formula lives in this repo's Formula/ folder and updates itself automatically on every release (same mechanism as the Scoop bucket below). brew upgrade zt picks up new versions.
Download from Releases and place in your PATH. Each release includes .sha256 checksum files and a combined checksums.txt.
scoop bucket add cfzt https://github.com/casablanque-code/cfzt
scoop install cfzt/ztThe bucket lives in this repo's bucket/ folder and updates itself automatically on every release. To pick up a new version, run scoop update (no arguments) first — that's what actually git pulls the bucket repos — then scoop update zt. Running scoop update zt on its own can still report the old version if the bucket itself hasn't been refreshed yet.
See Windows support for what currently works — this
installs the binary and adds it to your user PATH safely:
$dest = "$env:LOCALAPPDATA\zt"
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$exe = "$dest\zt.exe"
Invoke-WebRequest "https://github.com/casablanque-code/cfzt/releases/latest/download/zt-windows-amd64.exe" -OutFile $exe
Invoke-WebRequest "https://github.com/casablanque-code/cfzt/releases/latest/download/zt-windows-amd64.exe.sha256" -OutFile "$exe.sha256"
$expected = (Get-Content "$exe.sha256").Split(" ")[0]
$actual = (Get-FileHash $exe -Algorithm SHA256).Hash.ToLower()
if ($expected -ne $actual) { throw "checksum mismatch — do not run this binary" }
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$dest*") {
[Environment]::SetEnvironmentVariable("Path", "$userPath;$dest", "User")
}
Write-Host "Installed. Open a new terminal and run: zt init"zt.exe is a console app — double-clicking it in Explorer will show a
"you need to open cmd.exe" prompt. That's expected; always run it from a
terminal (cmd, PowerShell, Windows Terminal).
Don't use setx PATH ... to add it to PATH by hand — setx truncates
anything over 1024 characters silently, and on a dev machine with a few
toolchains already installed your PATH is almost certainly already longer
than that. It will corrupt your PATH rather than append to it. The script
above uses [Environment]::SetEnvironmentVariable, which doesn't have that
limit — but if your PATH still looks wrong afterwards (check with
echo %PATH% in a new cmd window — env var changes don't apply to
already-open terminals), fix it via the GUI instead: Win+R → sysdm.cpl
→ Advanced → Environment Variables, rather than any more setx.
zt up installs a Task Scheduler task (zt-<name>, logon trigger,
restart-on-failure) — the same auto-start/auto-restart guarantee
systemd/launchd give on Linux/macOS, and zt down/restart/status/logs/
doctor all work against it. zt watchdog is also available, running as its
own zt-watchdog task.
The task runs under your own logon session with no stored credentials, so
zt up never needs admin rights or a password prompt — same permission
model as systemctl --user / a per-user LaunchAgent. The trade-off is the
same one those have too: it only starts once you log in, not at machine
boot before any user session exists.
If cloudflared isn't on PATH (or task creation fails for any other
reason), zt up falls back to running it as a directly tracked process —
you'll see (no auto-restart) in the output when that happens.
(Windows install: Scoop (recommended, self-updating) or PowerShell script above. A winget package is planned — see the issue tracker for status.)
zt initYou will be prompted for three values:
| Field | Where to find it |
|---|---|
| API Token | Cloudflare → My Profile → API Tokens |
| Account ID | Cloudflare dashboard → right sidebar |
| Domain | Your domain as it appears in Cloudflare (e.g. example.com) |
zt init validates the token and domain against the Cloudflare API before saving. Config is stored at ~/.zt-config.json (mode 0600).
zt up <name> <port># Restrict access to specific email (Cloudflare sends OTP to that address)
zt up portainer 9000 --allow [email protected]
# Multiple allowed emails
zt up vault 8200 --allow [email protected] --allow [email protected]
# Auto-detect port from a running Docker container
zt up portainer --docker
# Docker + email restriction
zt up portainer --docker --allow [email protected]
# No Zero Trust gate — public access, no Access app created
zt up api 8080 --public
# ZT Access app created but bypass policy (no login required)
zt up grafana 3000
# Force TCP if QUIC is blocked by your ISP
zt up portainer 9000 --tcpThe service becomes available at https://<name>.<domain>.
The tunnel is registered as a system service and survives reboots automatically.
zt down portainerStops the system service, removes local config files, deletes the DNS record, removes the Zero Trust Access app, and deletes the tunnel from Cloudflare.
zt list # or: zt lsNAME URL PORT STATUS MANAGED BY
portainer https://portainer.example.com 9000 running systemd
grafana https://grafana.example.com 3000 stopped pid 84291
zt status portainer portainer
URL: https://portainer.example.com
Port: 9000
Tunnel ID: 07fc193d-d05e-48eb-bb00-22be71823b14
Managed by: systemd
Status: running
Protocol: http2 (TCP)
Created: 2026-05-27 00:01:08
Log: /root/.zt/tunnels/portainer/cloudflared.log
# last 50 lines
zt logs portainer
# last 100 lines
zt logs portainer -n 100
# follow (like tail -f)
zt logs portainer -f
# show logs inline with status
zt status portainer --logsExport everything zt currently manages to a portable manifest:
zt export # writes zt.yaml in the current directory
zt export -o ~/backup/zt.yamlThe generated zt.yaml captures the intent behind each tunnel — port, protocol, access policy — but deliberately excludes credentials and Cloudflare-specific IDs. It is safe to commit to git.
# zt.yaml — portable cfzt service manifest
# generated by `zt export` — credentials are NOT included here.
# on a new machine: run `zt init` first, then `zt apply zt.yaml`
services:
grafana:
port: 3000
portainer:
docker: true
allow:
- [email protected]
vault:
port: 8200
protocol: quic
api:
port: 8080
public: trueTo recreate the same setup on a different machine:
# 1. on the new machine — configure credentials
zt init
# 2. apply the manifest
zt apply zt.yamlzt apply diffs the manifest against the local state and only creates what is missing. Existing tunnels are never modified or deleted automatically:
⚡ Applying zt.yaml
plan: to create: 3 skipped: 1 untracked: 0
~ api already exists — skipping
⚡ Bringing up grafana.example.com → localhost:3000
...
🎉 Ready: https://grafana.example.com
✅ Done — 3 service(s) created
If a service exists locally but is absent from the manifest, zt apply reports it without touching it — remove it explicitly with zt down <name> if needed.
cloudflared automatically falls back from QUIC to HTTP/2 when UDP is blocked or unstable — but it never tries QUIC again on its own, even after the network recovers (cloudflare/cloudflared#1534). A brief UDP blip can leave a tunnel stuck on HTTP/2 indefinitely, with no automatic recovery.
zt watchdog runs in the background, watches each tunnel's log for the fallback, and restarts the tunnel after a backoff delay so cloudflared gets a fresh shot at QUIC. Only tunnels running with the default protocol: auto are affected — tunnels pinned via --protocol or --tcp are left alone, since that's a deliberate choice.
zt watchdog enable # install as a background service, checks every 30s
zt watchdog status # check if it's running
zt watchdog disable # remove itRestarts back off exponentially per tunnel (10 min → 20 min → ... capped at 60 min) so a tunnel with a persistently broken UDP path isn't flapped repeatedly — it still gets retried roughly once an hour rather than being abandoned.
zt doctor System
✓ cloudflared installed
version: cloudflared version 2024.1.0
Cloudflare
✓ API token valid
✓ domain example.com found in Cloudflare
Tunnel: portainer
✓ systemd service zt-portainer.service active
✓ local service on port 9000 reachable
✓ DNS resolves portainer.example.com
✓ Cloudflare tunnel exists
✓ all checks passed
| Flag | Description |
|---|---|
-o <path> |
Output path (default: zt.yaml in current directory) |
zt apply <file> takes no additional flags. It reads the manifest at <file> and creates any missing services.
| Subcommand | Description |
|---|---|
enable |
Install and start the watchdog as a background service |
disable |
Stop and remove the watchdog service |
status |
Show whether the watchdog is running |
| Flag | Description |
|---|---|
--allow <email> |
Restrict access to this email via Cloudflare Access (repeatable) |
--public |
No Zero Trust gate — skip Access app entirely |
--docker |
Auto-detect port from a running Docker container with this name |
--tcp |
Force TCP (http2) — use if QUIC/UDP is blocked by your ISP |
--protocol <proto> |
Protocol: auto (default), quic, http2 |
| Flag | Description |
|---|---|
-n <lines> |
Number of lines to show (default: 50) |
-f |
Follow log output |
| Flag | Description |
|---|---|
--logs |
Show recent log output inline |
zt status <name> and zt <name> status are equivalent — same for logs, restart, and down.
zt uses Cobra, which ships tab-completion out of the box:
# bash (current shell)
source <(zt completion bash)
# bash (persist)
zt completion bash | sudo tee /etc/bash_completion.d/zt
# zsh
zt completion zsh > "${fpath[1]}/_zt"
# fish
zt completion fish > ~/.config/fish/completions/zt.fish
Run zt completion --help for details per shell.
zt --version
There is currently no zt update command — zt is a single static binary with no
auto-updater. To upgrade, re-run the install script or go install (see
Install), which simply overwrites the existing binary in place:
curl -fsSL https://raw.githubusercontent.com/casablanque-code/cfzt/main/install.sh | bash
~/.zt-config.json # credentials (0600)
~/.zt-state.json # tunnel state (0600)
~/.zt/tunnels/<name>/
config.yml # cloudflared config
<tunnel-id>.json # tunnel credentials
cloudflared.log # cloudflared process log
~/.config/systemd/user/
zt-<name>.service # systemd unit (Linux)
~/Library/LaunchAgents/
com.zt.<name>.plist # LaunchAgent (macOS)
Task Scheduler > zt-<name> # scheduled task (Windows, no on-disk file)
cloudflared not found in PATH
Install it: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
unsupported cloudflared version
Your cloudflared is older than 2023.x. Upgrade using the link above.
502 Bad Gateway after zt up
The tunnel is up but the local service is not running or not listening on the specified port.
curl http://localhost:<port>
zt logs <name>502 Bad Gateway even though zt status shows the QUIC connection is up
zt status reflects what cloudflared's connectivity pre-check and connection
registration reported - but that pre-check only opens a small test UDP
connection, not real traffic. On some networks the pre-check passes (QUIC
control packets get through fine) while actual response data gets silently
dropped due to UDP fragmentation/MTU issues on the path, causing request
timeouts that surface as 502s. This is not something zt or the watchdog
can detect or fix automatically - if you're seeing 502s with an apparently
healthy QUIC connection, force TCP:
zt down <name> && zt up <name> <port> --tcpTunnel shows stopped in zt ls
zt doctor
zt logs <name>Or check the service manager directly:
systemctl --user status zt-<name> # Linux
launchctl list com.zt.<name> # macOS
schtasks /query /tn zt-<name> /v # WindowsIf the service crashed, restart it:
zt restart <name>Or tear down and recreate:
zt down <name> && zt up <name> <port>tunnel already exists
Run zt down <name> first. If the tunnel is stale on Cloudflare's side (e.g. after a failed previous run), zt up detects and removes it automatically before creating a new one.
zone not found for domain
Make sure the domain is added to Cloudflare and the API token has Zone / DNS / Edit permission.
Authentication error on Access app creation
The API token is missing Account / Access: Apps and Policies / Edit. Edit the token in the Cloudflare dashboard.
DNS record conflict
zt up uses upsert — it removes any existing A, AAAA, or CNAME record with the same name before creating the tunnel CNAME. No manual cleanup needed.
Task doesn't start on Windows / schtasks shows the task but it's not running
The zt-<name> task only fires on logon to your account (see Windows
support) — it won't start before you sign in, and it won't
run under a different user's session. Sign in and check:
schtasks /query /tn zt-<name> /v
zt logs <name>Run zt doctor first
Most issues are diagnosed automatically:
zt doctorMIT
