Skip to main content
Deploy previews and CI test environments are ephemeral by nature—they spin up for a branch, get tested, and disappear. But accessing them securely and reliably can be tricky: you need a public URL for external testing tools or manual review, but you don’t want zombie previews leaking your roadmap to the public internet. ngrok makes ephemeral workloads easier by letting you:
  • Expose staging or preview builds on-demand with secure, authenticated URLs.
  • Use dynamic endpoint URLs based on branch names, PR numbers, or commit SHAs.
  • Apply Traffic Policy to add authentication, logging, and access control to every preview.
  • Clean up automatically when CI jobs finish.

What you’ll need

  • A CI/CD platform like GitHub Actions, GitLab CI, or Jenkins
  • A pay-as-you-go account for custom domains

1. Reserve a wildcard domain

Navigate to the Domains section of the ngrok dashboard and click New + to reserve a custom wildcard domain like *.preview.example.com. You’ll then need to set up CNAME records with your DNS provider. This wildcard lets you dynamically create preview URLs like pr-123.preview.example.com for each pull request or branch.

2. Create a Cloud Endpoint

Navigate to the Endpoints section of the ngrok dashboard, then click New + and Cloud Endpoint. In the URL field, enter the domain you just reserved to finish creating your Cloud Endpoint.

3. Add routing with Traffic Policy

While viewing your new Cloud Endpoint in the dashboard, copy the policy below and paste it into the Traffic Policy editor.
on_http_request:
  - actions:
      - type: forward-internal
        config:
          url: https://${req.host.split(".preview.example.com")[0]}.internal
What’s happening here? This policy dynamically routes each preview URL to a matching internal Agent Endpoint. Requests to pr-123.preview.example.com get forwarded to https://pr-123.internal, which connects to the ephemeral container or service running your deploy preview.

4. Add authentication to your previews

Deploy previews shouldn’t be publicly accessible. Use the OAuth action to require reviewers to authenticate before accessing any preview and deny all requests from those without a example.com email:
on_http_request:
  - actions:
      - type: oauth
        config:
          provider: google

  - expressions:
      - "!actions.ngrok.oauth.identity.email.endsWith('@example.com')"
    actions:
      - type: deny

  - actions:
      - type: forward-internal
        config:
          url: https://${req.host.split(".preview.example.com")[0]}.internal
This ensures only authenticated users from your domain can access previews, while keeping the dynamic routing intact.

5. Start an Agent Endpoint from your CI pipeline

In your CI/CD workflow, start an internal Agent Endpoint that matches the preview URL pattern. The exact setup depends on your CI platform, but here’s a GitHub Actions example:
name: Deploy Preview

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

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build and start app
        run: |
          # Build your app
          npm install && npm run build
          # Start your app in the background
          npm run start &
          sleep 5

      - name: Install ngrok
        run: |
          curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
            | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null
          echo "deb https://ngrok-agent.s3.amazonaws.com buster main" \
            | sudo tee /etc/apt/sources.list.d/ngrok.list
          sudo apt update && sudo apt install ngrok

      - name: Start ngrok tunnel
        env:
          NGROK_AUTHTOKEN: ${{ secrets.NGROK_AUTHTOKEN }}
        run: |
          ngrok config add-authtoken $NGROK_AUTHTOKEN
          ngrok http 3000 --url https://pr-${{ github.event.pull_request.number }}.internal &
          sleep 5

      - name: Run tests against preview
        run: |
          curl -sSf https://pr-${{ github.event.pull_request.number }}.preview.example.com

      - name: Comment preview URL
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `🚀 Deploy preview available at: https://pr-${{ github.event.pull_request.number }}.preview.example.com`
            })
What’s happening here? This workflow builds your app, starts it on port 3000, then creates an internal Agent Endpoint at https://pr-{PR_NUMBER}.internal. The Cloud Endpoint you configured earlier routes traffic from pr-{PR_NUMBER}.preview.example.com to this internal endpoint. When the job finishes, the agent stops and the preview disappears.

6. Try out your endpoint

Visit the domain you reserved either in the browser or in the terminal using a tool like curl. You should see the app or service at the port connected to your internal Agent Endpoint.

7. Route external testing tools (optional)

If you use external testing services like BrowserStack, Sauce Labs, or a third-party QA platform, they can hit your preview URLs directly. Add IP restrictions to allow only your testing provider:
on_http_request:
  - expressions:
      - "!conn.client_ip in ['<TESTING_PROVIDER_IP_RANGE>']"
    actions:
      - type: deny

  - actions:
      - type: forward-internal
        config:
          url: https://${req.host.split(".preview.example.com")[0]}.internal
Or combine OAuth for manual reviewers with IP allowlisting for automated tests:
on_http_request:
  - expressions:
      - "conn.client_ip in ['<TESTING_PROVIDER_IP_RANGE>']"
    actions:
      - type: forward-internal
        config:
          url: https://${req.host.split(".preview.example.com")[0]}.internal

  - actions:
      - type: oauth
        config:
          provider: google

  - expressions:
      - "!actions.ngrok.oauth.identity.email.endsWith('@example.com')"
    actions:
      - type: deny

  - actions:
      - type: forward-internal
        config:
          url: https://${req.host.split(".preview.example.com")[0]}.internal

What’s next?