The most common reason to want a tunnel in CI is "my E2E test needs the world to reach my dev server." Cypress / Playwright run against localhost:3000 in the same job, but webhook providers (Stripe, GitHub, Shopify) need a real public URL, and so do third-party scanners like Lighthouse CI. A Nodetonet HTTP tunnel inside a workflow takes roughly three seconds to start and half a second to tear down — well under any test budget.
TL;DR: Two curl calls — one POST to open the tunnel, one DELETE to close it — give you a public HTTPS URL for the lifetime of a CI job, for a fraction of a cent per run. Idle tunnels cost nothing, and Nodetonet uses prepaid credit with no monthly subscription.
We don't yet ship an official nodetonet/cli-action; that is on the roadmap. For now, two curl calls give you 95% of what an action would do, and they are easier to debug. The REST API is the canonical interface and will keep working forever.
Why use a tunnel in CI at all?
Runners on GitHub-hosted machines sit behind ephemeral NAT — there is no way for an external service to reach your test server directly. A Nodetonet tunnel is a persistent reverse connection from our edge to your runner: once open, any request to https://<subdomain>.nodetonet.com is forwarded to your localhost. No port-forwarding, no firewall rules, no reserved IP needed.
Compare the main options available to CI teams today:
| Approach | Cost model | Setup effort | Webhook-ready | Custom domain |
|---|---|---|---|---|
| Nodetonet tunnel | Prepaid credit, per second | Two curl calls | Yes | Yes |
| ngrok free tier | Free (limited sessions) | One binary download | Yes (limited) | Paid plan only |
| Cloudflare Tunnel | Free (persistent daemon) | Daemon install + DNS | Yes | Yes (own domain) |
| Dedicated staging server | Monthly VPS cost, always on | Provision + maintain | Yes | Yes |
| Public S3/CDN static deploy | Storage + transfer fees | Build pipeline change | No | Yes |
Nodetonet's edge advantage is that tunnels compose with mobile proxies and rotating proxies you may already run on the same panel — one prepaid balance, one dashboard, one API key. If your E2E suite also needs a clean mobile IP for outbound requests, you do not need a second vendor. See also Nodetonet vs ngrok and Nodetonet vs Cloudflare Tunnel for a detailed feature comparison.
The pattern in three steps
- At job start —
POST /api/v1/proxieswith the test server port. Capture the returned URL into$GITHUB_ENV. - During the job — your test runner uses
$TUNNEL_URLwherever it would have usedhttp://localhost. - At job end —
DELETE /api/v1/proxies/{id}in anif: always()step so cleanup runs even when tests fail.
Even if step 3 does not run (canceled job, runner died), Nodetonet auto-stops idle tunnels after 30 minutes of no traffic, and stopped tunnels cost nothing. The blast radius of a leaked tunnel is "a dangling URL until you notice it." Learn more about monitoring tunnel health and audit logs for CI usage.
A working workflow file
Drop this in .github/workflows/e2e.yml. It assumes your test server boots in the background, listens on :3000, and your tests respect TARGET_URL:
name: E2E with Nodetonet tunnel
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install deps
run: npm ci
- name: Boot test server (background)
run: npm run start:test &
env:
PORT: 3000
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Open Nodetonet tunnel
env:
NTN_TOKEN: ${{ secrets.NTN_TOKEN }}
run: |
SUB="ci-${{ github.run_id }}"
RESP=$(curl -fsS -X POST https://nodetonet.com/api/v1/proxies -H "Authorization: Bearer $NTN_TOKEN" -H "Content-Type: application/json" -d "{\"subdomain\":\"$SUB\",\"target_host\":\"127.0.0.1\",\"target_port\":3000}")
echo "TUNNEL_ID=$(echo $RESP | jq -r .id)" >> $GITHUB_ENV
echo "TUNNEL_URL=$(echo $RESP | jq -r .url)" >> $GITHUB_ENV
- name: Run Playwright against public URL
env:
TARGET_URL: ${{ env.TUNNEL_URL }}
run: npx playwright test
- name: Close Nodetonet tunnel
if: always()
env:
NTN_TOKEN: ${{ secrets.NTN_TOKEN }}
run: |
curl -fsS -X DELETE -H "Authorization: Bearer $NTN_TOKEN" https://nodetonet.com/api/v1/proxies/${{ env.TUNNEL_ID }} || true
The jq binary is pre-installed on all ubuntu-latest runners. If you are on a self-hosted runner that lacks it, install with sudo apt-get install -y jq before the tunnel step.
Webhook tests — the killer use case
If you are testing a Stripe, GitHub, or Twilio webhook integration, the provider must reach your server. localhost obviously will not do, and local-runner tricks like act do not help because the third-party service lives in its own data center.
The flow is identical to E2E — open a tunnel, register the webhook endpoint as $TUNNEL_URL/webhooks/stripe, run the test that triggers the event, assert the outcome. The only twist is wanting a predictable subdomain per branch so cleanup is easy if a run dies mid-way. Use github.head_ref for PR jobs:
- name: Open per-branch tunnel
env:
NTN_TOKEN: ${{ secrets.NTN_TOKEN }}
run: |
SUB="webhook-$(echo ${{ github.head_ref || github.ref_name }} | tr '/' '-' | tr -cd 'a-z0-9-' | cut -c1-40)"
# ... same POST as above
For a deeper look at the lifecycle of Nodetonet tunnels — including how TLS certificates are issued automatically and how custom domains work — read those companion posts.
Storing the API token securely
Two things matter for CI token hygiene:
- Use a scoped token, not your account token. Read about API tokens vs personal tokens. Generate one from your panel with the
proxies:writescope only — a leaked CI secret cannot then read billing data or invite users. - Store it as a GitHub Actions secret (Settings → Secrets and variables → Actions) and reference it as
${{ secrets.NTN_TOKEN }}. Never hard-code it in the workflow file.
If you use deployment environments (staging, prod), use a separate token per environment so audit logs show exactly which CI run opened which tunnel.
Parallel jobs and matrix builds
When you run a matrix build, each parallel job needs its own tunnel. The subdomain approach already handles this: if you include the matrix variable in the subdomain (e.g., "ci-${{ github.run_id }}-${{ matrix.node }}"), each shard gets a unique public URL and there is no collision. The same DELETE in if: always() closes only that shard's tunnel.
For large fleet builds where you want per-client controls — quotas, thread limits, IP allow-lists — see the per-client auth and thread limits posts, which describe the same model for HTTP tunnels.
Cost — what does this actually cost?
Nodetonet uses prepaid credit, billed per second of active tunnel time. A tunnel that lives for a 10-minute CI job costs a small fraction of a cent per run. A repo running E2E on every PR push (say 150 CI runs per month) typically spends well under a dollar per month on tunnels. Even a hyperactive monorepo with thousands of runs per month stays cheap. Idle tunnels — those with no active traffic — cost nothing at all. You only pay while the tunnel is open and serving requests.
For an honest comparison of costs across providers, check our Nodetonet vs ngrok and Nodetonet vs Cloudflare Tunnel pages.
Coming: an official GitHub Action
The pattern above is reliable but verbose. An official nodetonet/cli-action@v1 is on the roadmap and will collapse the four steps (POST, capture env, run tests, DELETE) into:
- uses: nodetonet/cli-action@v1
with:
token: ${{ secrets.NTN_TOKEN }}
port: 3000
# exports TUNNEL_URL automatically, auto-cleans on job end
- run: npx playwright test
env:
TARGET_URL: ${{ env.TUNNEL_URL }}
The curl pattern above will keep working forever — it is the canonical REST interface — but the action will be the one-liner you reach for first. Contact support or join the Discord to get notified when it ships.
Get started
Ready? Create a free account, generate an API token, paste the workflow above, and push a PR. Your tunnel opens in seconds. Explore the HTTP tunnels feature page for the full tunnel reference, or browse the proxy checker to verify your connection from any endpoint.