Development
15 min read
59 views

Programmatic Tunnels for CI/CD Pipelines: Automating Ephemeral URLs for Webhook Testing

IT
InstaTunnel Team
Published by the InstaTunnel team | Editorial policy
Programmatic Tunnels for CI/CD Pipelines: Automating Ephemeral URLs for Webhook Testing

Quick answer

Programmatic Tunnels for CI/CD: Automated Webhook & Endpoint: quick comparison answer

Choose the tunnel tool based on the network model: public HTTPS URLs for webhooks and demos, private mesh access for internal apps, and managed infrastructure when policy controls matter most.

Which tunnel tool is best for public webhook testing?

Use a public HTTPS localhost tunnel with stable URLs. InstaTunnel focuses on webhook testing, demos, OAuth callbacks, and MCP endpoint workflows.

When should I choose a private network tool instead?

Choose a private mesh or Zero Trust tool when every user and service should stay inside a controlled private network.

For years, local tunnels were strictly the domain of individual developers running CLI binaries on their laptops. If you needed to test a Stripe payment or a GitHub event, you opened a terminal, ran a command, copied the URL, and manually pasted it into a dashboard. In 2026, that workflow still exists — but advanced QA and platform teams increasingly skip it entirely. Instead of running a CLI binary by hand, they spawn ephemeral public URLs programmatically, straight from a test suite or a CI/CD job. By importing a tunneling library directly into Node.js code, engineering teams get fully automated endpoint testing and true end-to-end webhook validation, with no human ever touching a terminal.

The Shift from CLI to Code

Command-line tunneling tools are fantastic for local development. Tools like ngrok, Pinggy, and Cloudflare Tunnel have perfected the developer experience of sharing a localhost port with one command. But CLIs introduce friction the moment you move into automation. If a CI/CD pipeline needs to verify that your application correctly processes an incoming webhook, a standalone binary is clumsy: you have to spawn it as a detached background process, scrape its stdout to extract a dynamically generated URL, and manage its lifecycle carefully so it doesn’t leave orphaned processes hanging around the runner.

A programmatic tunnel solves this cleanly. Instead of shelling out to an external binary, you import a library directly into your test suite or application code. The tunnel becomes just another asynchronous function call that resolves to a public URL, ready to hand to a headless browser instance or a third-party API.

The Legacy of npm’s localtunnel, and Where Developers Go Instead

Historically, Node.js developers reached for the localtunnel npm package to solve this. It was genuinely useful because it let you expose a port in a few lines of JavaScript:

const localtunnel = require('localtunnel');

(async () => {
  const tunnel = await localtunnel({ port: 3000 });

  // e.g. https://abcdefgjhij.loca.lt
  console.log(tunnel.url);

  tunnel.on('close', () => {
    // tunnel is closed
  });
})();

The project is still on npm and still works, but its GitHub history tells its own story: the original repository went through long stretches with little maintainer activity, and the community responded by spinning up more than a dozen independent forks and wrapper packages (tunnelout, various Dockerized localtunnel-server images, language ports, and so on) to keep the ecosystem alive. That pattern — lots of forks, inconsistent upstream attention — is usually a sign that a team shouldn’t build CI infrastructure on the free hosted loca.lt service without a fallback plan. By 2026, most teams building programmatic tunnels for testing pipelines have moved to newer, more actively maintained alternatives.

Here’s an honest look at where they’ve landed, with corrected, verified code for each:

Tunnelmole

Tunnelmole is a fully open source tunneling tool — the client is MIT licensed and the backing service is AGPLv3 — meaning both pieces can be audited or self-hosted if you don’t want to depend on a third party’s uptime. It’s written natively in TypeScript for the Node.js ecosystem rather than wrapping an external binary.

The real programmatic API (this corrects an invented serve() function that circulated in an earlier draft of this piece) is a single function, exported as both an ES module and a CommonJS module:

// ESM
import { tunnelmole } from 'tunnelmole';

// or CommonJS
// const tunnelmole = require('tunnelmole/cjs');

const url = await tunnelmole({ port: 3000 });
// url = https://idsq6j-ip-157-211-195-169.tunnelmole.net

The function is async and returns the assigned public URL directly, so it drops straight into a beforeAll hook. Two details matter for CI specifically:

  • Tunnelmole collects anonymized telemetry (Node version, OS, crash reports) by default. Set TUNNELMOLE_TELEMETRY=0 in the environment to disable it on a runner.
  • Set TUNNELMOLE_QUIET_MODE=1 to suppress the console banner it normally prints, which keeps CI logs cleaner.
  • Custom, stable subdomains require a paid plan on the hosted service or a self-hosted instance — the free tier always hands back a random subdomain, which is usually fine for ephemeral test runs anyway.

ngrok’s official Node.js SDK

The original draft of this article said ngrok’s Node SDK “wraps its closed-source Go binary,” which was true of an older, unofficial ngrok npm wrapper (the one that downloads and spawns the ngrok executable as a child process) — but it is not how ngrok’s current official SDK works. @ngrok/ngrok is described by ngrok itself as requiring no binaries at all; it’s a native Node.js binding built on ngrok’s own Rust libraries, not a child_process wrapper.

const ngrok = require("@ngrok/ngrok");

(async function () {
  const listener = await ngrok.forward({
    addr: 8080,
    authtoken_from_env: true, // reads NGROK_AUTHTOKEN
  });

  console.log(`Ingress established at: ${listener.url()}`);
})();

You still need an authtoken from a free ngrok account for most features (custom domains, longer-lived sessions, etc.), set as NGROK_AUTHTOKEN in your CI secrets. But the “wraps a binary” criticism applies to the legacy ngrok community package, not the SDK teams should actually be reaching for in 2026.

Pinggy: SSH is the API, not a special SDK

Pinggy doesn’t ship a dedicated Node.js SDK the way ngrok or LocalXpose do. What it ships is:

  1. An actively maintained, official CLI (npm install -g pinggy, requiring Node.js 18+), which prints the generated pinggy.link URL to stdout and can be spawned as a child process from a test script, or
  2. Nothing more exotic than standard SSH remote port forwarding, which you can automate directly with a library like ssh2 instead of shelling out to the ssh binary at all: ssh -p 443 -R0:localhost:3000 a.pinggy.io

That command (or its ssh2-library equivalent) is the entire “API” — Pinggy prints back a public https://<random>.pinggy.link URL once the reverse tunnel is established. Two practical notes for CI use: the free tier caps a tunnel session at roughly 60 minutes, which is plenty for a webhook-testing job but worth knowing if a pipeline stage runs long; and routing over port 443 (rather than 22) is specifically useful on runners that only allow outbound HTTPS traffic.

LocalXpose

The earlier draft undersold LocalXpose here, describing its “programmatic library support” as requiring “specific integrations.” In fact LocalXpose ships a clean, official, promise-based Node.js binding that’s arguably the most straightforward programmatic API of the bunch, supporting HTTP, TLS, TCP, and UDP tunnels from the same client object:

const LocalXpose = require('localxpose');

// Works as a rate-limited guest, or pass an access token
// (or set LOCALXPOSE_ACCESS_TOKEN in the environment)
const client = new LocalXpose();

(async function () {
  const httpTunnel = await client.http({
    to: '127.0.0.1:3000',
    region: 'us', // us, ap, or eu
  });

  console.log(`Available at ${httpTunnel.addr}`);
})();

Because LocalXpose is one of the few tools in this space with first-class UDP tunnel support, it’s worth a look if your pipeline needs to test anything beyond plain HTTP webhooks — game servers, IoT device simulators, or other UDP-based integrations.

The Hidden Risks of Unchecked Webhooks

Why go through the effort of a programmatic tunnel in a CI environment at all? Because webhooks fail silently, and silent failures are the most expensive kind.

Modern software relies heavily on event-driven integrations. Teams write thorough unit tests for their own internal APIs but rarely write automated tests for how their application handles an external vendor’s events. When Stripe changes a timestamp format or GitHub rotates a signing scheme, unit tests built on static mock payloads keep passing. The build goes green. The integration breaks in production anyway.

Automated endpoint testing over a real tunnel forces your application to receive an authentic HTTP POST request — with real headers and a real cryptographic signature — and process it correctly, before the code ever merges to the main branch.

Building a Programmatic Tunnel in Node.js

Here’s what that looks like inside a Jest or Mocha test suite, using Tunnelmole’s corrected API as the example:

import { tunnelmole } from 'tunnelmole';
import app from '../src/app.js';
import http from 'http';

let server;
let publicUrl;

beforeAll(async () => {
  // 1. Start the local server on a dynamic port
  server = http.createServer(app);
  server.listen(3000);

  // 2. Programmatically establish the tunnel
  publicUrl = await tunnelmole({ port: 3000 });

  console.log(`Test environment exposed at: ${publicUrl}`);
});

afterAll(() => {
  // 3. Clean up
  server.close();
  // Tunnelmole doesn't require an explicit teardown call for the
  // hosted service, but always close your local HTTP server so the
  // process can exit cleanly.
});

Because publicUrl is just a variable in your test scope, you can pass it to a headless Playwright instance or hand it to a third-party API (Shopify, Slack, Stripe) and tell it to deliver test events there.

Webhook Testing in GitHub Actions

The end goal of programmatic tunneling is full CI/CD integration: isolate the application in a controlled runner, boot the HTTP server, tunnel it to the public internet, and simulate a real third-party webhook against it. A typical pipeline breaks this into four phases:

Phase 1 — Environment provisioning. The workflow spins up the target application alongside any backing services it needs (Postgres, Redis) using GitHub’s native services-container support.

Phase 2 — Programmatic tunneling. A Node.js script launches the server and opens a tunnel with one of the libraries above, capturing the resulting HTTPS URL as a variable or environment output.

Phase 3 — Payload injection. The script triggers a real webhook event. For Stripe specifically, this means running two separate Stripe CLI commands, not one combined command — a detail worth getting right, since stripe trigger and stripe listen do different jobs:

# 1. In the background, forward Stripe events to the tunnel URL and
#    capture the webhook signing secret it prints out
stripe listen --forward-to "$EPHEMERAL_URL/webhooks/stripe" &

# 2. Separately, ask Stripe to actually generate a test event
stripe trigger payment_intent.succeeded

--forward-to is a flag on stripe listen, which subscribes to live test-mode events and relays them to a local (or tunneled) endpoint. stripe trigger is a different command that calls the real Stripe API to create the object that fires the event in the first place — it doesn’t take a --forward-to flag itself. Running listen in the background first, then firing trigger once it’s connected, is the pattern Stripe’s own CLI documentation describes.

Phase 4 — State verification. The test suite waits for the application to receive the webhook, asserts it returns 200 OK (so Stripe doesn’t retry), and checks the resulting state change in the test database — for example, that a subscription flipped to active.

Example GitHub Actions Workflow

name: Webhook Integration Test Suite
on:
  pull_request:
    branches: [ main ]

jobs:
  test-webhooks:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v6

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: '22'

      - name: Install Dependencies
        run: npm ci

      - name: Install Stripe CLI
        run: |
          curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
          echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
          sudo apt-get update && sudo apt-get install stripe

      - name: Run Programmatic Tunnel and Tests
        env:
          STRIPE_API_KEY: ${{ secrets.STRIPE_TEST_KEY }}
        run: npm run test:webhooks

Two updates from the original version of this workflow: actions/checkout and actions/setup-node are now on major version 6 (both v4 and v5 are behind current), and the Node.js runtime target has moved from 20 to 22 — Node.js 20 reached end-of-life in April 2026, so pinning CI to it means running on an unsupported runtime with no further security patches. Node 22 is in active/maintenance LTS through April 2027; Node 24 is the newer active-LTS choice if you want more runway.

Inside npm run test:webhooks, your JavaScript orchestrates everything: opening the tunnel, starting stripe listen in the background, calling stripe trigger via child_process, and running the assertions.

Best Practices and Security for CI Tunnels

Exposing a CI runner to the public internet, even ephemerally, calls for real governance:

Strict ephemerality. Never leave a tunnel running longer than the test needs. Use try/finally or afterAll hooks to aggressively close both the tunnel and the local server, including on test failure. A hung CI job can burn through concurrency limits and cost real money.

Mask sensitive output. If your tunnel URL or provider output contains anything sensitive, scrub it from CI logs. GitHub Actions supports this natively with the ::add-mask:: workflow command:

echo "::add-mask::$EPHEMERAL_URL"

Anything registered this way is treated as a secret and redacted from the log for the rest of the run — but register it with add-mask before it’s printed anywhere else, since masking only applies going forward. The same masking is available programmatically via core.setSecret() in the @actions/core npm package if your tunnel setup is itself a custom JavaScript Action.

Verify raw-body parsing. The single most common cause of “valid webhook rejected” bugs in production is middleware that mutates the raw HTTP body before signature verification runs. Because a real tunnel routes genuine HTTP traffic through your actual server stack, it validates that your HMAC-SHA256 signature parsing (the scheme both Stripe and GitHub use for their webhook signatures) behaves exactly as it will in production — something a static mock payload can never catch.

Handle concurrency and port collisions. CI runners frequently execute jobs in parallel. Configure your Node.js server to listen(0) so the OS assigns a random available port, then pass that port into your tunnel configuration. This avoids port collisions when multiple pull requests are being tested at the same time on shared infrastructure.

Know each tool’s session limits. Free tiers on hosted tunnel services often cap session length — Pinggy’s free tier tops out around 60 minutes, for instance. That’s rarely a problem for a webhook-testing job that runs in seconds to a few minutes, but it’s worth checking against your slowest pipeline stage before relying on a free tier in production CI.

Conclusion

The era of manually verifying webhooks by pasting a tunnel URL into a dashboard is over for teams running serious CI/CD. By moving from a standalone CLI binary to a programmatic tunnel inside a Node.js test suite, network ingress becomes just another piece of testable code. Whichever library you pick — Tunnelmole for a fully open-source, self-hostable option; ngrok’s native SDK for its maturity and dashboard tooling; Pinggy’s plain-SSH simplicity; or LocalXpose for UDP and multi-protocol coverage — the resulting pipeline is the same: external integrations get tested against real HTTP traffic, with real signatures, long before a customer ever clicks “Pay.”


Changelog

This piece was rewritten from an earlier draft using the blog’s standard fact-checking workflow: every technical claim was checked against each project’s official documentation or source repository before publishing. Changes from the original draft:

  1. Removed document metadata/formatting artifacts from the source file and reformatted into clean, properly structured Markdown with real headings and fenced code blocks (the original had run-on paragraph breaks from a source-document export).
  2. Corrected the Tunnelmole code sample. The original invented a serve() import that doesn’t exist in the package. The real, current API is tunnelmole() (or require('tunnelmole/cjs') for CommonJS), an async function that resolves to the public URL directly. Added the licensing split (MIT client / AGPLv3 service), the default telemetry behavior, and the TUNNELMOLE_QUIET_MODE / TUNNELMOLE_TELEMETRY environment variables relevant to CI use, none of which were in the original.
  3. Corrected the ngrok Node SDK characterization. The original claimed ngrok’s Node SDK “wraps its closed-source Go binary.” That’s true of an older, unofficial community ngrok npm wrapper, but ngrok’s current official SDK (@ngrok/ngrok) is explicitly documented as requiring no external binary — it’s a native Node binding built on ngrok’s Rust libraries. The article now distinguishes the two and gives a verified, current code sample for the official SDK.
  4. Corrected LocalXpose’s programmatic story. The original vaguely described LocalXpose’s library support as “requiring specific integrations.” In reality, LocalXpose publishes an official, well-documented, promise-based Node.js binding (localxpose on npm) supporting HTTP/TLS/TCP tunnels from one client object — one of the more turnkey options in this space, not a bespoke integration effort.
  5. Clarified Pinggy’s actual programmatic surface. No dedicated Node SDK exists; the real options are Pinggy’s official CLI package (spawnable as a subprocess) or hand-rolled SSH remote port forwarding via a library like ssh2. Added the free-tier ~60-minute session cap as a CI-relevant caveat, which the original omitted.
  6. Fixed the Stripe CLI example. The original combined stripe trigger with a --forward-to flag as if it were one command. --forward-to belongs to stripe listen, not stripe trigger — they’re separate commands with separate jobs. Replaced with the correct two-command pattern (run listen in the background, then call trigger separately) per Stripe’s own CLI documentation.
  7. Updated the GitHub Actions workflow. Bumped actions/checkout and actions/setup-node from v4 to v6, the current major version of both as of August 2026. Updated the Node.js runtime target from 20 (which reached end-of-life in April 2026) to 22.
  8. Softened an unverifiable reliability claim. The original asserted npm’s localtunnel package suffers “significant uptime issues and abuse” without support. Replaced with a defensible, sourced observation: the upstream repository has a documented history of long maintenance gaps, evidenced by more than a dozen independent community forks — a reasonable signal for teams evaluating it, short of unverifiable uptime statistics.
  9. Verified as accurate and left unchanged: the GitHub Actions ::add-mask:: workflow command (including that it can also be triggered programmatically via core.setSecret()), and the general framing that HMAC-SHA256 is the signature scheme used by both Stripe and GitHub webhooks.

Continue from this article into the most relevant product guides and workflows.

Related Topics

#programmatic localhost tunnel, npm localtunnel alternative, webhook testing github actions, automated endpoint testing, localtunnel npm, localtunnel package, ephemeral urls, programmatic tunneling, continuous integration tunneling, ci/cd pipeline tunneling, github actions localtunnel, node.js localtunnel, automated webhook testing, integration test tunnel, spawn ephemeral endpoints, local tunnel automation, programmatic ngrok alternative, end to end webhook testing, automated API testing, ephemeral webhook endpoints, headless tunneling tool, ci pipeline local server, localtunnel vs ngrok, nodejs webhook testing, programmatic reverse proxy, cypress webhook testing, playwright webhook testing, automated webhook verification, dynamic tunnel URL, programmatic server tunneling, pipeline webhook testing, automated QA testing tools, continuous delivery tunneling, nodejs tunnel package, mock webhook testing, ci/cd endpoint validation, automated browser testing tunnel, github workflow webhook, programmable localhost tunnel, localtunnel alternative, ci cd webhook sandbox, testing webhooks in ci, temporary public url generator, automated regression testing tunnels, headless ngrok alternative, programmatic proxy setup, ci pipeline tunnel script, expose local server in ci, webhook automation testing, continuous integration endpoint testing, programmatic port forwarding, localtunnel integration tests, automated QA pipeline tunnel

Keep building with InstaTunnel

Read the docs for implementation details or compare plans before you ship.

Share this article

More InstaTunnel Insights

Discover more tutorials, tips, and updates to help you build better with localhost tunneling.

Browse All Articles