Photo by Florian Krumm on Unsplash
Photo by https://unsplash.com/@floriankrumm

Expose Your Local Dockerized Node Server to the Web with Ngrok

Run your node server locally with Docker & Ngrok while enabling to communicate with 3rd party services and other devices

Tuesday, Dec 15, 2020

avatardocker
avatarnode

The problem

Let's say you're working on your local development server and encounter the need to test your environment from a different machine other than the one your working on which... is also on a different network.

Or that your local server is dependent on a 3rd party service and that API needs a longer time to process and send results back to your server via a callback interface.

The list can go on.

Trying to expose your local server running on http://localhost:${PORT} to the public internet can be a pain.

Local tunneling services such as ngrok allow you to expose your local server to the public internet in a secure fashion.

The Solution ~ What we will build

We'll be using:

  • Docker to containerize our server environment.
  • In the docker config, install ngrok and the dependencies for our node server.
  • Then we'll be using the ngrok npm package to create a connection in the application and get the forwarded DNS address to be accessible to the web.
  • We can then pass that address/url to the other API's and recieve the results back on our local machine.



Project Setup (If starting from scratch)

Create project Directory

mkdir docker-node-server

cd or re-open vscode new directory as project root

cd docker-node-server

Create package.json

npm init -y

Install Dependencies

npm i express 

Install Development Dependencies

npm i -D ngrok



Docker Setup

Create in the project root

Dockerfile

FROM node:12.18.1
 
# Install ngrok https://ngrok.com/download
RUN curl -s -O https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip \
    && unzip ngrok-stable-linux-amd64.zip \
    && mv ngrok /usr/local/bin/ \
    && rm -f ngrok-stable-linux-amd64.zip
 
WORKDIR /app
 
COPY package*.json ./
 
RUN npm ci
 
COPY . .
 
ENV PORT=8080
ENV NODE_ENV=development
ENV LOCAL_DEV=true
# ENV callbackUrl= [Prod URL]
 
EXPOSE 8080
 
CMD [ "npm", "start" ]

Brief DockerFile rundown,

  • We start from a node image of 12.18.1
  • Install ngrok within the container
  • Create our working directory and install our npm modules (as if setting up our project from scratch)
  • Set our environment variables we can access in the node runtime via process.env.*
  • Expose the docker container to be accessible via Port 8080.
  • Finally start the node server

Create in the project root

.dockerignore

node_modules

The .dockerIgnore behaves as a .gitignore. Since we install our npm modules each time we build our Docker image, we want to ignore the existing modules from our image. Which will greatly reduce the size of the image as well.



Create our application server

index.js

const express = require('express');
const app = express();
const PORT = process.env.PORT || 8080; // set via DockerFile ENV
const NODE_ENV = process.env.NODE_ENV || 'development';  // set via DockerFile ENV
const LOCAL = process.env.LOCAL_DEV || false;  // set via DockerFile ENV
let callbackUrl = process.env.callbackUrl;  // set via DockerFile ENV
 
// if running locally, set the callbackUrl to one assigned via ngrok
if (LOCAL) {
  (async () => {
    callbackUrl = await require('./exposeServer')(PORT)
    console.log(`Server available at ${callbackUrl}`);
  })();
}
 
app.use(express.json());
app.use(express.urlencoded());
 
app.get('/', (req, res) => {
  res.send('Hello World!')
});
 
app.post('/results', (req, res) => {
  // process results here 
});
 
app.listen(PORT, () => {
  console.log(`Example app listening at http://localhost:${PORT}`)
});

Basic boilerplate node/express server, if the process.env.LOCAL environment variable is set, we require ngrok helper module mentioned below and use the ngrok service to expose the application to the web and assign a dynamic DNS record.



Helper function to expose our server

exposeServer.js

const ngrok = require('ngrok');
 
/**
 * Should only be used for local development
 * @param {*} port
 */
async function exposeLocalServer(PORT) {
  let localCallback;
  try {
    localCallback = await ngrok.connect({ addr: PORT })
  } catch(err) {
    console.error(err);
  }
  return localCallback;
}
 
module.exports = exposeLocalServer;

This is a helper async function that requires the ngrok npm package that gives us a javascript based API to interact with ngrok's services. We give it a port, and it will assign a DNS that any computer on any network can access. This will only work if the ngrok executable is installed on the machine (Installed as part of our Docker Image)



Update package.json Scripts

package.json

{
    "scripts": {
        "start": "node index.js",
        "docker": "npm run docker-clean && npm run docker-build && npm run docker-run",
        "docker-build": "docker build --no-cache --tag node-server .",
        "docker-run": "docker run --name node-server-api node-server",
        "docker-clean": "docker stop node-server-api || true && docker image prune -f && docker container prune -f"
    }
}

*Since we opted to not use docker-compose, building and starting the docker image/container can get tiring to type in manually. Just run npm run docker, which will remove the old image, build the new updated image and finally run the container.

Test access to the Dockerized Node Server

Go ahead and run npm run docker

After the image is built and the container has started, should see the following logged to the console.

Example app listening at http://localhost:8080
Server available at https://6ccc57d4060b.ngrok.io

Go to your browser on another device and navigate to https://*.ngrok.io and should receive back a response of Hello World!



Work with a 3rd Party API Callback

Since your node application has access to this ngrok address in the application layer, it can pass this information to an API/Service.

This scenario is usually necessary for requests that take a long time to process, and requires setting a callback endpoint of where to send the results back to.

Unfortunately, not aware of any public API's out there that have a callback interface to send results back to but here's a bit of pseudo-code.

...
const fetch = require('isomorphic-unfetch') // npm i isomorphic-unfetch
 
let data = [{...}];
let callbackUrl; // https://6ccc57d4060b.ngrok.io
 
const req = fetch('https://example.com/processdata/', 
    method: 'POST',
    headers: { 'Content-Type': 'application/json'},
    body: JSON.stringify({
      data,
      // where 3rd party will send results to
      callback: `${callbackUrl}/results`
    })
})
 
// handling post request from 3rd party
app.post('/results', (req, res) => {
  // process results here 
});
 

Conclusion

This is just one way to run your environment locally if this was a barrier to do so previously. You do not need to use docker with ngrok, but if you're familiar with docker it's an added convenience. Hopefully, this helps you as it has helped me. Cheers!