29 min read

How to Run a CI/CD Pipeline That Tests Against a Live Local Environment

Run database integration tests inside GitHub Actions using service containers, and test webhook handlers against your local code using a Localtonet HTTP tunnel. No staging server needed.

Two testing architectures: a CI job with a database container and a public tunnel to a local application.
Database tests stay inside the CI job, while webhook tests reach local code through a temporary tunnel.
CI/CD · GitHub Actions · Integration Testing · Localtonet · 2026

Choose the correct trust boundary before connecting a pipeline to a live test service

Integration tests can either build their dependencies inside a CI runner or call an environment maintained outside the runner. Those architectures have different networking, readiness, security, and cleanup requirements. This guide provides a self-contained GitHub Actions pattern using service containers, then shows how an intentionally controlled job or webhook provider can reach a developer-managed test application through a temporary Localtonet HTTP tunnel. It also covers authentication, isolated test data, webhook verification, timeouts, artifacts, troubleshooting, and tunnel cleanup.

🔒 Explicit trust boundaries and test-only credentials 🌐 Runner-local and public HTTPS architectures ⚡ Readiness checks, assertions, artifacts, and cleanup

Two architectures with different trust boundaries

The phrase “test against a live local environment” can describe two fundamentally different systems. In the first, the database, cache, application, and tests all run within a GitHub Actions job. In the second, the application remains on a developer-controlled machine and receives requests from a CI runner or an external provider through a public tunnel.

Keep these models separate when designing the pipeline. A self-contained runner is usually the best default for pull requests because it is repeatable and does not depend on a workstation. A tunnel is appropriate when the test specifically requires an internet-originated request, a provider callback, a device outside the local network, or behavior that only exists in a developer-managed environment.

🐳 Architecture A: self-contained runner A Linux GitHub-hosted runner starts service containers, installs the application, applies migrations, waits for application readiness, runs assertions, uploads results, and destroys the job environment automatically.
🌐 Architecture B: temporary public tunnel The Localtonet client makes an outbound connection from a developer-controlled device. A temporary public HTTPS URL forwards requests to the selected local IP address and port while the client is connected and the tunnel is running.
🪝 External provider callback A provider sends a webhook or redirects a browser to the public URL. The local application authenticates the request using the provider’s documented signature or protocol controls and records an observable test result.
🔐 Different secret locations Runner-only database credentials can remain inside the CI job. Tunnel tests require separate local application secrets, CI secrets, and provider signing secrets, each granted only the permissions needed for the test.

What a hosted runner can and cannot receive

A third-party service normally cannot initiate a connection to an unexposed GitHub-hosted runner. The runner makes outbound connections, while its application ports are not automatically published as stable internet endpoints. It is technically possible to add a separate inbound exposure mechanism, but that is a different architecture and requires a verified installation, authentication, URL-discovery, lifecycle, and security workflow.

This guide does not claim that the Localtonet client can be installed or automated inside GitHub Actions because no verified Localtonet command sequence or URL-discovery workflow has been supplied for that use case. Instead, the tunnel architecture keeps the Localtonet client on the developer-controlled device and passes the assigned public URL into a deliberately triggered CI job.

Requirement Self-contained CI job Developer-managed tunnel
Pull request database and API tests Preferred. The environment is recreated for each job. Usually unnecessary and introduces workstation availability as a dependency.
Internet-originated webhook Not directly reachable unless a separate inbound mechanism is configured. Suitable when the provider is configured with the temporary public HTTPS endpoint.
OAuth or browser callback Useful only if the complete callback environment is intentionally exposed. Suitable when the registered callback URI, application origin, cookies, and proxy behavior are aligned.
Test data Fresh fixtures in an isolated service container or test database. Dedicated local test database with sanitized fixtures and test accounts.
Repeatability High when dependencies and images are pinned and fixtures are deterministic. Depends on the developer machine, local state, tunnel URL, and environment availability.
Cleanup owner The workflow and runner lifecycle. The device operator or an independently verified external orchestrator.
Do not test against production records

Neither architecture should use production data merely because it is convenient. Use isolated fixtures, synthetic identities, sandbox provider accounts, and sanitized data. A tunnel to an HTTP application port does not provide direct access to a local database, and the application should not expose database administration functions through the public endpoint.

Prerequisites and repository responsibilities

A pipeline file cannot make an application testable by itself. Before adding either workflow, define the commands, health endpoints, credentials, and expected results that belong to the repository. The examples below use conventional names, but your repository must actually implement them.

Requirements for the self-contained GitHub Actions job

  • A GitHub repository with Actions enabled.
  • A Linux runner with Docker available. GitHub Actions service containers require a Linux runner and Docker.
  • A lockfile compatible with npm ci, such as package-lock.json.
  • An npm run migrate script that applies the test schema.
  • An npm run start:test script that starts the application without detaching itself unexpectedly.
  • An npm run test:integration script that exits nonzero when assertions fail.
  • An application readiness endpoint, represented below as /health/ready.
  • Deterministic fixture or seed logic that never imports production records.
  • A test reporter configured to write machine-readable results if you want to upload them as artifacts.

Container image tags should identify a deliberate version. A broad tag can change underneath the pipeline. For stronger supply-chain reproducibility, use the image digest approved by your project. GitHub Actions references can also be pinned to reviewed commit SHAs when your security policy requires immutable dependencies.

Requirements for a tunneled environment

  • A dedicated local test application and database, not a production application copied onto a workstation.
  • The Localtonet client installed and running on the device that can reach the application.
  • A device-specific AuthToken selected without exposing it in logs, screenshots, workflow inputs, or source control.
  • A current Localtonet relay server selected from the options available in the product.
  • A verified local IP address and port for the HTTP application.
  • A protected readiness route and test API, or a provider-signed webhook route.
  • A test account or sandbox account for the external provider.
  • An operator responsible for starting the environment, monitoring it, and stopping or deleting the tunnel afterward.
Service-container networking depends on the job layout

When job steps run directly on the Linux runner, publish a service port and connect through localhost. When the job itself runs in a container, service containers share a Docker network and are reached by their service label, such as postgres:5432. Host port mapping is not required for communication between those job and service containers.

Architecture A: build a complete integration-test job

Integration-test job starting a database service, waiting for readiness, running tests, and recording the result.
The CI job starts its database dependency, checks readiness, and runs integration tests within one isolated boundary.

The following Node.js workflow creates an isolated PostgreSQL service, installs locked dependencies, applies migrations, starts the application, waits for application-level readiness, runs integration tests, collects artifacts, and terminates the application during cleanup. It also prevents overlapping runs for the same workflow and branch or pull request.

name: Integration Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: integration-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  integration:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    services:
      postgres:
        image: postgres:16.4-alpine
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpassword
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 12

    env:
      NODE_ENV: test
      DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/testdb
      TEST_BASE_URL: http://127.0.0.1:3000

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm

      - name: Install locked dependencies
        run: npm ci

      - name: Apply test database migrations
        run: npm run migrate

      - name: Start test application
        shell: bash
        run: |
          npm run start:test > application.log 2>&1 &
          echo $! > application.pid

      - name: Wait for application readiness
        shell: bash
        run: |
          for attempt in {1..60}; do
            if curl --silent --show-error --fail \
              --max-time 2 \
              "$TEST_BASE_URL/health/ready"; then
              exit 0
            fi
            sleep 2
          done

          echo "Application did not become ready within 120 seconds."
          cat application.log || true
          exit 1

      - name: Run integration assertions
        run: npm run test:integration

      - name: Upload logs and test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: integration-results
          path: |
            application.log
            test-results/
          if-no-files-found: warn
          retention-days: 7

      - name: Stop test application
        if: always()
        shell: bash
        run: |
          if [ -f application.pid ]; then
            kill "$(cat application.pid)" 2>/dev/null || true
          fi

Why both container health and application readiness matter

The PostgreSQL health command reports the health status of the database container. It reduces startup races, but it does not prove that your application has connected successfully, that migrations completed, or that the API can serve a real request. The separate readiness loop tests the application after migrations and startup.

A readiness route should check only the dependencies required for the test and return promptly. Do not place secrets, database contents, stack traces, or administrative controls in its response. If the route is included in production builds, restrict the information it reveals.

Credentials and fixtures

The PostgreSQL credentials in this example exist only inside the disposable CI job. They are not suitable for a shared or production database. If a test requires a third-party sandbox credential, store it as a GitHub Actions secret and expose it only to the step that needs it. Be especially careful with workflows triggered from forks because repository secrets are intentionally restricted in many forked pull-request contexts.

Run migrations before seeding fixtures, give each test an independent record namespace, and clean state between tests. Common application-level strategies include transaction rollback, a schema per suite, or a database per worker. The service container gives isolation between jobs, but tests within one job can still conflict when they share records or run concurrently.

Python variation with PostgreSQL and Redis

The same architecture works for Python when the repository defines its dependency and test commands. The following service configuration is valid for a Linux job whose steps run directly on the runner. The tests connect through mapped localhost ports.

jobs:
  python-integration:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    services:
      postgres:
        image: postgres:16.4-alpine
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpassword
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 12

      redis:
        image: redis:7.2-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 12

    env:
      DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/testdb
      REDIS_URL: redis://localhost:6379

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Apply migrations
        run: python manage.py migrate

      - name: Run integration tests
        run: pytest tests/integration/ -v --junitxml=test-results/pytest.xml

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: python-integration-results
          path: test-results/
          if-no-files-found: warn
          retention-days: 7

Replace python manage.py migrate with the migration command implemented by your project. If these tests also need a running HTTP process, start it, perform a bounded application-readiness check, capture its logs, and stop it using the same lifecycle shown in the Node.js job.

Architecture B: expose the local test application with Localtonet

External requests traveling through a Localtonet HTTP tunnel to an application on localhost.
The temporary public endpoint forwards HTTP requests through an outbound tunnel to the local test application.

With Localtonet, the client application on your device establishes an outbound connection to our relay. The HTTP tunnel then supplies a public HTTPS address that forwards to the configured local IP address and port. No inbound router port forwarding, public IP address, VPN setup, or inbound firewall change is required for this tunnel workflow.

The public endpoint exists only while the selected client or device is connected and the tunnel is running. Creating a tunnel does not start it. The operator must select Start, verify the assigned URL, and later stop or delete the tunnel.

1

Install and run the Localtonet client

Install the current Localtonet application for the operating system on the device that can reach the test service. Start the client and keep it connected for the entire test window. Obtain current installation details from the Localtonet application or documentation rather than copying unverified commands.

2

Start the application and verify it locally

Start the dedicated test application, its database, and required dependencies. From the same device as the Localtonet client, request the readiness route using the exact local IP address and port you plan to configure. Seed only synthetic or sanitized fixtures.

3

Open the HTTP tunnel configuration

Create an HTTP tunnel using the current Localtonet interface. Consult the Localtonet HTTP tunnel documentation for the current workflow rather than linking directly to an authenticated dashboard route.

4

Select the Process Type

Choose the HTTP Process Type appropriate for the test from the currently available Random Sub Domain, Custom Sub Domain, or Custom Domain options. All serve the local HTTP content at a public HTTPS address. Availability and domain requirements can vary, so use only options shown for your account and current product configuration.

5

Select the device and relay server

Select the AuthToken for the device running the Localtonet client, then select an available relay server or region from the current interface. AuthTokens are device-specific secrets and must never be placed in repository files, public workflow inputs, logs, or screenshots.

6

Configure the local IP address and port

Enter the address that the Localtonet client can use to reach the application. Use 127.0.0.1 only when the application and client share the same network namespace and the service listens there. A containerized or remote LAN service may require a different reachable address. Enter the application’s actual listening port.

7

Create and start the tunnel

Save or create the configuration, then select Start. Creation alone does not make the endpoint available. Wait until the tunnel is running and note the public HTTPS URL assigned by the current configuration.

8

Verify the public endpoint before starting CI

Request the public readiness endpoint from a network path outside the local application process. Confirm the expected HTTPS status, response, authentication behavior, and application log entry. Do not begin a paid-provider or state-changing test until this check succeeds.

A tunnel publishes the selected HTTP service

Anyone who obtains the public URL can attempt to connect while the client and tunnel are running. Protect test APIs with authorization, expose only the required routes, apply least privilege, avoid production data, validate provider signatures, and keep the exposure window short. A hard-to-guess URL is not a substitute for authentication.

Run a controlled CI job against the temporary URL

The evidence available for this guide does not establish a documented Localtonet mechanism for automatically discovering a newly assigned tunnel URL inside GitHub Actions. It also does not establish a verified workflow for installing the Localtonet client in a hosted runner. We therefore should not pretend that URL handoff and tunnel creation are automatic.

A safe, supportable pattern is to start and verify the tunnel on the developer-controlled device, then invoke a restricted workflow_dispatch job with the public base URL. The job receives the URL explicitly, waits for the local application to become ready, authenticates to a dedicated test API, runs assertions, uploads results, and times out if the workstation disappears.

name: Controlled Local Environment Test

on:
  workflow_dispatch:
    inputs:
      public_base_url:
        description: Verified temporary HTTPS base URL
        required: true
        type: string

permissions:
  contents: read

concurrency:
  group: controlled-local-integration
  cancel-in-progress: false

jobs:
  test-local-environment:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment: local-integration

    env:
      TEST_BASE_URL: ${{ inputs.public_base_url }}
      TEST_API_TOKEN: ${{ secrets.LOCAL_TEST_API_TOKEN }}

    steps:
      - name: Validate input scheme
        shell: bash
        run: |
          case "$TEST_BASE_URL" in
            https://*) ;;
            *)
              echo "The test URL must use HTTPS."
              exit 1
              ;;
          esac

      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm

      - name: Install locked dependencies
        run: npm ci

      - name: Wait for remote application readiness
        shell: bash
        run: |
          for attempt in {1..30}; do
            if curl --silent --show-error --fail \
              --max-time 5 \
              --header "Authorization: Bearer $TEST_API_TOKEN" \
              "$TEST_BASE_URL/test/ready" \
              > readiness-response.txt; then
              exit 0
            fi
            sleep 5
          done

          echo "The local environment did not become ready within 150 seconds."
          exit 1

      - name: Run remote integration assertions
        env:
          BASE_URL: ${{ inputs.public_base_url }}
          API_TOKEN: ${{ secrets.LOCAL_TEST_API_TOKEN }}
        run: npm run test:remote-integration

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: controlled-local-test-results
          path: |
            readiness-response.txt
            test-results/
          if-no-files-found: warn
          retention-days: 7

This workflow assumes the repository implements test:remote-integration and writes reports to test-results/. Tests should use BASE_URL as their origin and send API_TOKEN only to the dedicated test application. Never print the token or include it in the URL.

Configure the local-integration GitHub environment with appropriate reviewers if manual approval is required. Restrict who can dispatch the workflow because an arbitrary URL supplied to a privileged runner can create a server-side request forgery risk. If your organization needs unattended tunnel creation, URL discovery, or teardown, implement it only from current documented Localtonet API behavior and have that automation reviewed separately.

Design assertions around observable outcomes

A remote test should do more than receive HTTP 200. Create a unique run identifier, submit a test request, poll a protected status route for a bounded period, and verify the expected state transition. Make retries safe by using an idempotency key or the unique run identifier. Capture response status, sanitized response bodies, correlation IDs, and application logs without recording credentials or personal data.

Test webhook delivery without weakening verification

Webhook payload and signature passing through a tunnel before local signature verification accepts or rejects the event.
The tunnel carries the webhook request, while the local handler still verifies its signature before processing it.

A webhook tunnel changes how the provider reaches your application, but it should not change the provider’s security model. Use the provider’s test or sandbox mode, store the endpoint signing secret outside source control, preserve the exact request body required by the signature algorithm, compare signatures safely, reject stale or invalid messages when the provider’s protocol supports timestamp validation, and make event processing idempotent.

Provider dashboard labels and test-event controls can change. Use the provider’s current first-party documentation and interface to create the endpoint, choose event types, obtain or rotate the signing secret, send a test delivery, and inspect delivery attempts. Do not rely on an old menu path copied from a tutorial.

Stripe webhook handler using the raw request body

const express = require('express');
const Stripe = require('stripe');

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Register this route before any JSON body parser that would modify the body.
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['stripe-signature'];

    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (error) {
      return res.status(400).send('Invalid webhook signature');
    }

    try {
      // hasProcessedEvent and recordProcessedEvent must use durable,
      // atomic storage keyed by the provider event ID.
      if (await hasProcessedEvent(event.id)) {
        return res.status(200).json({ received: true, duplicate: true });
      }

      if (event.type === 'payment_intent.succeeded') {
        const paymentIntent = event.data.object;
        await processTestPayment(paymentIntent);
      }

      await recordProcessedEvent(event.id);
      return res.status(200).json({ received: true });
    } catch (error) {
      console.error('Webhook processing failed', {
        eventId: event.id,
        eventType: event.type
      });
      return res.sendStatus(500);
    }
  }
);

The signing secret belongs in the local environment’s secret store, not in code or a pipeline input. Use the signing secret associated with the exact test endpoint. Rotate it if it is exposed. Stripe can retry deliveries, and delivery can occur more than once, so the event ID must be recorded atomically before a duplicate can repeat a state-changing operation.

Webhook endpoints should respond within the provider’s required time window. For production designs, lengthy processing is commonly handed to a durable queue after signature verification and idempotent acceptance. For a local test, keep processing bounded and inspect the provider’s delivery log alongside local application logs.

GitHub webhook handler with safe payload access

from flask import Flask, request, abort
import hashlib
import hmac
import json
import os

app = Flask(__name__)

@app.post('/webhooks/github')
def github_webhook():
    body = request.get_data(cache=True)
    supplied_signature = request.headers.get('X-Hub-Signature-256', '')
    delivery_id = request.headers.get('X-GitHub-Delivery', '')
    event_name = request.headers.get('X-GitHub-Event', '')

    secret = os.environ['GITHUB_WEBHOOK_SECRET'].encode('utf-8')
    digest = hmac.new(secret, body, hashlib.sha256).hexdigest()
    expected_signature = f'sha256={digest}'

    if not hmac.compare_digest(supplied_signature, expected_signature):
        abort(401)

    if not delivery_id:
        abort(400)

    if delivery_already_processed(delivery_id):
        return '', 200

    payload = json.loads(body.decode('utf-8'))

    if event_name == 'push':
        ref = payload.get('ref')
        head_commit = payload.get('head_commit')
        commit_message = (
            head_commit.get('message')
            if isinstance(head_commit, dict)
            else None
        )

        process_test_push(
            delivery_id=delivery_id,
            ref=ref,
            commit_message=commit_message
        )

    record_processed_delivery(delivery_id)
    return '', 200

The example reads the exact request bytes before parsing JSON, verifies X-Hub-Signature-256, uses a constant-time comparison, and does not assume that head_commit is always present. Implement delivery_already_processed and record_processed_delivery with durable, atomic storage. GitHub can redeliver events, so processing must tolerate duplicates.

Create the webhook in a test repository or another deliberately limited context. Configure only the events required by the test, use a unique random secret, and enter the current Localtonet public URL plus the handler path as the payload URL. After triggering a test event, compare the provider delivery ID, HTTP status, request timestamp, local log correlation ID, and resulting test record.

Bearer authentication and provider signatures serve different callers

A CI job can authenticate to a protected test API with a dedicated bearer token. A webhook provider generally will not send that custom bearer token, so the webhook route must use the provider’s documented signature scheme. Do not disable webhook signature verification merely because the endpoint is temporary.

Verify locally, publicly, and end to end

Test each network segment independently. This avoids blaming the tunnel when the application is not listening, or blaming the application when the provider is still configured with an old URL.

1

Verify the local process

From the Localtonet client device, request the exact local target and readiness path. Confirm the process is listening on the expected interface and port, the database is reachable, migrations are complete, and the response contains no sensitive diagnostics.

2

Verify the public HTTPS endpoint

Start the tunnel and request the assigned public URL. Check the status code, expected body, authentication rejection for unauthorized requests, application log entry, and external host behavior.

3

Verify CI-to-local connectivity

Run the bounded readiness step before state-changing tests. If it fails, retain the CI response, timestamps, and local application logs. Confirm that the runner can make outbound HTTPS requests through any organizational proxy or egress policy.

4

Verify the business outcome

Send a uniquely identified test request, then assert the resulting test-only record, state transition, callback, or queued operation. A successful transport response alone does not prove that the integration worked.

5

Verify provider delivery details

For webhooks, inspect the provider’s current delivery log. Match its delivery or event ID to the local application record, verify the returned status, and distinguish an initial delivery from a retry or manual redelivery.

Security and operational controls

A public tunnel shifts an application from loopback-only access to internet-reachable access for the duration of the tunnel. Treat that as a temporary deployment, even when the code runs on a laptop.

🧪 Dedicated test environment Use sandbox provider accounts, test users, separate databases, synthetic fixtures, and non-production storage. Remove production credentials from the local process.
🔑 Least-privilege secrets Give the CI token access only to test routes and records. Keep Localtonet AuthTokens device-specific and private. Store provider signing secrets and CI credentials in appropriate secret stores.
⏱️ Short exposure window Start the tunnel only after local verification, run a bounded test, and stop or delete it immediately afterward. Do not leave an unused development server public overnight.
🔁 Idempotency and replay handling Record provider event IDs or CI run IDs atomically. Repeated delivery must not create duplicate payments, messages, notifications, or test records.
🚦 Concurrency control Allow only one test against a shared local fixture set, or isolate every run by namespace. Prevent two workflows from changing the same local records simultaneously.
🧾 Sanitized evidence Retain test reports, timestamps, correlation IDs, and relevant status codes. Redact authorization headers, cookies, signatures, tokens, personal data, and complete sensitive payloads.

Origin, host, and HTTPS behavior

The browser or provider sees the public HTTPS origin, while the Localtonet client forwards to a local IP address and port that may use plain HTTP. Applications that generate absolute URLs, enforce allowed hosts, validate callback origins, use secure cookies, or trust forwarded proxy information must be configured for the externally visible origin.

Do not globally disable host validation or TLS-origin checks to make a test pass. Configure the exact test origin using the mechanism supported by the installed framework version. For OAuth, the provider’s registered callback URI must match the public scheme, host, port when present, and callback path expected by the application.

Troubleshooting failed pipeline and tunnel tests

Symptom Likely boundary What to check
Connection refused locally Application process Confirm the process is running, the port is correct, startup did not fail, and the service listens on an address reachable by the Localtonet client.
Local URL works but public URL fails Tunnel configuration Confirm the selected device is connected, the correct relay is selected, the local IP and port match the verified target, and the tunnel was started after creation.
Public URL returns an application 404 Route or host handling Check the full path, base-path configuration, virtual-host rules, allowed-host settings, and whether the application expects a different external origin.
CI cannot reach a public URL that works in a browser Runner egress Check organizational proxies, DNS resolution, outbound firewall rules, URL input errors, tunnel availability, and the workflow timeout.
Webhook signature fails Request verification Preserve the raw body, use the signing secret for the exact endpoint and mode, read the correct signature header, and verify that middleware did not parse or alter the body first.
Webhook is processed twice Application idempotency Use the provider event or delivery ID as an atomic idempotency key. Account for retries, delayed delivery, and manual redelivery.
Database container is healthy but tests fail at startup Application readiness Inspect migrations, fixture loading, application logs, database permissions, and the application readiness endpoint. Container health is not application readiness.
Tests intermittently time out Readiness or shared state Add bounded polling, avoid fixed sleeps, isolate records, prevent concurrent runs, record timing data, and fail with retained diagnostics.
OAuth redirects to localhost or loses its session External-origin configuration Align the registered callback URI, application base URL, proxy trust, secure-cookie behavior, callback path, and public HTTPS host.
A previously used public URL no longer works Tunnel lifecycle Confirm the client and tunnel are running and use the URL currently assigned by the active tunnel. Do not assume a generated URL remains unchanged across sessions.
The developer machine becomes unavailable External environment Let the CI job fail within its timeout, preserve artifacts, mark the run as an environment failure, and rerun only after the device, application, and tunnel are verified again.

Container binding mistakes

If the application runs inside Docker on the developer machine, a process bound only to the container’s loopback interface may not be reachable from the host. Publish the application port according to the project’s container configuration and verify it from the host before targeting it with Localtonet. Do not guess a container IP that can change between starts.

Failure artifacts that are worth retaining

Capture application startup logs, migration output, sanitized HTTP status and response data, test reports, correlation IDs, provider delivery IDs, and timestamps. Keep artifact retention proportionate to the test and avoid collecting complete webhook bodies when they contain personal or payment-related data. Never upload environment dumps or authorization headers as debugging artifacts.

Complete the test with explicit cleanup

Cleanup is part of the pipeline design, not an optional task after a successful run. The self-contained GitHub Actions environment is discarded after the job, while its explicit if: always() steps preserve diagnostics and stop the application process even when assertions fail.

In the developer-managed architecture, the CI runner does not own the Localtonet tunnel. The device operator must perform cleanup after success, failure, cancellation, or timeout.

1

Record the final result

Preserve the CI conclusion, test report, provider delivery ID, and sanitized local logs needed to diagnose a failure. Do not retain secrets or unnecessary payload data.

2

Remove temporary provider configuration

Delete or disable the temporary webhook endpoint or callback registration when it is no longer required. Rotate its signing secret if it was exposed during debugging.

3

Stop or delete the Localtonet tunnel

Stop the running tunnel immediately after the test. Delete the tunnel configuration as well when it is no longer needed. Verify that the former public endpoint no longer reaches the application.

4

Stop the local environment and clear fixtures

Stop the test application and dependencies, revoke temporary test tokens, and remove or reset test records. Preserve only the sanitized evidence required by your development process.

Frequently asked questions

Can every Docker image be used as a GitHub Actions service container?

No. Service containers require a Linux runner with Docker, and the image must operate correctly in the service-container environment. You must supply any required environment variables, ports, volumes, startup options, and health command. Licensing, architecture compatibility, authentication, resource requirements, and image entrypoint behavior can also affect whether an image is suitable.

Does a service-container health check prove my application is ready?

No. It reports the container health defined by that command. A database can accept basic connections while migrations, fixtures, application startup, or another dependency still fails. Add a separate bounded application-level readiness check before running assertions.

Can GitHub Actions automatically discover my Localtonet tunnel URL?

No verified automatic URL-discovery workflow is established by the evidence available for this article. The supported pattern described here is to start and verify the tunnel on the developer-controlled device, then provide the active public HTTPS URL to a restricted manually dispatched job. Do not build automation around guessed commands or undocumented output formats.

Can I assume the same public tunnel URL will be available next time?

Do not assume that. Use the public URL shown for the currently running tunnel and verify it before each test. Process Type options and availability can vary, and exact custom-domain or DNS instructions must be confirmed against current Localtonet documentation and the options shown for your account.

Should a tunneled test use my local copy of production data?

No. Use a dedicated test database with synthetic or sanitized fixtures. Remove production credentials and integrations from the process before publishing it. The tunnel exposes the selected HTTP service while it is running, so the application must not provide routes that reveal production records or administrative capabilities.

Why does a webhook need idempotency if the signature is valid?

A valid signature authenticates the delivery but does not guarantee it will arrive only once. Providers can retry after timeouts or server errors, and operators can initiate redelivery. Record the provider’s event or delivery ID atomically so a repeated request returns success without repeating the state-changing operation.

Which architecture should run on every pull request?

Prefer the self-contained runner for routine pull-request tests because it is reproducible and does not depend on a developer machine. Reserve the tunneled architecture for controlled tests that genuinely require an external callback or a developer-managed environment. Keep those runs manually approved or externally orchestrated until every lifecycle step is documented and reviewed.

Open a temporary path to your local test application

When an integration genuinely requires an internet-originated callback, use Localtonet to publish only the required local HTTP service, verify the assigned HTTPS endpoint, run a bounded test with isolated data, and stop the tunnel as soon as the test finishes.

Get Started Free →

Corrections & updates

Substantive changes approved by the Localtonet editorial team are listed transparently below.

Reframe the article around two clearly separated architectures: a self-contained GitHub Actions integration-test job and a CI job or external provider reaching a developer-controlled local environment through a temporary Localtonet HTTP tunnel. Add an architecture and trust-boundary explanation, prerequisites, supported runner limitations, test-data isolation guidance, and a decision table that does not recommend production data. Correct the GitHub Actions service-container explanation for Linux and Docker requirements, job-container

Localtonet is a secure multi-protocol tunneling and proxy platform designed to expose localhost, devices, private services, and AI agents to the public internet supporting HTTP/HTTPS tunnels, TCP/UDP forwarding, mobile proxy infrastructure, file server publishing, latency-optimized game connectivity, and developer-ready AI agent endpoint exposure from a single unified control plane.

support