Teitunnel

Guides

Previews in CI and teams

Pull request previews on your own Cloudflare domain with the Teitunnel GitHub Action or GitLab CI template, and hostname reservations for teams sharing one account.

Every pull request can get its own address on your domain, like https://pr-42-preview.yourhost.com, published from CI to your own Cloudflare account: commented on the pull request, updated on every push, and removed when it closes. There's no preview service in between: the Action and the template run the teitunnel CLI in your job, against your account, through the same reviewed plan → apply engine as the app.

Two kinds of preview

Snapshot (default)Share
WhatA static copy of your built site (Snapshots)A live tunnel to a server the job started
OnlineAfter the job, until the pull request closes (or expires)While the job runs
Good forReviews of front ends, docs, designEnd-to-end tests against a real hostname, APIs, server-rendered apps, webhooks
On each pushA new version; only changed files are uploadedA new share for that job
Cloudflare piecesA Worker with static assets and a Custom DomainA tunnel for the job, a DNS record

GitHub Actions

Create the token

Use an account-owned API token (Cloudflare dashboard ▸ Manage account ▸ Account API tokens): it belongs to the account, not to a person who might leave. Give it:

PermissionFor
Account · Cloudflare Tunnel · Edit, Zone · DNS · Edit, Zone · Zone · ReadEvery mode
Account · Workers Scripts · Edit, Zone · Workers Routes · EditSnapshots
Account · Access: Apps and Policies · Editallow (a login in front of the preview)

Limit it to the zones previews use, and store it as a repository or environment secret named CLOUDFLARE_API_TOKEN.

Add the workflow

# .github/workflows/preview.yml
name: Preview
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

permissions:
  contents: read
  pull-requests: write # the comment

jobs:
  publish:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: npm ci
      - uses: teispace/teitunnel-action@v1
        with:
          cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          build: true
          zone: yourhost.com

  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - uses: teispace/teitunnel-action@v1
        with:
          cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          mode: cleanup
          zone: yourhost.com

Open a pull request

The job builds the site, publishes it at pr-<number>-preview.yourhost.com, and comments the URL on the pull request. Each push publishes a new version and edits the same comment. Closing or merging the pull request runs cleanup, which deletes the Snapshot.

A live share for end-to-end tests

      - run: npm run start -- --port 3000 &
      - run: npx wait-on http://localhost:3000
      - id: preview
        uses: teispace/teitunnel-action@v1
        with:
          cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          mode: share
          port: 3000
          hostname: pr-{number}-{repo}.{zone}   # one level: covered by the free certificate
          zone: yourhost.com
      - run: npx playwright test
        env:
          BASE_URL: ${{ steps.preview.outputs.url }}

The share stays up until the job ends. The Action's post step, which runs even when a step failed, stops it, removes its DNS record and deletes the job's tunnel.

Inputs

InputDefault
cloudflare-api-token(required)A Cloudflare API token, from a secret.
accountThe account's name or id, when the token reaches several.
modesnapshotsnapshot, share or cleanup.
port / urlShare mode: the server the job started (3000, or http://localhost:8080).
path.Snapshot mode: the folder to publish, or the project with build.
buildfalseSnapshot mode: build the project first (its package manager runs its build script; Next.js needs output: 'export').
hostnamepr-{number}-preview.{zone}The hostname template (below).
zoneOne of the account's domains, for {zone}.
name{repo}-pr-{number}Snapshot mode: its name (the Worker is teitunnel-<name>). Keep it unique per repository and pull request.
expiresEnd by itself: share 30m, 2h; Snapshot 7d.
passwordSnapshot mode: a password visitors type (pass a secret; only a salted hash reaches Cloudflare).
allowRequire a Cloudflare Access login for these emails or @domains (comma or newline separated).
commenttrueComment the URL on the pull request.
github-token${{ github.token }}For the comment and for downloading the CLI.
versionlatestThe Teitunnel CLI version to install.
cli-pathUse this teitunnel binary instead of installing one (self-hosted runners).
wait120Share mode: seconds to wait for the share to be live.

Outputs: url (empty in cleanup mode) and hostname.

Hostname templates

PlaceholderBecomes
{number}The pull request number (runs for other events can't use it).
{branch}The head branch, e.g. feat/login → feat-login.
{sha}The first 7 characters of the head commit.
{repo}, {owner}The repository and its owner.
{zone}The zone input, as given.

Every value is made a DNS label: lowercase letters, digits and dashes, at most 63 characters. When several repositories share a zone, include {repo}: pr-{number}-{repo}.{zone}.

Keep previews one level deep

Cloudflare's free certificate covers one level of subdomain (pr-42-preview.yourhost.com), not deeper names like pr-42.preview.yourhost.com. The default templates stay one level deep for that reason. If you choose a deeper template, Snapshots still work (Cloudflare issues their certificate), but shares need an Advanced Certificate for the deeper name.

The comment

One comment per preview hostname, found again by a hidden marker and edited in place on every push, so a pull request never collects a stack of them. A share's comment says when the job ended; a cleanup says the preview was removed. Without pull-requests: write the preview still works and the comment is skipped with a warning.

A reusable workflow

The repository's examples/reusable-preview.yml is a workflow_call workflow that starts an app and shares it for a set number of minutes. Keep it in one repository and call it from the others:

jobs:
  preview:
    uses: <owner>/<repo>/.github/workflows/reusable-preview.yml@main
    with:
      zone: yourhost.com
      start: npm run dev -- --port 3000
      port: "3000"
    secrets:
      cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}

Pull requests from forks

GitHub doesn't give secrets to workflows run for pull requests from forks, so those previews are skipped. Don't work around it with pull_request_target: it runs with your secrets and a write token, and building the fork's code there hands both to whoever opened the pull request. If you must preview forks, let a maintainer apply a label after reviewing the code, run on pull_request_target only for that label, check out the reviewed commit by its SHA, and keep the token scoped to the preview zone.

GitLab CI

Include the template and extend its hidden jobs. GitLab shows the preview on the merge request through the environment (View app), and runs the stop job when the merge request is merged or closed.

include:
  - remote: https://raw.githubusercontent.com/teispace/teitunnel/main/integrations/gitlab-ci/teitunnel.gitlab-ci.yml

variables:
  TEITUNNEL_ZONE: yourhost.com

preview:
  extends: .teitunnel-snapshot
  stage: deploy
  variables:
    TEITUNNEL_PATH: dist          # a built folder (or set TEITUNNEL_BUILD: "true")
  environment:
    on_stop: preview-stop

preview-stop:
  extends: .teitunnel-snapshot-stop
  stage: deploy

e2e:                              # a live share around the job's script
  extends: .teitunnel-share
  variables:
    TEITUNNEL_PORT: "3000"
    TEITUNNEL_HOSTNAME: mr-${CI_MERGE_REQUEST_IID}-${CI_PROJECT_NAME}.${TEITUNNEL_ZONE}
  script:
    - npm run test:e2e -- --base-url "$TEITUNNEL_URL"
JobDoes
.teitunnel-snapshotPublishes TEITUNNEL_PATH as a Snapshot at TEITUNNEL_HOSTNAME, updated on each push; TEITUNNEL_URL is passed on as a dotenv artifact.
.teitunnel-snapshot-stopDeletes it (the environment's on_stop job). Nothing there is fine.
.teitunnel-shareShares TEITUNNEL_PORT while the job's script runs; after_script stops it and deletes the job's tunnel.
VariableDefault
TEITUNNEL_ZONE(required)
TEITUNNEL_HOSTNAMEmr-${CI_MERGE_REQUEST_IID}-preview.${TEITUNNEL_ZONE}
TEITUNNEL_NAME${CI_PROJECT_NAME}-mr-${CI_MERGE_REQUEST_IID}
TEITUNNEL_PATH.
TEITUNNEL_BUILD"false"
TEITUNNEL_EXPIRES(none)
TEITUNNEL_PORT(none; required for .teitunnel-share)
TEITUNNEL_VERSIONlatest

Set CLOUDFLARE_API_TOKEN as a masked CI/CD variable. A protected variable reaches only pipelines of protected branches; merge requests from forks get no variables at all, so their previews are skipped. The job image needs a POSIX shell, curl, tar and sha256sum (any Debian or Ubuntu image; on Alpine, apk add curl).

Anywhere else

The Action and the template are thin wrappers around the CLI, which works in any CI:

export CLOUDFLARE_API_TOKEN=…             # from your CI's secrets; never stored
export TEITUNNEL_OWNER="ci/shop"          # who holds the names this job creates
export TEITUNNEL_MACHINE_NAME="ci-shop-$JOB_ID"   # names the job's tunnel

teitunnel snapshot publish dist --name shop-pr-42 --on pr-42-preview.yourhost.com \
  --or-update --yes --json                # {"url": "https://pr-42-preview.yourhost.com", …}
teitunnel snapshot rm pr-42-preview.yourhost.com --missing-ok --yes

teitunnel cloudflared install             # a verified cloudflared, if the machine has none
teitunnel share 3000 --on pr-42-shop.yourhost.com --json &     # up until stopped
Variable
CLOUDFLARE_API_TOKEN or TEITUNNEL_API_TOKENThe token, for this command only (a …_FILE variant reads it from a file, as Docker secrets do).
TEITUNNEL_OWNERThe owner written into DNS records, e.g. github-actions/<repository>.
TEITUNNEL_MACHINE_NAMEThe name of the tunnel a share runs on.
TEITUNNEL_DATA_DIRWhere Teitunnel keeps its database and cloudflared.

share --json prints {"url": …, "hostname": …} once it's live. A hostname someone else holds exits with code 3; other failures exit with 1.

Teams sharing one account

When several people (and CI) use one Cloudflare account, Teitunnel writes who made each DNS record into its comment, for example teitunnel:route=…;by=<user>@<computer>. Every Teitunnel reading the account sees who holds a name, and a plan that would take a teammate's hostname says so and needs your confirmation first.

Reservations

Reserve a hostname so nobody else takes it, optionally until a date:

teitunnel reserve demo.yourhost.com                            # no end date
teitunnel reserve pr-402-preview.yourhost.com --until 2026-12-31
teitunnel reservations ls
teitunnel release demo.yourhost.com

In the app: Domains ▸ Reservations ▸ Reserve ("Reserve a hostname"). The hostname field of a new route shows, as you type, whether the name is Available, Reserved by you, Reserved by … (and until when), Routed by … on another machine, or Has a DNS record Teitunnel didn't create.

A reservation lives in your DNS, so it needs no server: a placeholder record (AAAA 100::, proxied, which serves nothing) carrying the owner and end date in its comment. When you add a route on your reserved name, the route keeps the reservation; remove the route and the placeholder comes back. An ended reservation frees the name. Reserving it again changes the end date.

Taking a name someone else holds needs --take-over on the command line (or Take Over in the app). A share never takes one.

Your owner label is your user name and this computer's name. On a server or in CI, set TEITUNNEL_OWNER to a stable name (the GitHub Action uses github-actions/<repository>, the GitLab template gitlab-ci/<project path>).

AI agents can list, reserve and release names too, through the MCP tools list_reservations, reserve_hostname and release_hostname (AI agents).

Troubleshooting

Edit on GitHub

Last updated on

On this page