
As developers, we frequently face a common challenge: how do we test webhook-driven workflows that depend on services running locally? More specifically, how do we validate GitHub Actions that need to communicate with APIs hosted on our development machines?
Enter ngrok — a powerful tunneling tool that creates secure tunnels to expose your local services to the internet. In this guide, I’ll walk you through using ngrok to create a bridge between GitHub Actions and your local .NET API endpoints, enabling seamless testing without deploying to a staging environment.
Before diving into the solution, let’s understand the problem:
localhost
)This is where ngrok shines, creating a secure tunnel that makes your local services temporarily accessible from anywhere.
Before we begin, make sure you have:
Let’s start with a simple .NET API that we’ll expose via ngrok. For this example, I’ll create a basic webhook receiver that logs incoming GitHub event data.
using Microsoft.AspNetCore.Mvc;using System.Text.Json;var builder = WebApplication.CreateBuilder(args);var app = builder.Build();app.MapPost("/api/github-webhook", async (HttpContext context) =>{// Read the request bodyusing var reader = new StreamReader(context.Request.Body);var requestBody = await reader.ReadToEndAsync();// Parse the JSON payloadvar payload = JsonSerializer.Deserialize<JsonDocument>(requestBody);// Log the event (in a real app, you'd process this based on your requirements)Console.WriteLine($"Received GitHub webhook: {payload?.RootElement.GetProperty("action")}");Console.WriteLine($"Event payload: {requestBody}");// Return a success responsereturn Results.Ok(new { message = "Webhook received successfully" });});app.Run("http://localhost:5000");
Save this as Program.cs
in a new .NET project. This creates a simple API with a single endpoint that accepts GitHub webhook payloads.
To run this API locally:
dotnet run
Your API is now running at http://localhost:5000
, but it’s not accessible from the internet yet.
Now, let’s use ngrok to create a public URL for your local API:
ngrok http 5000
You’ll see output similar to this:
Session Status onlineAccount your-account (Plan: Free)Version 3.3.1Region United States (us)Latency 51msWeb Interface http://127.0.0.1:4040Forwarding https://a1b2c3d4e5f6.ngrok.io -> http://localhost:5000
The important part is the Forwarding URL (https://a1b2c3d4e5f6.ngrok.io
in this example). This is your temporary public URL that routes to your local API.
Note: Each time you restart ngrok, you’ll get a different URL. For persistent URLs, consider upgrading to a paid ngrok plan.
Now we’ll set up a GitHub Action workflow that interacts with your local API via the ngrok tunnel:
name: Test Local API Integrationon:push:branches: [ main ]workflow_dispatch:jobs:test-webhook:runs-on: ubuntu-lateststeps:- name: Checkout codeuses: actions/checkout@v3- name: Send test webhook to local APIrun: |curl -X POST \-H "Content-Type: application/json" \-H "X-GitHub-Event: ping" \-d '{"action": "test", "sender": {"login": "github-actions"}}' \https://a1b2c3d4e5f6.ngrok.io/api/github-webhook
Save this as .github/workflows/test-local-api.yml
in your repository.
Important: Replace
https://a1b2c3d4e5f6.ngrok.io
with your actual ngrok URL from the previous step.
The basic example works, but in a real development workflow, you need something more robust. Let’s improve our setup:
Instead of hardcoding the ngrok URL, let’s use GitHub repository secrets:
name: Test Local API Integrationon:push:branches: [ main ]workflow_dispatch:inputs:ngrok_url:description: 'Current ngrok URL (without trailing slash)'required: truejobs:test-webhook:runs-on: ubuntu-lateststeps:- name: Checkout codeuses: actions/checkout@v3- name: Send test webhook to local APIrun: |# Use the input URL if provided, otherwise use the secretWEBHOOK_URL="${{ github.event.inputs.ngrok_url || secrets.NGROK_URL }}"curl -X POST \-H "Content-Type: application/json" \-H "X-GitHub-Event: ping" \-d '{"action": "test", "sender": {"login": "github-actions"}}' \$WEBHOOK_URL/api/github-webhook
This allows you to:
NGROK_URL
)To make the ngrok setup easier to use, create a start-ngrok.sh
script:
#!/bin/bash# Kill any running ngrok processpkill -f ngrok || true# Start ngrok in the backgroundngrok http 5000 > /dev/null &# Wait for ngrok to initializesleep 3# Get the public URLNGROK_URL=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[0].public_url')echo "====================================================="echo "ngrok tunnel is running!"echo "Public URL: $NGROK_URL"echo "====================================================="echo ""echo "To update your GitHub repository with this URL, run:"echo "gh secret set NGROK_URL --body=\"$NGROK_URL\""echo ""echo "Or use this URL when manually triggering the workflow"echo "====================================================="# Keep the script running to maintain the tunnelwait
Make it executable:
chmod +x start-ngrok.sh
Now you can start ngrok with a simple command and easily update your GitHub secret with the new URL.
Let’s improve our .NET API to be more production-ready:
using Microsoft.AspNetCore.Mvc;using System.Text.Json;using System.Security.Cryptography;using System.Text;var builder = WebApplication.CreateBuilder(args);// Add servicesbuilder.Services.AddLogging();var app = builder.Build();// GitHub webhook secretstring webhookSecret = builder.Configuration["GitHub:WebhookSecret"] ?? "your-webhook-secret";app.MapPost("/api/github-webhook", async (HttpContext context, ILogger<Program> logger) =>{try{// Verify signature if X-Hub-Signature-256 is presentif (context.Request.Headers.TryGetValue("X-Hub-Signature-256", out var signatureWithPrefix)){// Buffer the request body so we can read it multiple timescontext.Request.EnableBuffering();using var reader = new StreamReader(context.Request.Body,encoding: Encoding.UTF8,detectEncodingFromByteOrderMarks: false,leaveOpen: true);var requestBody = await reader.ReadToEndAsync();// Rewind the stream for later usecontext.Request.Body.Position = 0;// Verify signatureif (!VerifyGitHubWebhook(requestBody, webhookSecret, signatureWithPrefix)){logger.LogWarning("Invalid webhook signature");return Results.Unauthorized();}}// Read the event typevar eventType = context.Request.Headers["X-GitHub-Event"].ToString();// Read the request body (again)using var bodyReader = new StreamReader(context.Request.Body);var body = await bodyReader.ReadToEndAsync();// Parse the JSON payloadvar payload = JsonSerializer.Deserialize<JsonDocument>(body);// Log the eventlogger.LogInformation("Received GitHub webhook: {EventType}, Action: {Action}",eventType,payload?.RootElement.TryGetProperty("action", out var action) ? action.ToString() : "none");// Here you would normally process the webhook based on the event type// For example, handling push events, pull requests, etc.return Results.Ok(new { message = $"Processed {eventType} webhook successfully" });}catch (Exception ex){logger.LogError(ex, "Error processing webhook");return Results.Problem("Error processing webhook");}});app.Run("http://localhost:5000");// GitHub webhook signature verificationbool VerifyGitHubWebhook(string payload, string secret, string signatureWithPrefix){var secretBytes = Encoding.ASCII.GetBytes(secret);var payloadBytes = Encoding.UTF8.GetBytes(payload);using var hmac = new HMACSHA256(secretBytes);var hash = hmac.ComputeHash(payloadBytes);var expectedSignature = "sha256=" + string.Concat(hash.Select(b => b.ToString("x2")));return SignatureEquals(expectedSignature, signatureWithPrefix);}// Constant-time comparison to prevent timing attacksbool SignatureEquals(string a, string b){if (a.Length != b.Length){return false;}int result = 0;for (int i = 0; i < a.Length; i++){result |= a[i] ^ b[i];}return result == 0;}
This enhanced API adds:
Now let’s test the entire workflow:
Start your .NET API
dotnet run
Start ngrok using the script
./start-ngrok.sh
Copy the generated URL.
Update your GitHub Action Either:
gh secret set NGROK_URL --body="https://your-ngrok-url"
Run the GitHub Action
Check your .NET API logs You should see the incoming webhook from GitHub Actions.
When working with ngrok and GitHub Actions, you might encounter some issues. Here are some tips:
Access the ngrok inspection interface
Open http://localhost:4040
in your browser to see all requests passing through your ngrok tunnel.
Enable detailed logging in ngrok
ngrok http 5000 --log=stdout
Verify connectivity
curl -v https://your-ngrok-url.ngrok.io/api/github-webhook
Enable developer exception page
Add app.UseDeveloperExceptionPage();
to your .NET API to see detailed error information.
Check request headers Log all incoming headers to understand what GitHub is sending:
foreach (var header in context.Request.Headers){Console.WriteLine($"{header.Key}: {header.Value}");}
Test locally Send test webhooks directly to your API to verify it works correctly:
curl -X POST \-H "Content-Type: application/json" \-H "X-GitHub-Event: ping" \-d '{"action":"test"}' \http://localhost:5000/api/github-webhook
When exposing your local services with ngrok, consider these security practices:
Use webhook secrets Always verify webhook signatures using a shared secret.
Limit exposed endpoints Only expose the specific endpoints needed for testing.
Use ngrok’s IP restrictions With paid plans, you can restrict which IPs can access your ngrok tunnel.
Don’t use production data Use test data in your development environment.
Temporary credentials If your API requires authentication, use temporary credentials for testing.
For more complex scenarios, you might want to create a custom GitHub Action that handles communication with your local API. Here’s a simple example:
name: Custom Webhook Actionon:push:branches: [ main ]workflow_dispatch:inputs:ngrok_url:description: 'Current ngrok URL'required: truejobs:send-webhook:runs-on: ubuntu-lateststeps:- name: Checkout codeuses: actions/checkout@v3- name: Setup .NETuses: actions/setup-dotnet@v3with:dotnet-version: '7.0.x'- name: Build webhook senderrun: |dotnet new console -o WebhookSendercd WebhookSender# Add dependenciesdotnet add package System.CommandLinedotnet add package Newtonsoft.Json# Create the webhook sender programcat << EOF > Program.csusing System.CommandLine;using System.Text;using Newtonsoft.Json;using System.Net.Http.Headers;// Define command-line optionsvar urlOption = new Option<string>("--url", "The webhook URL to send data to");var eventOption = new Option<string>("--event", "The GitHub event type");var dataOption = new Option<string>("--data", "JSON payload to send");var rootCommand = new RootCommand("GitHub webhook sender");rootCommand.AddOption(urlOption);rootCommand.AddOption(eventOption);rootCommand.AddOption(dataOption);rootCommand.SetHandler(async (url, eventType, data) =>{using var httpClient = new HttpClient();// Create request messagevar request = new HttpRequestMessage(HttpMethod.Post, url);request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));request.Headers.Add("X-GitHub-Event", eventType);request.Headers.Add("User-Agent", "GitHub-Actions-Webhook-Sender");// Add the payloadvar content = new StringContent(data, Encoding.UTF8, "application/json");request.Content = content;// Send the requestConsole.WriteLine($"Sending {eventType} webhook to {url}");var response = await httpClient.SendAsync(request);// Process the responsevar responseBody = await response.Content.ReadAsStringAsync();Console.WriteLine($"Response: {(int)response.StatusCode} {response.StatusCode}");Console.WriteLine(responseBody);if (!response.IsSuccessStatusCode){Environment.Exit(1);}}, urlOption, eventOption, dataOption);return rootCommand.Invoke(args);EOF# Build the webhook senderdotnet build- name: Send webhook to local APIrun: |cd WebhookSenderTARGET_URL="${{ github.event.inputs.ngrok_url || secrets.NGROK_URL }}/api/github-webhook"dotnet run -- \--url "$TARGET_URL" \--event "workflow_run" \--data '{"action": "completed","workflow_run": {"id": ${{ github.run_id }},"name": "${{ github.workflow }}","conclusion": "success"},"repository": {"full_name": "${{ github.repository }}"}}'
This action creates a small .NET application that sends a webhook to your local API with customizable event types and payloads.
Here are some practical scenarios where this ngrok-GitHub Actions integration is valuable:
Testing webhooks during development Validate webhook handlers without deploying.
Debugging complex workflows Diagnose issues in GitHub Actions that interact with your API.
Testing integration between services Verify how your service responds to GitHub events.
Local preview of deployment workflows Test deployment processes without affecting production.
Feature branch testing Test new API features with existing CI/CD pipelines.
Using ngrok to connect GitHub Actions with your local .NET API creates a powerful development workflow that saves time and reduces the need for deployment just for testing. By following the approach outlined in this guide, you can:
Remember to consider security implications when exposing local services, and always use webhook signature verification in production environments.
Your insights drive us! For any questions, feedback, or thoughts, feel free to connect:
If you found this guide beneficial, don’t hesitate to share it with your network. Until the next guide, happy coding! 🚀
Quick Links
Legal Stuff