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 visitorNothing 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:
- appThe 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-hereCompose auto-loads .env. Gitignore it. Never commit tokens.
4. Start it
docker compose up -d
docker compose logs tunnelWhat 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:
- apiThe 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 --checkEach 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.
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 myappEvery 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.comHTTPS 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 tunnelCheck which version you have:
docker compose exec tunnel teztun versionMore 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-stoppedteztun 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-stoppedTezTun 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-stoppedA 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-stoppedConfigure 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-stoppedTezTun 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
| Variable | What it does |
|---|---|
TEZTUN_TOKEN | Service token from the dashboard. Required. |
TEZTUN_NO_UPDATE | Skip the CLI's self-update check. Already set to 1 in the image — containers shouldn't self-modify. |
Security
- Treat tokens like passwords. Store them in
.env(gitignored) or your CI's secret manager. Never hard-code in compose files. - Revoke if leaked. Every token has a Revoke button in the dashboard. Any container using it loses access the moment you click.
- Apply policy at the edge. Multi-tunnel YAML can restrict HTTP tunnels with IPv4/IPv6 CIDR allowlists, per-IP rate limits, and basic auth before requests reach your network.
- The tunnel exposes whatever the target exposes. If
db:5432allows an empty password, so does your tunnel. Lock down internal services before tunneling them.
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 tunnelHTTP-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-subdomainA 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:
- Use
depends_on+ a healthcheck so the tunnel only starts when the app is ready. - Ping the tunnel URL once after deploy to warm it up — the next 30 seconds get the warm-tunnel fast path and skip the handshake entirely.
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.