Per-branch preview environments for agentic CI/CD pipelines — zero port exposure, auto-HTTPS, one docker compose up.
AI-driven development moves fast. Agentic workflows — where autonomous coding agents create branches, push commits, and iterate on features — need a way to instantly preview and test each change in a real, publicly-accessible environment. Traditional CI/CD preview deployments require cloud infrastructure, DNS configuration, TLS certificate management, and ingress controllers. That's too much friction for rapid iteration.
zrok-cicd-poc solves this by giving every branch, commit, or experimental patch its own isolated, auto-HTTPS preview URL using a single Docker Compose stack and a self-hosted zrok tunnel. No cloud infra. No DNS changes. No exposed ports. Just docker compose up and you have a live URL.
- Agentic coding tools (Claude Code, Cursor, Codex, Devin, etc.) generate branches and commits at high velocity
- Each change needs human or automated review in a live environment — not just CI green checks
- Traditional preview environments (Vercel, Netlify, k8s preview namespaces) add complexity, cost, and vendor lock-in
- WebSocket-heavy apps and streaming APIs are especially painful to test without a real deployment
- Dynamic hostnames are hard to propagate through a multi-service stack at runtime
This proof of concept provides a self-contained, reproducible building block for per-branch preview environments:
- One command spins up frontend, backend, proxy, and tunnel with a unique public URL
- Git mode automatically derives the URL from the current branch and commit hash — no manual naming
- Zero port exposure — all traffic flows through the zrok tunnel with auto-HTTPS
- WebSocket streaming works out of the box, including heartbeats and bidirectional messaging
- Same stack for dev and prod — only the share mode and hostname change
- Parallel stacks on one machine — run multiple branches simultaneously, each with its own URL, on a single host
Since every stack runs on an isolated Docker network with zero host port exposure, you can run as many instances as your machine can handle — simultaneously. Each gets its own zrok tunnel and unique public URL.
# Clone into separate directories per branch
git worktree add ../myapp-feature-auth feature/auth
git worktree add ../myapp-feature-payments feature/payments
# Launch both — each gets its own isolated stack and URL
(cd ../myapp-feature-auth && ZROK_SHARE_MODE=git docker compose -p auth --profile git up -d)
(cd ../myapp-feature-payments && ZROK_SHARE_MODE=git docker compose -p payments --profile git up -d)
# Two live URLs, one machine, no port conflicts
docker compose -p auth logs zrok | grep "PUBLIC URL"
# → https://appauthab12cd34.domain.com
docker compose -p payments logs zrok | grep "PUBLIC URL"
# → https://apppayments9f8e7d6c.domain.comThe -p flag gives each stack a unique Docker Compose project name, ensuring networks, volumes, and containers don't collide. Combined with git worktrees, you can preview every active branch on a single dev machine or CI runner without any infrastructure changes.
- Agentic CI/CD: Let AI agents spin up preview environments for every branch they create
- Pull request previews: Reviewers click a link to see the live change, not just a diff
- QA and staging: Isolated environments per feature branch, teared down on merge
- Parallel branch testing: Run 5, 10, or 20 branches on one machine — each with its own live URL
- Demo environments: Spin up a temporary public URL for stakeholder review
- Pair programming: Share a live environment with a teammate via URL
- Webhook testing: Give external services a stable callback URL during development
Internet
│
▼ (auto HTTPS)
┌──────────────────────────────┐
│ zrok (openziti/zrok) │
│ self-hosted @ zrok.domain.com│
└──────────────┬───────────────┘
│ http://proxy:80
┌──────────────▼───────────────┐
│ nginx reverse proxy │
│ │
│ / → frontend:5173 │
│ /api/* → backend:8000 │
│ /ws/* → backend:8000 (ws) │
└──────────────────────────────┘
│ │
┌────▼──┐ ┌───▼────┐
│ Vite │ │FastAPI │
│ React │ │Python │
└───────┘ └────────┘
All services run on an internal Docker network with no ports exposed to the host. The only external access is through the zrok tunnel.
# 1. Configure
cp .env.example .env
# Edit .env — set ZROK_ENABLE_TOKEN at minimum
# 2. Launch
./up.sh
# 3. Get your public URL (printed automatically by up.sh, or manually)
./get-url.sh # Print URL
./get-url.sh --wait # Wait for stack to be ready, then print
./get-url.sh --open # Open in default browserzrok generates a random URL. Good for quick testing.
ZROK_SHARE_MODE=ephemeralURL appears in docker compose logs zrok as something like https://abc123xyz.domain.com.
Predictable URL derived from a name you choose. The public URL becomes https://{ZROK_UNIQUE_NAME}.{ZROK_SHARE_DOMAIN}.
ZROK_SHARE_MODE=reserved
ZROK_UNIQUE_NAME=appdev
ZROK_SHARE_DOMAIN=domain.com
# → https://appdev.domain.comzrok constraint: Unique names must be lowercase alphanumeric only (no dashes/underscores), 4-32 characters.
Automatically generates a unique name from the current git state. An alpine/git init container computes the name inside Docker — no wrapper scripts needed.
# Set mode in .env or pass as env var, add --profile git
ZROK_SHARE_MODE=git docker compose --profile git up -d
# Or use the convenience wrapper:
./up.sh # auto-detects git mode from .env and adds --profile gitZROK_SHARE_MODE=git
ZROK_GIT_PREFIX=app
ZROK_GIT_FORMAT={prefix}{hash}
ZROK_GIT_DIRTY_ENABLED=true
ZROK_GIT_DIRTY_WORD=dirtyExamples with different formats:
| Format | Clean | Dirty |
|---|---|---|
{prefix}{hash} |
appa1b2c3d |
appa1b2c3ddirty |
{prefix}{branch}{hash} |
appmaina1b2c3d |
appmaina1b2c3ddirty |
{prefix}{hash}{branch} |
appa1b2c3dmain |
appa1b2c3dmaindirty |
Configuration:
| Variable | Default | Description |
|---|---|---|
ZROK_GIT_PREFIX |
app |
Prefix for the generated name |
ZROK_GIT_FORMAT |
{prefix}{hash} |
Name template. Tokens: {prefix}, {branch}, {hash} |
ZROK_GIT_DIRTY_ENABLED |
true |
Append dirty suffix when working tree has uncommitted changes |
ZROK_GIT_DIRTY_WORD |
dirty |
The suffix word (e.g. dirty, wip, dev) |
Non-alphanumeric characters are stripped from all tokens. Final name is capped at 32 characters (zrok limit).
Set PUBLIC_URL directly. Works with external ingress (k8s Traefik, etc.) where zrok may not be involved.
PUBLIC_URL=https://app.domain.comThe core idea: each branch/commit gets its own isolated, publicly-accessible environment.
# Git mode — name computed automatically inside Docker
ZROK_SHARE_MODE=git docker compose --profile git up -d
# → https://appfeatureautha1b2c3d.domain.com
# Check the computed name
docker compose logs git-info
# Tear down when done
docker compose --profile git down -vFor production, use the same stack with a fixed ZROK_UNIQUE_NAME or bypass zrok entirely with PUBLIC_URL and an external load balancer.
| Endpoint | Description |
|---|---|
GET /api/health |
Health check with hostname and timestamp |
GET /api/info |
Service metadata and public URL |
WS /ws/stream |
WebSocket streaming — heartbeats every 2s + message echo |
Single-page test UI with:
- Connection info display (current host, protocol, public URL)
- API health check panel (fetches
/api/healthon load) - WebSocket streaming test (connect, send messages, see heartbeats and echoes in real time)
Uses relative URLs (/api/..., wss://${window.location.host}/ws/...) so no hostname configuration is needed at build time.
Routes by path prefix with WebSocket upgrade support:
| Path | Target | Notes |
|---|---|---|
/ |
frontend:5173 | Includes Vite HMR WebSocket passthrough |
/api/* |
backend:8000 | Standard HTTP proxy |
/ws/* |
backend:8000 | WebSocket upgrade, 24h timeout |
Custom entrypoint script (scripts/zrok-entrypoint.sh) that:
- Enables the zrok environment (idempotent — skips on restart)
- Waits for the proxy to be reachable
- Starts either a reserved or ephemeral share
- Writes the public URL to a shared volume for other services to read
- Persists zrok state in a named volume across restarts
Building per-branch preview environments with tunnels and Docker Compose surfaces several non-obvious problems:
Dynamic hostname propagation — When a tunnel generates a random URL at runtime, how do the frontend and backend learn it? We solved this by designing the frontend with relative URLs and window.location.host for WebSocket connections. The backend reads the URL from a shared Docker volume. No build-time hostname needed.
zrok naming constraints — zrok v1 unique names must be lowercase alphanumeric only, 4-32 characters. No dashes, underscores, or dots. Git branch names like feature/auth-v2 must be sanitized. The compute-git-name.sh script strips all non-alphanumeric characters automatically.
Git state inside Docker — Computing the share name from git requires access to the repo inside a container. We use an alpine/git init container with Docker Compose profiles so it only runs when git mode is active. The computed name is passed to zrok via a shared volume — no wrapper scripts required.
Container permissions — The zrok Docker image runs as user ziggy (uid 2171), which can't write to root-owned shared volumes. The entrypoint runs as root to ensure volume write access while keeping the zrok process functional.
Ephemeral URL discovery — In ephemeral mode, the URL isn't known until zrok starts sharing. We capture it from zrok's JSON log output using a FIFO pipe and write it to the shared volume for other services.
WebSocket passthrough — nginx must be configured with proxy_http_version 1.1, Upgrade, and Connection headers for WebSocket connections. The proxy handles both Vite HMR WebSockets (dev) and application WebSocket streams.
See .env.example for full documentation. Key variables:
| Variable | Required | Default | Description |
|---|---|---|---|
ZROK_API_ENDPOINT |
yes | — | Self-hosted zrok controller URL |
ZROK_ENABLE_TOKEN |
yes | — | Account enable token |
ZROK_SHARE_MODE |
no | reserved |
reserved, ephemeral, or git |
ZROK_UNIQUE_NAME |
reserved mode | — | Name for the share (becomes subdomain) |
ZROK_SHARE_DOMAIN |
no | domain.com |
Domain suffix for public URL |
ZROK_TARGET_CONTAINER |
no | proxy |
Which container zrok tunnels to |
ZROK_TARGET_PORT |
no | 80 |
Port on target container |
PUBLIC_URL |
no | (computed) | Override the public URL entirely |
ZROK_GIT_PREFIX |
git mode | app |
Prefix for generated name |
ZROK_GIT_FORMAT |
git mode | {prefix}{hash} |
Name template |
ZROK_GIT_DIRTY_ENABLED |
no | true |
Append suffix for uncommitted changes |
ZROK_GIT_DIRTY_WORD |
no | dirty |
The dirty suffix word |
CORS_ALLOWED_ORIGINS |
no | * |
Comma-separated origins, or * |
BACKEND_PORT |
no | 8000 |
FastAPI listen port |
FRONTEND_PORT |
no | 5173 |
Vite dev server port |
zrok-cicd-poc/
├── docker-compose.yaml # All 4 services + volumes + network
├── up.sh # Launcher with git mode support
├── .env.example # Documented env template
├── .env # Local config (gitignored)
├── .gitignore
├── get-url.sh # Retrieve public URL (--wait, --open)
├── scripts/
│ ├── zrok-entrypoint.sh # zrok enable + share logic
│ └── compute-git-name.sh # git-info init container script
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── main.py # FastAPI: /api/*, /ws/stream
├── frontend/
│ ├── Dockerfile
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ └── App.tsx # Test UI with WS streaming
└── proxy/
├── Dockerfile
└── nginx.conf.template # envsubst-based routing config
A common challenge with tunnels is getting the dynamically-generated public URL to services that need it. This project sidesteps the problem:
- Frontend uses relative URLs (
/api/...) andwindow.location.hostfor WebSocket connections — no hostname needed at build time - Backend uses
CORS_ALLOWED_ORIGINS=*for dev/CI, explicit origins for production - zrok writes the URL to a shared Docker volume at
/shared/public_url— the backend reads it for the/api/infoendpoint
For reserved mode, the URL is known before any service starts (https://{name}.{domain}). For ephemeral mode, it's discovered at runtime and propagated via the shared volume.
# Stop everything
docker compose down
# Stop and remove volumes (clears zrok environment state)
docker compose down -vPull requests, issues, and feedback are welcome. If you're using this as a building block for your own agentic CI/CD pipeline, we'd love to hear about it.
Source-available, non-commercial. See LICENSE for details. Commercial use requires a written license from BASG CORP.
Built by BASG — Leaders in AI.