Run TezTun in Docker Compose

The TezTun Docker image is the same CLI, shipped as a container. Add one sidecar for a single app, or use teztun run to expose multiple services and static sites from one container. No host ports, router config, nginx fan-out layer, or separate tunnel container per service.

How it works

You add a sidecar — a second service in your compose file — that runs the TezTun client. It makes an outbound connection to teztun.uz and tells the server "forward anything for this URL back to me." The server forwards; the sidecar hands the bytes to your app over the internal Docker network.

visitor → https://you.ttn.uz
         → TezTun server (public)
         → your sidecar container
         → your app container (on the compose network)
         → back to the visitor

Nothing in your stack changes. The app doesn't need to know it's being tunnelled. The sidecar doesn't need root, doesn't bind ports on the host, doesn't need network_mode: host.

Setup in 90 seconds

1. Create an account and a token

Sign up at app.teztun.uz, go to Tokens, click Create token. Copy the value — it's shown once.

On the Max plan tokens never expire (recommended for containers). Pro tokens expire after 3 days; Free tokens expire in 6 hours (not really usable for Docker).

2. Add the sidecar to your compose file

services:
  app:
    image: acme/myapp
    # Notice: no "ports:" here. Only the sidecar reaches it.

  tunnel:
    image: asadullokhn/teztun:latest
    environment:
      TEZTUN_TOKEN: ${TEZTUN_TOKEN}
    command: http app:3000
    restart: unless-stopped
    depends_on:
      - app

The command is http <service>:<port> for HTTP apps — or tcp <service>:<port> for raw-TCP services like Postgres or Redis. app is resolved by Docker's internal DNS — whatever service name you gave your app in the compose file.

3. Put the token in a .env file

# .env
TEZTUN_TOKEN=tzt_your-token-here

Compose auto-loads .env. Gitignore it. Never commit tokens.

4. Start it

docker compose up -d
docker compose logs tunnel

What you'll see in the logs

Connecting...

  Your tunnel is active!

  Your link: https://a3f8k2x9.ttn.uz
  Local:     app:3000

  Press Ctrl+C to stop.

Open the URL in a browser. Traffic routes into your app container. Done.

One container for multiple tunnels

When a Compose stack has several public services, mount one strict YAML file and run them through one authenticated TezTun process. Every configured tunnel gets its own client, WebSocket pool, origin monitor, and retry loop while keeping its own subdomain, upstream, static root, and edge policy.

services:
  api:
    image: acme/api

  tunnel:
    image: asadullokhn/teztun:latest
    environment:
      TEZTUN_TOKEN: ${TEZTUN_TOKEN}
      API_BASIC_AUTH: ${API_BASIC_AUTH}
    volumes:
      - ./teztun.yaml:/etc/teztun/tunnels.yaml:ro
      - ./dashboard:/sites/dashboard:ro
    command: run /etc/teztun/tunnels.yaml
    restart: unless-stopped
    depends_on:
      - api

The mounted teztun.yaml can mix HTTP origins and built-in static sites:

version: 2

tunnels:
  - name: api
    subdomain: api
    strict_subdomain: true
    pool_size: 2
    upstream: http://api:8000
    basic_auth_env: API_BASIC_AUTH
    allow_cidrs:
      - 203.0.113.0/24
    rate_limit:
      requests: 120
      window: 1m

  - name: dashboard
    subdomain: dashboard
    pool_size: 1
    static:
      root: /sites/dashboard
      spa: true
      cache_control: public, max-age=3600
      precompressed: [br, gzip]

Validate the mounted file without authenticating or creating tunnels:

docker compose run --rm tunnel run /etc/teztun/tunnels.yaml --check

Each tunnel requires exactly one upstream or static block. Its pool_size accepts 1–8 and defaults to 1; --pool-size or TEZTUN_POOL_SIZE supplies the default for entries that omit it. Unknown fields, duplicate names, bad origins, invalid CIDRs or rate limits, missing directories, unset basic_auth_env secrets, and insufficient account capacity fail before anything is exposed. Keep TEZTUN_TOKEN and basic-auth values in environment variables, never in YAML.

Reserve api and dashboard in the TezTun dashboard before using this example, or remove subdomain and strict_subdomain to receive random URLs. Replace the documentation CIDR with your real office or VPN network.

Behind a reverse proxy?

If your app container is itself nginx in front of another service, that nginx is probably overwriting our X-Forwarded-Proto header with $scheme and your real backend redirects to HTTPS in a loop. Two-line fixes for nginx, Rails, Django, Express, downstream Caddy / Traefik are at /docs/behind-a-proxy.

Keep a stable URL

Option A — a reserved subdomain (Pro or Max)

Random subdomains change on every restart. To pin one, reserve the name first at app.teztun.uz/domains, then pass --subdomain to the command:

command: http app:3000 --subdomain myapp

Every restart, you get https://myapp.ttn.uz.

If you pass a subdomain you haven't reserved, the tunnel still starts — you just get a random subdomain, and a warning is printed in the logs. Reserve it and restart to activate.

Option B — your own domain (Max only)

Add the domain at app.teztun.uz/domains, add the CNAME, click Verify. Then:

command: http app:3000 --domain myapp.com

HTTPS certificate is issued automatically on the first request. Full walk-through: Tunnel your localhost to a custom domain.

When your app goes down

The tunnel container stays up. Visitors see a branded "tunnel offline" page (HTTP 503). When your app comes back — same container restarted, new deploy, whatever — the tunnel automatically resumes serving. In multi-tunnel mode, sibling apps and static sites remain available throughout the outage.

A tunnel creation error, connection loss, expiry, or strict-subdomain fallback also retries only that tunnel's worker. The remaining boundary is the single process: a TezTun process crash, container restart, host outage, or invalid shared token affects all tunnels in that container.

This is why restart: unless-stopped is on the tunnel service but not strictly required for the app side: if the app crashes, the tunnel waits for it.

Updating

The image is published as asadullokhn/teztun:latest — a single rolling tag, always the newest release. To update:

docker compose pull tunnel
docker compose up -d tunnel

Check which version you have:

docker compose exec tunnel teztun version

More realistic examples

A static site with one container

services:
  site:
    image: asadullokhn/teztun:latest
    environment: { TEZTUN_TOKEN: ${TEZTUN_TOKEN} }
    volumes:
      - ./site:/site:ro
    command: serve /site --subdomain docs --spa --cache-control "public, max-age=3600" --precompressed br --precompressed gzip
    restart: unless-stopped

teztun serve replaces an nginx container whose only job is serving static files. SPA fallback, Cache-Control, ETags, conditional 304 responses, and existing Brotli/gzip sidecars are handled by TezTun. It does not replace dynamic path routing or an application backend.

An external HTTPS API without nginx

services:
  tunnel:
    image: asadullokhn/teztun:latest
    environment: { TEZTUN_TOKEN: ${TEZTUN_TOKEN} }
    command: http https://api.example.com --subdomain api --strict-subdomain
    restart: unless-stopped

TezTun verifies the origin certificate and automatically sends the origin hostname in Host. Use --host only to override it. The URL must be an origin only: paths, queries, fragments, and embedded credentials are rejected.

A Next.js dev server

services:
  web:
    build: .
    environment:
      NODE_ENV: development
  tunnel:
    image: asadullokhn/teztun:latest
    environment: { TEZTUN_TOKEN: ${TEZTUN_TOKEN} }
    command: http web:3000 --subdomain demo
    restart: unless-stopped

A FastAPI webhook receiver

services:
  api:
    build: .
    command: uvicorn main:app --host 0.0.0.0 --port 8000
  tunnel:
    image: asadullokhn/teztun:latest
    environment: { TEZTUN_TOKEN: ${TEZTUN_TOKEN} }
    command: http api:8000 --subdomain hooks
    restart: unless-stopped

Configure Stripe / Telegram / GitHub to hit https://hooks.ttn.uz/.... The URL stays stable because hooks is reserved on your account.

A PostgreSQL you're sharing with a teammate

services:
  db:
    image: postgres:16
    environment: { POSTGRES_PASSWORD: dev }
  tunnel:
    image: asadullokhn/teztun:latest
    environment: { TEZTUN_TOKEN: ${TEZTUN_TOKEN} }
    command: tcp db:5432
    restart: unless-stopped

TezTun is a TCP tunnel — not HTTP-only. The tunnel carries the raw wire protocol, so Postgres, Redis, MQTT, SSH, anything that speaks TCP works. Your teammate connects with psql, DataGrip, or whatever. Send them the host:port from the log output.

Environment variables

VariableWhat it does
TEZTUN_TOKENService token from the dashboard. Required.
TEZTUN_NO_UPDATESkip the CLI's self-update check. Already set to 1 in the image — containers shouldn't self-modify.

Security

Troubleshooting

"Error: unknown command 'http'"

Your image is older than the HTTP-mode release. Pull a fresh one:

docker compose pull tunnel
docker compose up -d tunnel

HTTP-aware mode (the http subcommand) ships in asadullokhn/teztun:latest. If :latest still doesn't have it, your registry mirror is stale — bypass it with docker pull docker.io/asadullokhn/teztun:latest. You can check what's running with docker compose exec tunnel teztun version and compare against the badge on /download.

Parallel API requests return 503 on boot

Symptom: 3 calls succeed, 1 returns 503 after exactly 10 seconds. This was a real issue with TCP mode — each visitor request opened a new TCP connection, the client serialised cold-start dials, and the slowest one tripped the edge's handshake timer.

Fix: use HTTP mode (command: http app:3000). The edge multiplexes all HTTP requests over the WebSocket frames, so parallelism isn't capped by connection count and there's no per-request dial handshake. As of 2.8.10, cold-start dials also run concurrently on the client side and the edge deadline is tighter (6s), so even TCP-mode parallel boot works.

The browser DevTools shows inject.js / wrs_env.js / content.js / rum in the response

That's not TezTun. The edge forwards your origin's bytes verbatim — there is no script injection, no body rewriting, no RUM beacon, no buffering. The scripts you're seeing are browser extensions that inject into every page: wrs_env.js is LanguageTool, content.js + inject.js are common content-script names used by Honey, Grammarly, Ghostery, Dashlane, and most ad-blockers.

Verify by opening the same URL in an incognito window with all extensions disabled. If the scripts are gone, it was an extension; if they're still there, please open an issue with the response body.

I asked for a subdomain but got a random one

Reserve the subdomain first at app.teztun.uz/domains. Until it's reserved, the server logs a warning and falls back to a random subdomain so the tunnel still comes up. Want it to fail loudly instead? Add --strict-subdomain to the command:

command: http app:3000 --subdomain myapp --strict-subdomain

A single-tunnel command exits non-zero when the reservation is missing, so Compose's restart policy and your monitoring surface the problem. With teztun run, only that tunnel's worker retries and sibling tunnels stay active; the container itself remains up.

The first request after a tunnel restart fails

Cold-tunnel behaviour: the edge waits up to 6 seconds for the CLI to dial the local app before serving a 503. If your app boots slowly (Rails + assets, large Node.js dependency tree), the first request can miss this window. Mitigations:

FAQ

My container keeps restarting. What's wrong?
Either the token is invalid (revoked, typo'd, or not pasted completely), or the token has expired (Free plan: 6h, Pro: 3d). Check the logs: docker compose logs tunnel. An auth failure logs "Authentication failed".

Can I run the tunnel outside Compose, pointing at services on my host?
Yes: docker run --rm -e TEZTUN_TOKEN=... asadullokhn/teztun:latest tcp host.docker.internal:3000. But the sidecar pattern is cleaner.

Does it support WebSockets / SSE / long-lived connections?
Yes. Raw tcp tunnels carry long-lived protocols unchanged. HTTP-aware mode streams SSE responses and forwards valid WebSocket upgrades through the raw tunnel path after applying the tunnel's edge policy.

What if my app listens on localhost only, inside its container?
Change it to bind 0.0.0.0. Otherwise the sidecar (a separate container) can't reach it.

Can the tunnel and the app be in different compose projects?
Not by default — they need to share a Docker network. Easiest: keep them in one compose file. If you have to split, use an external network both projects join.

Get your token
Max plan — tokens never expire. Pin production containers with confidence.
Open dashboard