# zt — expose self-hosted services without the dashboard busywork [![CI](https://github.com/casablanque-code/cfzt/actions/workflows/ci.yml/badge.svg)](https://github.com/casablanque-code/cfzt/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/casablanque-code/cfzt/branch/main/graph/badge.svg)](https://codecov.io/gh/casablanque-code/cfzt) [![Go Reference](https://pkg.go.dev/badge/github.com/casablanque-code/cfzt.svg)](https://pkg.go.dev/github.com/casablanque-code/cfzt) [![Gitleaks](https://img.shields.io/badge/protected%20by-gitleaks-blue)](https://github.com/casablanque-code/cfzt/actions/workflows/gitleaks.yml) [![Tiny Tool Town](https://img.shields.io/badge/featured-TinyToolTown-blue)](https://www.tinytooltown.com/tools/cfzt/) [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-GitHub%20Action-blue?logo=github)](https://github.com/marketplace/actions/cfzt-preview-tunnel) [![Release](https://img.shields.io/github/v/release/casablanque-code/cfzt)](https://github.com/casablanque-code/cfzt/releases/latest) You have a service running somewhere. `zt` gives it a secure public endpoint through Cloudflare Zero Trust — no dashboard clicking, one command. ```bash zt up portainer --docker --allow you@example.com # → https://portainer.yourdomain.com (ZT-protected, live in ~15s) ``` ![zt demo](demo.gif) `zt up ` creates the tunnel, ingress rules, DNS record, and Access policy, and installs it as a service that survives reboots. `zt down ` removes everything it created. See [What it does](#what-it-does) for the full list. ## Use cases `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: ```bash zt up grafana 3000 --docker --allow you@example.com zt up open-webui 8080 --docker --allow you@example.com ``` **Home automation** — reach Home Assistant, Frigate, or Node-RED from outside your LAN without a VPN client on every device: ```bash zt up home-assistant 8123 --docker --allow you@example.com ``` **PR preview environments** — give each pull request its own public URL for review, and tear it down when the PR closes: ```mermaid flowchart LR A[PR opened] --> B[CI builds & starts container
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] ``` ```bash # 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-142 ``` Self-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](#github-action) below. --- ## GitHub Action `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. ```yaml - 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](docs/github-action.md)**. ## What it does `zt up --allow ` automatically: 1. Creates a Cloudflare Tunnel (reuses a same-name tunnel only if zt created it — see below) 2. Configures ingress rules 3. Upserts a CNAME DNS record (replaces a stale zt-created record automatically; refuses to touch a foreign record unless `--force` is given, and prints a warning naming what it deleted when it does) 4. Creates a Zero Trust Access application with an access policy 5. Installs and starts a systemd (Linux), LaunchAgent (macOS), or Task Scheduler (Windows) service 6. Saves state locally ```mermaid flowchart LR A[Docker / local service] --> B(zt) B --> C[Cloudflare Tunnel] B --> D[DNS] B --> E[Access] B --> F[Lifecycle
systemd / LaunchAgent / Task Scheduler] C & D & E & F --> G[https://name.example.com] ``` `zt down ` 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](#github-action) section). --- ## Prerequisites - A domain on Cloudflare - [`cloudflared`](#installing-cloudflared) ≥ 2023.x installed and in PATH - A Cloudflare API token with the following permissions: - `Account / Cloudflare Tunnel / Edit` - `Zone / DNS / Edit` - `Account / Access: Apps and Policies / Edit` ### Installing cloudflared `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](https://pkg.cloudflare.com/index.html#debian-any) | `sudo apt update && sudo apt install --only-upgrade cloudflared` | | Other Linux / binary | [Releases](https://github.com/cloudflare/cloudflared/releases) | re-download and replace the binary | | Windows | [Releases](https://github.com/cloudflare/cloudflared/releases) | re-download and replace the binary — see [Windows support](#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. ### Creating the API token 1. Cloudflare dashboard → **My Profile** → **API Tokens** → **Create Token** 2. Use **Custom token**, add the permissions above 3. Set Account Resources → your account 4. Set Zone Resources → your domain --- ## Install ### Option A — install script (recommended) ```bash curl -fsSL https://raw.githubusercontent.com/casablanque-code/cfzt/main/install.sh | bash ``` Downloads the correct binary for your OS and architecture, verifies the SHA-256 checksum, and places it in `/usr/local/bin/zt`. ### Option B — go install ```bash go install github.com/casablanque-code/cfzt/cmd/zt@latest ``` ### Option C — build from source ```bash git clone https://github.com/casablanque-code/cfzt cd cfzt go build -o zt ./cmd/zt sudo mv zt /usr/local/bin/ ``` ### Option D — Homebrew (macOS + Linux) ```bash brew tap casablanque-code/cfzt https://github.com/casablanque-code/cfzt brew install zt ``` The formula lives in this repo's [`Formula/`](Formula/) folder and updates itself automatically on every release (same mechanism as the Scoop bucket below). `brew upgrade zt` picks up new versions. ### Option E — download binary Download from [Releases](https://github.com/casablanque-code/cfzt/releases) and place in your PATH. Each release includes `.sha256` checksum files and a combined `checksums.txt`. ### Option F — Scoop (Windows) ```powershell scoop bucket add cfzt https://github.com/casablanque-code/cfzt scoop install cfzt/zt ``` The bucket lives in this repo's [`bucket/`](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 pull`s 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](docs/windows.md) for that and other Windows-specific gotchas (PATH setup, task scheduling, troubleshooting). --- ## Windows support `zt up` installs a Task Scheduler task (`zt-`, 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](#option-f--scoop-windows) (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](docs/windows.md)**. --- ## Setup ```bash zt init ``` You 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). Alternatively, set `ZT_API_TOKEN`, `ZT_ACCOUNT_ID`, and `ZT_DOMAIN` in the environment — useful on ephemeral CI runners or containers where writing a config file isn't desirable, or to swap the token without re-running `zt init`. Any of the three, if set, overrides the corresponding value from `~/.zt-config.json`; set all three and `zt init` isn't needed at all. --- ## Usage ### Bring up a tunnel ```bash zt up --allow you@example.com # or: zt up --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. ```bash # 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 --tcp ``` The service becomes available at `https://.`. The tunnel is registered as a system service and survives reboots automatically. ### Tear down a tunnel ```bash zt down portainer ``` Stops the system service, removes local config files, deletes the DNS record, removes the Zero Trust Access app, and deletes the tunnel from Cloudflare. ### List tunnels ```bash zt list # or: zt ls ``` ``` NAME 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 ``` ### Tunnel details ```bash 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 ``` ### View logs ```bash # 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 --logs ``` ### Backup & restore ```bash zt 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](docs/backup-restore.md)**. ### QUIC/HTTP2 fallback watchdog 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](https://github.com/cloudflare/cloudflared/issues/1534)). ```bash zt watchdog enable # install as a background service, checks every 30s zt watchdog status # check if it's running zt watchdog disable # remove it ``` How it detects fallback, backoff behavior, and a known race with manual restarts: **[docs/watchdog.md](docs/watchdog.md)**. ### Health check ```bash 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 ``` ### Checking your version ```bash zt version # or: zt --version ``` Both 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. --- ## Flags ### `zt export` | Flag | Description | |---|---| | `-o ` | Output path (default: `zt.yaml` in current directory) | ### `zt apply` | Flag | Description | |---|---| | `--force` | Accept a manifest that changed since it was last applied | `zt apply ` reads the manifest at `` and creates any missing services. It remembers the content of the manifest it last applied; if `` has changed since then — edited, or replaced by something else entirely — it shows the diff and refuses to proceed until re-run with `--force`. ### `zt watchdog` | 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 | ### `zt up` One of `--allow` or `--public` is required. | Flag | Description | |---|---| | `--allow ` | 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 ` | 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 ` | Protocol: `auto` (default), `quic`, `http2` | | `--force` | Replace an existing DNS record for the hostname even if zt didn't create it; also delete a same-name Cloudflare tunnel that zt didn't create | Every tunnel zt creates is tagged with Cloudflare Tunnel metadata `managed_by: cfzt`. If `zt up` finds a tunnel with the target name that already carries that tag (typically a stale one left behind by an interrupted or torn-down run), it deletes and recreates it automatically. If it finds a same-name tunnel *without* the tag — created via the Cloudflare dashboard, another tool, or an older cfzt release — it refuses and exits with an error rather than deleting someone else's tunnel; pass `--force` to delete it anyway. Same idea for DNS: replacing a record `zt` didn't create (with `--force`) prints a visible warning naming the record type and what it was pointing to, instead of deleting it silently. This is a plain printed warning, not an interactive confirmation prompt — a blocking prompt would break the CI-driven PR-preview use case (see [docs/github-action.md](docs/github-action.md)), so review the printed warning in your run logs if you're not watching the terminal live. ### `zt logs` | Flag | Description | |---|---| | `-n ` | Number of lines to show (default: 50) | | `-f` | Follow log output | ### `zt status` | Flag | Description | | -------- | ------------------------------- | | `--logs` | Show recent log output inline | `zt status ` and `zt status` are equivalent — same for `logs`, `restart`, and `down`. --- ## Shell completion `zt` uses [Cobra](https://github.com/spf13/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. --- ## Version ``` 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](#install)), which simply overwrites the existing binary in place: ``` curl -fsSL https://raw.githubusercontent.com/casablanque-code/cfzt/main/install.sh | bash ``` --- ## File layout ``` ~/.zt-config.json # credentials (0600) ~/.zt-state.json # tunnel state (0600) ~/.zt/tunnels// # cloudflared config, credentials, log per tunnel ``` Full layout including per-platform service files: **[docs/file-layout.md](docs/file-layout.md)**. --- ## Troubleshooting 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](docs/troubleshooting.md)**. --- ## License MIT