Skip to content

basgcorp/zrok-cicd-poc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

zrok-cicd-poc

Per-branch preview environments for agentic CI/CD pipelines — zero port exposure, auto-HTTPS, one docker compose up.

License Docker Compose zrok


Why This Exists

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.

The Problem

  • 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

The Solution

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

Parallel Environments on One Machine

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.com

The -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.

Use Cases

  • 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

Architecture

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.

Quick Start

# 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 browser

Share Modes

Ephemeral (default for dev)

zrok generates a random URL. Good for quick testing.

ZROK_SHARE_MODE=ephemeral

URL appears in docker compose logs zrok as something like https://abc123xyz.domain.com.

Reserved (CI/CD and production)

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.com

zrok constraint: Unique names must be lowercase alphanumeric only (no dashes/underscores), 4-32 characters.

Git (CI/CD)

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 git
ZROK_SHARE_MODE=git
ZROK_GIT_PREFIX=app
ZROK_GIT_FORMAT={prefix}{hash}
ZROK_GIT_DIRTY_ENABLED=true
ZROK_GIT_DIRTY_WORD=dirty

Examples 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).

Static / Production

Set PUBLIC_URL directly. Works with external ingress (k8s Traefik, etc.) where zrok may not be involved.

PUBLIC_URL=https://app.domain.com

CI/CD Usage

The 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 -v

For production, use the same stack with a fixed ZROK_UNIQUE_NAME or bypass zrok entirely with PUBLIC_URL and an external load balancer.

What's Included

Backend (FastAPI)

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

Frontend (Vite + React)

Single-page test UI with:

  • Connection info display (current host, protocol, public URL)
  • API health check panel (fetches /api/health on 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.

Proxy (nginx)

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

zrok Tunnel

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

Challenges and Design Decisions

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.

Environment Variables

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

Project Structure

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

Hostname Propagation

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/...) and window.location.host for 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/info endpoint

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.

Tear Down

# Stop everything
docker compose down

# Stop and remove volumes (clears zrok environment state)
docker compose down -v

Contributing

Pull 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.

License

Source-available, non-commercial. See LICENSE for details. Commercial use requires a written license from BASG CORP.

Built by BASG — Leaders in AI.

About

Per-branch preview environments with auto-HTTPS using self-hosted zrok and Docker Compose — parallel stacks, zero port exposure, one command

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors