By: Simon Goring

Moving a Node.js App to Heroku with Docker

This application is all stored in a GitHub repository: https://github.com/SimonGoring/node_docker. Unfortunately I didn’t initialize the git repo right away, so you can’t follow along easily with the commits, but the code is all there, including this file.

I’ve been working with node.js as a tool to rebuild the Neotoma Database’s API. As part of that process we’d like to build a self-contained unit that can be used to do development locally, but also to serve the container on the remote server.

While I’m familiar with Docker and node, I haven’t put the two together, so this is a first attempt at putting it all together to generate an app up on AWS. I am following documentation on the node.js website.

Setting up node.js and express

Creating your Repo

This should have been the first thing I did, but it wasn’t. With whatever version control system you want generate a new repository and clone it locally. Later on we’re going to use Heroku to deploy the application. One of the options with a Heroku instance is to link it to your GitHub (or other) repository, so that new pushes get automatically deployed to Heroku. This way you just have to commit and push once to get the benefit of an updated instance and version control.

As mentioned above, my repo for this project is at https://github.com/SimonGoring/node_docker.

Creating the package

The tutorial asks us to start with a package.json file, but I went with using npm install at the command line, and then hand editing so that I got something like this:

{
  "name": "node_docker",
  "version": "0.1.0",
  "description": "Trying out a simple node app with Docker.",
  "main": "server.js",
  "dependencies": {},
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Simon Goring",
  "license": "MIT"
}

To add the express dependency I used npm install express --save which resulted in a package.json that looked like this:

{
  "name": "node_docker",
  "version": "0.1.0",
  "description": "Trying out a simple node app with Docker.",
  "main": "server.js",
  "dependencies": {
    "express": "^4.15.3"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Simon Goring",
  "license": "MIT"
}

This looks pretty close to what was in the node.js tutorial, the differences are the name and general descriptive tags. I also don’t have the scripts tag set up properly. npm start automatically runs server.js when it runs, and I went in and deleted the scripts:test line. I’ll work on tests later, so for completeness I went and edited the scripts section. So now we’re at a package.json file that looks like this:

{
  "name": "node_docker",
  "version": "0.1.0",
  "description": "Trying out a simple node app with Docker.",
  "main": "server.js",
  "dependencies": {
    "express": "^4.15.3"
  },
  "devDependencies": {},
  "scripts": {
    "start": "node server.js"
  },
  "author": "Simon Goring",
  "license": "MIT"
}

Writing up the Server JavaScript

The basic app is in a file called server.js in the main directory. I was reading some documentation that talks about keeping things out of the root directory. In general I like this idea, I think that having all these files in the root directory makes things messy when you’re looking at a GitHub repo, but that’s maybe just me.

Regardless, I basically copied the server.js file from the tutorial with some minor changes:

'use strict';

// We're using the express library we pulled in using npm:
const express = require('express');

// Constants (note this will get weird later)
const PORT = 8080;

// We start an express app:
const app = express();

// At the root, create a `send` element for the `res`ponse.  There is no 
// option to pass in a `req`uest at this time.

app.get('/', function (req, res) {
  res.send('<h1>Hello world</h1>\nAnd a special hello to Simon!');
});

// Then listen to the port to see if there are any `get` calls to the root (with the response defined above):
app.listen(PORT);
console.log('Running on http://localhost:' + PORT);

A couple points about the code as it is. (1) The use of 'use strict' means that code is executed in as literal a manner as possible.

  • Variables can only be created when declared. Mis-typing a variable name doesn’t create a new variable, it throws an error.
  • Assignments to properties that can’t be overwritten throws an explicit error.
  • It prevents non-unique parameters.

There’s good documentation on the Mozilla Developer’s strict mode page.

I’ve heavily commented it, for the purposes of this document, but that’s not necessary. You can also change the function in the app.get command to return anything you want. You can even add more endpoints, as I’m slowly doing in the Neotoma API using the express.Router.

Running the Server code

The app only really prints out “Hello World” to the browser, and then logs “Running. . . “ to the log. We can test it out by running the same command that we used in our scripts:start element:

node server.js

(this works for me)

Setting up Docker

Docker is a container system for your computer. Think of it as a computer inside a computer. It’s similar to a Virtual Machine, except it uses your existing computer’s OS, so it’s more lightweight. It lets you package up the components needed to develop an app in a clean environment, so they you don’t wind up with conflicts between one project and another, and it lets you clearly define the components, so that installation in a new system is not affected by different environment variables, packages or dependencies. This makes Docker very useful for projects where you are either developing multiple projects, projects where you might be deploying to a different system, or projects where you are collaborating with a number of colleagues using different systems.

You need to install Docker before you can use it. I will not go over that. You can read the Installing Docker documentation from Docker.

Briefly, the Dockerfile

To run Docker, you need a Dockerfile from which to initialize the container. Docker has good documentation on Dockerfile best practices that is worth reading. We use the FROM argument to define the location of the image that we’ll be using as the basis of our container, we tell Docker what to RUN as it’s installing things, we tell it where to COPY the files it will be using, we tell it how to RUN npm to set up the project locally, and then we copy, EXPOSE a port, and then call the CMD to actually start the project.


# Define the docker hub image: https://hub.docker.com/_/node/
FROM node:alpine

# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

# Install app dependencies
COPY package.json /usr/src/app/
RUN npm install

# Bundle app source
COPY . /usr/src/app

EXPOSE 8080
CMD [ "npm", "start" ]

Again the Dockerfile

So, we create a Dockerfile in our local directory and we’ll use it to start our app. When it starts it will download the node image, then in the container (which Docker creates) we create a /usr/src/app folder, which is where the source code for the app is going to go.

The Dockerfile then sets the WORKDIR to this folder, and moves the local package.json file from the current directory into the new /usr/src/app/ folder. Once that’s done, in the WORKDIR it runs npm install to initialize the node.js application we’ve written above.

Once the application is initialized then we copy all the files in the current directory (.) into the /usr/src/app folder. Given this, I would probably change some of these parameters around if we used a nested directory structure.

Finally, the node.js app, by default, is broadcast through port 8080. This means that we need to let localhost:8080 broadacst to the pasten machine (ours). Finally we us the CMD npm start, but it’s concatenated.

And the .dockerignore

The Dockerfile Best practices says we should also add a .dockerignore file. Here we’ll put our node files:

node_modules
npm-debug.log

The Moment of Truth

With the Dockerfile and the .dockerignore files created, the next thing to do is to build the Docker container. We build the container and attach it to the system so that we can run it when we’re ready. We need to give it an informative name so that we know where it is, so we tag it using the -t parameter:

docker build -t simon/node_docker .

As this is happening you should see a whole bunch of output that looks something like this:

Sending build context to Docker daemon  11.78kB
Step 1/8 : FROM node:alpine
alpine: Pulling from library/node
79650cf9cc01: Pull complete 
2d271477e3b2: Pull complete 
49eae82ca692: Pull complete 
Digest: sha256:ec27361dcb1a1467f182c98e3e973123fda92580ef7b60b17166f550124a98a3
Status: Downloaded newer image for node:alpine
 ---> 2a7d8107cda5


Removing intermediate container 8f84cc1f9584
Successfully built 0cb6b0d27398
Successfully tagged simon/node_docker:latest

This should mean that everything’s been loaded into a container. We can check to see if it’s there using docker image. You should see your container, with the tag, listed. It’s there, in the Docker app, but it’s not actually running right now.

To run a Docker container we need to use the command docker run. This will tell Docker to set up the files as defined in the Dockerfile. But there are some other things we need to do. First, in the Dockerfile we expose a port (8080). Because Docker is running inside the machine, there’s no guarantee that 8080 is available. We need to bind the Docker port to a port on the parent machine using the -p flag (-p 12345:8080) so it says that to get to the 8080 port on the container we have to go in through our own 12345 port. Then we want to be clear that the name of the app project we’re going to be running is simon/node_docker, since we may have multiple Docker containers on our computer.

docker run -p 12345:8080 -d simon/node_docker

Running this command gives you a long hash that is the container ID. You can copy this to a text file, or find your container ID and container status at any time using docker ps:

$ docker ps

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS                     NAMES
a7c2debade1a        simon/node_docker   "npm start"         9 minutes ago       Up 8 minutes        0.0.0.0:12345->8080/tcp   loving_einstein

You can take the CONTAINER ID (in this case a7c2debade1a) and use it to return the container logs:

$ docker logs a7c2debade1a

npm info it worked if it ends with ok
npm info using npm@4.2.0
npm info using node@v7.10.0
npm info lifecycle node_docker@0.1.0~prestart: node_docker@0.1.0
npm info lifecycle node_docker@0.1.0~start: node_docker@0.1.0

> node_docker@0.1.0 start /usr/src/app
> node server.js

Running on http://localhost:8080

In this case, it tells us that the container is running fine. As a lark, I am going to stop Docker, remove the container, make an edit to the server.js file to break it, and then start the process again:

$ docker stop a7c2debade1a
a7c2debade1a
$ docker rm a7c2debade1a
a7c2debade1a
$ echo "
console.lug(asa);" >> server.js
$ docker build -t simon/node_docker .
$ docker run -p 12345:8080 -d simon/node_docker

Once this is done everything should look fine, but if you take the has and look at your docker logs, you’ll see it’s a huge mess:

$ docker logs 40b1
npm info it worked if it ends with ok
npm info using npm@4.2.0
npm info using node@v7.10.0
npm info lifecycle node_docker@0.1.0~prestart: node_docker@0.1.0
npm info lifecycle node_docker@0.1.0~start: node_docker@0.1.0

> node_docker@0.1.0 start /usr/src/app
> node server.js

/usr/src/app/server.js:17
\nconsole.lug(asa);
^
SyntaxError: Invalid or unexpected token
    at createScript (vm.js:53:10)
    at Object.runInThisContext (vm.js:95:10)
    at Module._compile (module.js:543:28)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:427:7)
    at startup (bootstrap_node.js:151:9)

npm info lifecycle node_docker@0.1.0~start: Failed to exec start script

.
.
.

Gross. Don’t make mistakes, and when you do, go back and look at your logs. I cleaned this error up in server.js, and then went back and re-ran the container.

Testing the Implementation

So, we’ve got the container running in the background, we know it’s running well, now we want to actually test it. In the docker logs we see that the Docker container tells us it’s running on localhost:8080, but that’s a trick. It is running on 8080 within the container, but remember that in our docker run command above we assigned the container port (8080) to our host’s port of 12345. So, we can either navigate to http://localhost:12345, or use curl:

curl -i localhost:49160

Using the i flag in this way gives us both the Hello World response defined above and the header information.

Releasing the App

One of the advantages of Docker is being able to deploy the application to any platform in a consistent way. The platform (AWS, Heroku, Azure) manages the OS and Docker manages the required files. You can have multiple Docker containers, with different dependencies on a single platform as long as the ports don’t overlap.

In this example I’ve chosen Heroku as the platform. It provides free microinstances for public repositories. With my heroku account I can use the command line argument:

heroku start

to start a microinstance. In my case the microinstance returns:

Creating app... done, ⬢ still-brook-77635
https://still-brook-77635.herokuapp.com/ | https://git.heroku.com/still-brook-77635.git

We also need to install some auxillary packages for Heroku:

heroku plugins:install heroku-container-registry
heroku container:login

Once this is all done, we should be able to push it up to the web and have it compile up there.

heroku container:push web

It’s worthwhile to note that the first time I pushed this, as configured above, it failed with the error message:

Web process failed to bind to $PORT within 60 seconds of launch.

I found a question on StackOverflow that helped me fix the error. In the server.js file I created I changed the line:

app.listen(PORT);

to:

app.listen(process.env.PORT || 8080);

This works because process.env.PORT pulls the process’s environment variable PORT, and, if it doesn’t exist, it sets the port to 8080. Part of the reason this works is that Heroku defines a port for you, so you can’t count on 8080 being available, but on your local machine there is unlikely to be an existing PORT environment variable, and so 8080 becomes the port of choice.

So, changing this parameter, commiting and then pushing back to Heroku with:

heroku container:push web

then allows the app to run in the cloud. You can see my incredibly basic app here.

Conclusion

So now we have a workflow to develop an app locally inside a container that keeps it quarrantined from the rest of our computer, and because the container is self describing, that lets us share the work with anyone we want to, with little or no fear that their system will over-ride our requirements. We also can then use a web service (Heroku) to push the container up and serve it from the web. Great stuff everyone!

I’m going to keep working on this, but if you have comments, suggestions or want to fix mistakes, please feel free to fork this repo, or raise issues in the repository issue tracker.