The Problem: Reviewing PRs Without a Live Preview Is Slow

You open a pull request diff on GitHub and… you see code. What you do not see is how the application actually behaves. To verify a PR manually, you have to switch branches locally, install dependencies, start the project, and click through scenarios by hand. That takes 10 to 15 minutes per PR, and most reviewers simply skip it.

The outcome is predictable: bugs slip into main, QA catches issues after the merge, and fixes turn into yet more pull requests. The feedback cycle stretches from minutes to hours or days. A local tunnel combined with CI/CD can fix this: every PR gets its own public URL with a running application.

Traditional Approaches to Preview Environments

Preview environments are not a new idea. Vercel and Netlify have been creating preview deployments for every PR for years. But these solutions share a serious limitation: they only work for the frontend.

Vercel / Netlify: Frontend Only

Vercel deploys previews for Next.js, Astro, and other frameworks with SSR or static generation. Netlify does the same for static sites and serverless functions. Both services are excellent for frontend projects.

But if your application is a Go backend with PostgreSQL, a Python API with Celery and Redis, or a Java monolith, Vercel will not help. You need a preview for the full stack, not just the frontend.

Staging Server: One for Everyone

The classic staging server is a shared environment where changes are deployed for testing. The problem is that there is one staging but many PRs. Two developers deploy simultaneously, and one set of changes overwrites the other. The staging queue slows down the entire team.

Kubernetes Per-PR Namespaces: Powerful but Complex

Some teams create a separate Kubernetes namespace for every PR. This works, but it requires serious infrastructure: Helm charts, Ingress controllers, wildcard DNS, automated cleanup. For a team of 5 to 10 people, it is overkill.

Full-Stack Preview with Tunnels: The Idea

The approach is straightforward. The CI runner builds and starts your application, fxTunnel creates a public URL pointing to it, and a bot comments on the PR with the link. The reviewer clicks the link and sees a working application.

PR opened
    |
    v
CI runner (GitHub Actions)
    |
    |-- 1. Build the app (docker compose up)
    |-- 2. Start fxTunnel -> get a public URL
    |-- 3. Comment on the PR with the preview link
    |-- 4. Wait (or run E2E tests)
    |
PR closed / merged
    |
    +-- 5. Tunnel closes automatically

The benefits are clear: the preview works with any stack, no separate infrastructure is needed, and the tunnel shuts down automatically when the CI job finishes.

GitHub Actions: Full Workflow

Below is a ready-to-use workflow for GitHub Actions. It builds the application with Docker Compose, starts fxTunnel, and comments on the PR with the public URL. You can copy it and adapt it to your project.

Main Workflow

# .github/workflows/preview.yml
name: PR Preview Environment

on:
  pull_request:
    types: [opened, synchronize, reopened]

concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  preview:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Start application
        run: |
          docker compose -f docker-compose.preview.yml up -d --build
          # Wait until the app is reachable
          for i in $(seq 1 30); do
            curl -sf http://localhost:8080/health && break
            echo "Waiting for app to start... ($i/30)"
            sleep 2
          done

      - name: Install fxTunnel
        run: curl -fsSL https://fxtun.dev/install.sh | bash

      - name: Start tunnel and get URL
        id: tunnel
        run: |
          # Start the tunnel in the background and capture the URL
          fxtunnel http 8080 --log-file /tmp/tunnel.log &
          TUNNEL_PID=$!
          echo "tunnel_pid=$TUNNEL_PID" >> "$GITHUB_OUTPUT"

          # Wait for the URL to appear in the logs
          for i in $(seq 1 15); do
            TUNNEL_URL=$(grep -oP 'https://[a-z0-9-]+\.fxtun\.dev' /tmp/tunnel.log || true)
            if [ -n "$TUNNEL_URL" ]; then
              echo "preview_url=$TUNNEL_URL" >> "$GITHUB_OUTPUT"
              echo "Preview URL: $TUNNEL_URL"
              break
            fi
            sleep 1
          done

          if [ -z "$TUNNEL_URL" ]; then
            echo "Failed to get tunnel URL"
            exit 1
          fi

      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const previewUrl = '${{ steps.tunnel.outputs.preview_url }}';
            const body = `## Preview Environment

            | | |
            |---|---|
            | **URL** | ${previewUrl} |
            | **Commit** | \`${context.sha.substring(0, 7)}\` |
            | **Status** | Active |

            This preview will be available while the CI job is running.`;

            // Delete the old comment if it exists
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            const botComment = comments.data.find(c =>
              c.body.includes('## Preview Environment')
            );
            if (botComment) {
              await github.rest.issues.deleteComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
              });
            }

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: body,
            });

      - name: Run E2E tests against preview
        run: |
          npx playwright test --base-url=${{ steps.tunnel.outputs.preview_url }}

      - name: Keep preview alive for manual testing
        if: success()
        run: |
          echo "Preview is live at: ${{ steps.tunnel.outputs.preview_url }}"
          echo "Keeping alive for 20 minutes for manual review..."
          sleep 1200

      - name: Cleanup
        if: always()
        run: |
          kill ${{ steps.tunnel.outputs.tunnel_pid }} || true
          docker compose -f docker-compose.preview.yml down

Docker Compose for Preview

# docker-compose.preview.yml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/preview
      - REDIS_URL=redis://redis:6379
      - APP_ENV=preview
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: preview
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 2s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine

For more on using Docker with tunnels, see Docker + tunnel.

GitLab CI: Brief Example

Here is the equivalent workflow for GitLab CI. The principle is the same: start the application, create a tunnel, comment on the merge request.

# .gitlab-ci.yml
preview:
  stage: test
  image: docker:24
  services:
    - docker:24-dind
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - docker compose -f docker-compose.preview.yml up -d --build
    - curl -fsSL https://fxtun.dev/install.sh | bash
    - fxtunnel http 8080 --log-file /tmp/tunnel.log &
    - sleep 5
    - TUNNEL_URL=$(grep -oP 'https://[a-z0-9-]+\.fxtun\.dev' /tmp/tunnel.log)
    - |
      curl --request POST \
        --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
        --header "Content-Type: application/json" \
        --data "{\"body\": \"Preview: ${TUNNEL_URL}\"}" \
        "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes"
    - echo "Preview live at ${TUNNEL_URL}"
    - sleep 1200
  after_script:
    - docker compose -f docker-compose.preview.yml down

What Tunnel Preview Gives Your Team

What changes when every PR comes with a live link? Quite a lot, actually.

Test Webhooks Directly from a PR

If your application receives webhooks from Stripe, GitHub, or Telegram, the preview environment lets you point a test webhook at the PR URL and verify the integration before merging. A QA engineer can trigger a test payment in Stripe and see how the application handles it.

Share with QA and Designers

The link in the PR comment lets anyone on the team open the preview in a browser without cloning the repository, installing dependencies, or starting the project locally. A designer checks the UI, a product manager verifies business logic, and QA explores edge cases.

Run E2E Tests Against a Real URL

The public HTTPS URL from fxTunnel can be passed to Playwright, Cypress, or Selenium. Tests run against an application that operates under near-production conditions: real HTTP, TLS, DNS resolution. This is more reliable than testing against localhost.

Test Mobile Integrations

If a mobile app connects to the backend, QA can enter the PR URL into a test build configuration and verify the integration on a real device.

Cost Comparison: Tunnel Preview vs Alternatives

ApproachCostFull stackPR isolationSetup time
fxTunnel (free)$0YesYes10 minutes
fxTunnel (paid)from $5/moYesYes10 minutes
Vercel preview$0-20/moFrontend onlyYes5 minutes
Netlify preview$0-19/moFrontend onlyYes5 minutes
Staging server$20-100/moYesNo (shared)Hours
K8s per-PR namespaces$50-200/moYesYesDays

For full-stack projects, fxTunnel gives you an isolated preview for every PR with support for any stack at minimal cost. Vercel and Netlify remain a strong choice for purely frontend projects. You can also see how fxTunnel stacks up against ngrok and Cloudflare Tunnel.

Cleanup: Closing the Tunnel When a PR Is Merged or Closed

A tunnel started inside a CI job closes automatically when the job finishes. But if you use sleep to keep the preview alive, you need a mechanism to cancel it when the PR is merged or closed.

GitHub Actions handles this through concurrency and a separate workflow that fires on PR close.

Cleanup Workflow on PR Close

# .github/workflows/preview-cleanup.yml
name: Cleanup Preview

on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Cancel preview workflow
        uses: actions/github-script@v7
        with:
          script: |
            // Find active workflow runs for this PR
            const runs = await github.rest.actions.listWorkflowRuns({
              owner: context.repo.owner,
              repo: context.repo.repo,
              workflow_id: 'preview.yml',
              status: 'in_progress',
            });

            for (const run of runs.data.workflow_runs) {
              // Check that the run belongs to our PR
              if (run.pull_requests.some(pr => pr.number === context.issue.number)) {
                await github.rest.actions.cancelWorkflowRun({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  run_id: run.id,
                });
                console.log(`Cancelled workflow run ${run.id}`);
              }
            }

      - name: Update PR comment
        uses: actions/github-script@v7
        with:
          script: |
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            const botComment = comments.data.find(c =>
              c.body.includes('## Preview Environment')
            );
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: `## Preview Environment\n\n**Status:** Closed (PR ${context.payload.action})`,
              });
            }

The key detail is the concurrency section in the main workflow. The cancel-in-progress: true parameter ensures that when a new commit is pushed to the PR, the old preview job is cancelled and a new one starts with updated code. When the PR is closed, the separate cleanup workflow cancels the active preview.

Tips for Production-Ready Setup

Cache Docker Layers

Building Docker images on every push can be slow. Use layer caching to speed things up:

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Cache Docker layers
        uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
          restore-keys: ${{ runner.os }}-buildx-

Health Check Before Opening the Tunnel

Do not start the tunnel until the application is ready. Use a health check endpoint:

      - name: Wait for application
        run: |
          for i in $(seq 1 30); do
            if curl -sf http://localhost:8080/health; then
              echo "Application is ready"
              exit 0
            fi
            echo "Waiting... ($i/30)"
            sleep 2
          done
          echo "Application failed to start"
          exit 1

Limit Preview Lifetime

Set a reasonable timeout-minutes for the job and a sensible sleep duration. 20 to 30 minutes is usually enough for manual testing. For automated E2E tests, the preview only needs to stay alive for the duration of the test run.

FAQ

What is a preview environment for a pull request?

Think of it as a disposable deployment that spins up automatically when someone opens a PR. Reviewers get a public URL they can click to see the running application. With fxTunnel, a tunnel starts directly on the CI runner, so no dedicated server is needed.

How does tunnel preview differ from Vercel preview deployments?

Vercel and Netlify target the frontend – static sites and SSR apps. A tunnel-based preview works with any stack: Go backends, Python APIs, Java monoliths, databases, queues. If it runs on the CI runner, it is accessible through the tunnel’s public URL.

Is it safe to expose a CI runner through a tunnel?

For preview environments running test data, yes. fxTunnel encrypts all traffic via TLS. Stick to test databases and test API keys, and keep production secrets out of the workflow. The tunnel shuts down automatically when the CI job ends.

Can I run E2E tests against a tunnel preview URL?

Absolutely. The tunnel gives you a public HTTPS URL that you can hand to Playwright, Cypress, or any other E2E framework. Your tests run against an application behind real HTTP, TLS, and DNS – much closer to production than localhost.

How much does tunnel preview cost compared to a staging server?

fxTunnel is free for basic use, and even the paid plan at $5/mo costs far less than a single staging server ($20-100/mo). The real win is isolation: every PR gets its own environment instead of a shared staging where developers step on each other’s changes.