You have a service running somewhere. zt gives it a secure public
endpoint through Cloudflare Zero Trust — no dashboard clicking, one command.
zt up portainer --docker --allow you@example.com
# → https://portainer.yourdomain.com (ZT-protected, live in ~15s)zt up <name> <port> creates the tunnel, ingress rules, DNS record, and
Access policy, and installs it as a service that survives reboots. zt down <name> removes everything it created. See What it does for
the full list.
zt doesn't care what's on the other end of the port — the same command
covers a few different jobs:
Self-hosted apps & AI tools — put a dashboard or a local model behind a
real HTTPS URL with login, instead of leaving it on localhost or opening a
port:
zt up grafana 3000 --docker --allow you@example.com
zt up open-webui 8080 --docker --allow you@example.comHome automation — reach Home Assistant, Frigate, or Node-RED from outside your LAN without a VPN client on every device:
zt up home-assistant 8123 --docker --allow you@example.comPR preview environments — give each pull request its own public URL for review, and tear it down when the PR closes:
flowchart LR
A[PR opened] --> B[CI builds & starts container<br/>localhost:3000]
B --> C["zt up pr-142 3000 --docker --public"]
C --> D[https://pr-142.example.com]
E[PR closed] --> F["zt down pr-142"]
F --> G[Tunnel, DNS, Access all removed]
# on PR open, in CI, after the container is up on localhost:3000
# --public: preview links need to be viewable without a Cloudflare Access
# login, same as a Vercel/Netlify preview URL — swap in --allow if your
# previews should stay restricted to your team
zt up pr-142 3000 --docker --public
# on PR close
zt down pr-142Self-hosted preview URLs without Vercel, Netlify, or a hosted preview
platform. casablanque-code/cfzt ships a composite action wrapping the
zt up/zt down calls above and reflecting them in the GitHub
Deployments UI — see GitHub Action below.
casablanque-code/cfzt is also a composite action wrapping zt up/zt down for exactly the PR-preview flow above, plus a GitHub Deployment +
Deployment Status so the preview URL shows up in the PR's own UI, not just
build logs.
- uses: casablanque-code/cfzt@v0.10.0
with:
mode: up # or: down
name: pr-${{ github.event.number }}
port: '3000'
docker: 'true'
public: 'true' # or use `allow:` to restrict to your team
domain: example.com
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}Full example workflow, input reference, and notes on state/matching across CI runs: docs/github-action.md.
zt up <name> <port> --allow <mail@example.com> automatically:
- Creates a Cloudflare Tunnel
- Configures ingress rules
- Upserts a CNAME DNS record (replaces a stale zt-created record automatically; refuses to touch a foreign record unless
--forceis given) - 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
flowchart LR
A[Docker / local service] --> B(zt)
B --> C[Cloudflare Tunnel]
B --> D[DNS]
B --> E[Access]
B --> F[Lifecycle<br/>systemd / LaunchAgent / Task Scheduler]
C & D & E & F --> G[https://name.example.com]
zt down <name> attempts to remove all created resources. It looks the
tunnel up in local state by default; pass --remote to resolve it
directly from Cloudflare by name instead, for tearing down from a
different machine than the one zt up ran on (e.g. CI — see the
GitHub Action section).
- 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.
zt.exe is a console app — double-clicking it in Explorer will show a
"you need to open cmd.exe" prompt; always run it from a terminal. See
docs/windows.md for that and other Windows-specific
gotchas (PATH setup, task scheduling, troubleshooting).
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. It runs under your own logon session
with no stored credentials and no admin prompt, same permission model as
systemctl --user / a per-user LaunchAgent.
Windows install: Scoop (recommended, self-updating). A WinGet package is in progress — see the issue tracker for status.
Full details, gotchas, and Windows-specific troubleshooting: docs/windows.md.
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> --allow you@example.com
# or: zt up <name> <port> --public--allow or --public is required — zt always makes you choose
explicitly whether a service needs a login or not; there's no implicit
default.
# Restrict access to specific email (Cloudflare sends OTP to that address)
zt up portainer 9000 --allow you@example.com
# Multiple allowed emails
zt up vault 8200 --allow alice@example.com --allow bob@example.com
# Auto-detect port from a running Docker container
zt up portainer --docker --allow you@example.com
# No Zero Trust gate — public access, no Access app created
zt up api 8080 --public
# Force TCP if QUIC is blocked by your ISP
zt up portainer 9000 --allow you@example.com --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 PROTOCOL ACCESS STATUS MANAGED BY
portainer https://portainer.example.com 9000 auto (quic) ZT (1 email) running systemd
grafana https://grafana.example.com 3000 http2 (TCP) public stopped pid 84291
zt status portainer portainer
URL: https://portainer.example.com
Port: 9000
Tunnel ID: 07fc193d-d05e-48eb-bb00-22be71823b14
Managed by: systemd
Protocol: http2 (TCP)
Access: ZT (1 email)
Status: running
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 --logszt export # writes zt.yaml in the current directory
zt apply zt.yaml # recreate the same services elsewhere (zt init first)zt export snapshots everything zt manages into a portable manifest
(credentials excluded, safe to commit). zt apply diffs it against local
state and only creates what's missing — re-running it is safe, existing
tunnels are reported and skipped, never modified or deleted automatically.
Manifest format, the full apply/skip behavior, and an example: docs/backup-restore.md.
cloudflared falls back from QUIC to HTTP/2 when UDP is blocked, but never retries QUIC on its own even after the network recovers (cloudflare/cloudflared#1534).
zt watchdog enable # install as a background service, checks every 30s
zt watchdog status # check if it's running
zt watchdog disable # remove itHow it detects fallback, backoff behavior, and a known race with manual restarts: docs/watchdog.md.
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
zt version
# or: zt --versionBoth print the running version and, unless ZT_NO_UPDATE_CHECK=1 is set,
do a quick (1.5s timeout) check against GitHub for a newer release —
silently skipped if there's no network, since zt is meant to work fine
offline. zt doctor does the same check and surfaces it alongside the
rest of the system report.
| 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 |
One of --allow or --public is required.
| 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 |
--container-port <n> |
Which container-side port to expose when the container publishes more than one (requires --docker) |
--tcp |
Force TCP (http2) — use if QUIC/UDP is blocked by your ISP |
--protocol <proto> |
Protocol: auto (default), quic, http2 |
--force |
Replace an existing DNS record for the hostname even if zt didn't create it |
| 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>/ # cloudflared config, credentials, log per tunnel
Full layout including per-platform service files: docs/file-layout.md.
Run zt doctor first — most issues are diagnosed automatically.
Common issues (cloudflared not found, 502 Bad Gateway, tunnel shows
stopped, DNS record conflicts, Windows task not starting, and more) with
fixes: docs/troubleshooting.md.
MIT
