davemason.dev

A blog site for Dave Mason. I'm a software engineering lead from Manchester, UK. Here I'll write about some techie stuff and some leadership stuff.

Pointing Webhooks to a Local Container with ngrok

This was a fun bit of development work with a couple of gotcha’s, so I thought I’d write it up into a blog post in case anyone is trying to do the same thing.

ngrok webhook

The Problem

We’ve been building a number of customisations for ContentStack CMS, and as with many SaaS integrations, it uses webhooks to allow us to run custom code in response to certain events that occur within ContentStack itself. We have an API hosted in GCP Cloud Run that exposes endpoints for this very purpose.

As the webhook needs a public URL in order for ContentStack to send the request to it, we can’t trigger the code when it’s running in our local development environment, which is a Docker container. Every engineer has their own personal development stack which is created and updated by running our schema generation code locally. So ideally, webhooks triggered from the development stack would send the request to the locally-running, development version of the API.

The Idea

Ngrok is a well known utility for exposing local endpoints with a public URL, so we thought we’d have a look into whether we could use that to point webhooks from the feature branch stack to the locally-running API container. Ideally, that should be without having to do any manual configuration.

The Solution

When running the API locally (using docker-compose), it will launch a side-car container running ngrok that will create a public URL for the exposed port of the API container.

Then, when generating the schema for the development stack, it would query the local ngrok container to get the public URL, and set that as the webhook URL for all webhooks. As a result, any events that occur within the dev stack in ContentStack will be sent to the locally-running API container.

Creating the Ngrok container

Previously, as it was a single container, the API was just run using a single docker run command, which in turn was run using a make command. The docker run command was already getting unwieldly due to the volumes, port mapping and environment variables, and now there was to be a 2nd container, it made sense to re-factor into a docker-compose file. Here’s the contents of the file:

version: "3.9"
services:
  api:
    build: .
    entrypoint: npm run dev
    volumes:
      - ./:/usr/app
      - ~/.google_cloud/pollen_qa_credentials.json:/secrets/google/credentials.json
    ports:
      - 3000:8080
    environment: 
      - ENVIRONMENT=dev
      - CONTENTSTACK_EMAIL=${CONTENTSTACK_DEVOPS_EMAIL}
      - CONTENTSTACK_PASSWORD=${CONTENTSTACK_DEVOPS_PASSWORD}
      - API_KEY=${POLLEN_API_KEY}
    networks:
      - pollen-api
  ngrok:
    image: wernight/ngrok:latest
    entrypoint: ngrok http api:8080
    ports:
      - 4040:4040
    networks:
      - pollen-api

networks:
  pollen-api:

The docker-compose file creates a shared network that both containers use. The ngrok container runs the ngrok http api:8080 command, which tells ngrok to create a public URL and forwards all requests to that address (port 8080 on the api container).

Pointing the Dev Stack Webhooks to the Ngrok URL

To do this, when we generate the dev stack schema, we need to query the ngrok URL from the ngrok container and set the result to an environment variable. This is then passed in as part of the docker run command. Here’s the shell command for doing that:

curl -s $(docker port api_ngrok_1 4040 | grep 0.0.0.0)/api/tunnels | jq -r '.tunnels[].public_url | select(test("https.*"))')

We’ll need to break this down into parts. First, there’s this bit:

docker port api_ngrok_1 4040 | grep 0.0.0.0

This uses the docker port command to get the URL of the running ngrok container. We then pipe it into a grep that filters the results for the URL that’s on 0.0.0.0, as it can return multiple results, which would break the sub-command.

We then use curl -s to silently send a request to ngrok’s api/tunnels endpoint, which returns a JSON object containing all the endpoint data. The JSON object looks something like this:

{
  "tunnels": [
    {
      "name": "command_line (http)",
      "uri": "/api/tunnels/command_line%20%28http%29",
      "public_url": "http://00bbdd913fc3.ngrok.io",
      "proto": "http",
      "config": {
        "addr": "http://api:8080",
        "inspect": true
      },
      "metrics": {
        "conns": {
          "count": 0,
          "gauge": 0,
          "rate1": 0,
          "rate5": 0,
          "rate15": 0,
          "p50": 0,
          "p90": 0,
          "p95": 0,
          "p99": 0
        },
        "http": {
          "count": 0,
          "rate1": 0,
          "rate5": 0,
          "rate15": 0,
          "p50": 0,
          "p90": 0,
          "p95": 0,
          "p99": 0
        }
      }
    },
    {
      "name": "command_line",
      "uri": "/api/tunnels/command_line",
      "public_url": "https://00bbdd913fc3.ngrok.io",
      "proto": "https",
      "config": {
        "addr": "http://api:8080",
        "inspect": true
      },
      "metrics": {
        "conns": {
          "count": 0,
          "gauge": 0,
          "rate1": 0,
          "rate5": 0,
          "rate15": 0,
          "p50": 0,
          "p90": 0,
          "p95": 0,
          "p99": 0
        },
        "http": {
          "count": 0,
          "rate1": 0,
          "rate5": 0,
          "rate15": 0,
          "p50": 0,
          "p90": 0,
          "p95": 0,
          "p99": 0
        }
      }
    }
  ],
  "uri": "/api/tunnels"
}

So what we need is the public_url property from the https endpoint. In order to get this, we pipe the JSON result into the incredibly useful jq command-line utility. That’s what this bit does:

jq -r '.tunnels[].public_url | select(test("https.*"))')

The -r command gives us the raw JSON output. We then use the query syntax to select the public_url property of every object in the tunnels array. This gives us both the http and the https public_url values. We therefore need to pipe the result of that into the select function, which allows us to specify a test that will resolve to true or false. The select function will only return results where the function it’s supplied resolves to true. The test function allows us to pass in a regex which will only match strings that start with https.

The Result

After a bit of trial-and-error, the above worked perfectly. We’re just using the free version of ngrok, which comes with a number of limitations, including that the tunnel will only stay open for a few hours, so if it’s a long piece of development work, we need to re-run the schema generation every now and again. It only takes a minute or so, so it’s not the end of the world, but if it becomes problematic we can get a paid ngrok subscription. That would also simplify the above as we could get fixed URL’s for each engineer. But then where’s the fun in that?