Finnian Anderson

My hackerspace

Rapid development with Node.js and Docker

I have quite a few Nodejs repos on GitHub and until now I've not migrated any of them to Docker because I didn't really know how. The other day I had a ping on Twitter about the URL shortener I helped complete early this year, so I decided that now was the time to work out the best way to do it:

The service in question was built with Node and relied on MongoDB for storage. Seeing as the stack was so simple, I quickly wrote a Dockerfile and accompanying compose file so we could deploy the service to Docker Swarm. The difficulty we encountered was how to use different settings based on production or development env vars.

To achieve this, we went with multi stage builds, two Dockerfiles and two compose files (dev overrides prod settings) to make development and deployment as easy as possible. The reason for having two different Dockerfies is one very subtle difference between prod and dev. Essentially, in dev we wanted to include all the development dependencies (in this instance, it was just nodemon, but you might want to include chai for your test suite etc in practice) but we didn't want these extra dependencies in production as we didn't need them and it just inflated the image size. To get around this, we created a Dockerfile.dev which installed all the dependencies and was built by docker-compose. The multi stage build Dockerfile below is based upon the one by Codefresh and allows us to include only the production dependencies in the final image.

Docker config

Here are the four files:

# Production Dockerfile (Dockerfile)

#
# ---- Base Node ----
FROM node:8-alpine AS base  
# set working directory
WORKDIR /shortener  
# install git
RUN apk add --no-cache git  
# copy project file
COPY package.json package-lock.json ./

#
# ---- Dependencies ----
FROM base AS dependencies  
# install node packages
RUN npm set progress=false && npm config set depth 0 && npm cache clean --force  
RUN npm install --only=production  
# copy production node_modules aside
RUN cp -R node_modules prod_node_modules  
# install ALL node_modules, including 'devDependencies'
RUN npm install

# ---- Build ----
# build up docs
FROM dependencies AS build  
COPY . .  
RUN npm run build:docs

#
# ---- Release ----
FROM base AS release  
# copy production node_modules
COPY --from=dependencies /shortener/prod_node_modules ./node_modules  
# copy in built docs
COPY --from=build /shortener/docs ./  
# copy app sources
COPY . .  
CMD npm start  
# Development Dockerfile (Dockerfile.dev)
#
# ---- Base Node ----
FROM node:8-alpine AS base  
# set working directory
WORKDIR /shortener  
# install git
RUN apk add --no-cache git  
# copy project file
COPY package.json package-lock.json ./  
RUN npm i  
COPY . .  
CMD npm run dev  
# Development compose file (docker-compose.dev.yml)

version: '3.2'

services:  
  mongo:
    ports:
      - '27018:27017'

  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    volumes:
      - /shortener/node_modules # prevent them being overwritten by the above
      - .:/shortener
    environment:
      NODE_ENV: development
      SSL: 'false'
      URL: localhost
      PORT: 3001
    ports:
      - 3001:3001
# Production compose file (docker-compose.yml)
version: '3.2'

services:  
  mongo:
    image: mongo:latest
    volumes:
      - mongo-data:/data/db
    deploy:
      placement:
        constraints:
          - node.role == manager

  web:
    image: subjectrefresh/shortener:latest
    ports:
      - 80:80
    deploy:
      mode: replicated
      replicas: 6
    environment:
      NODE_ENV: production
      PORT: 80
      SSL: 'true'
      URL: subr.pw
    depends_on:
      - mongo

volumes:  
  mongo-data:

They're pretty self explanatory. The only notable things are that we used a data volume for Mongo and another volume to ensure the node_modules is not overwritten by the host's version. Also, Mongo's default port 27017 was bound to the host's at 27018 in dev (useful for debugging) and the port for the web server was set to 3001 in case 80 was already taken.

NPM setup

Now for the package.json file which we added a couple of scripts to so that deployment was a super simple command:

{
  ...
  "scripts": {
    "deploy:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --build",
    "deploy:prod": "docker-compose up --build",
  }
  ...
}

This means that to bring up the app on prod or dev, it's a simple npm run deploy:<dev/prod> command.

Deploy and release scripts

We also desired to make the process of releasing a new version as simple as possible. To do this, we wanted to take advantage of Docker Hub's automated build system but unfortunately it doesn't currently support multi stage builds. So for the time being, we build & push the Docker images as part of npm version which works really well.

Here's another script we added to package.json as npm's version hook:

"postversion": "./release.sh && git push && git push --tags"

Here is the release.sh script which builds an image with the correct tag and pushes it to the hub:

#!/bin/bash

# build images & push to hub

TAG=`node -p "require('./package.json').version"`  
IMAGE="subjectrefresh/shortener"

echo "Building $IMAGE:$TAG"  
docker build -t $IMAGE:$TAG . && \  
docker tag $IMAGE:$TAG $IMAGE:latest && \  
docker push $IMAGE:$TAG && \  
docker push $IMAGE:latest  

The final step was to create a deploy.sh script which runs all the commands needed to redeploy the app to production.

#!/bin/bash

git pull && docker pull subjectrefresh/shortener:latest && docker stack deploy -c docker-compose.yml shortener  

At the current time, we don't have CI or CD set up, but this is something we're looking to do in the future.

How do we use it?

Right! One command to get your dev environment setup, complete with hot reloading:

npm run deploy:dev  

Now write your code and see it automatically refresh in the terminal via docker volumes & nodemon. Simple.

Commit your changes and create a new version:

npm version <patch/minor/major>  

This command will:

  • bump the version in package.json
  • create a new git tag vx.x.x and push it to the remote
  • build docker images tagged with the same version and push them to the docker hub

Here's a demo:

I'm using three different commands for three different environments, see below for why:

  • npm run deploy:dev - your standard dev environment in compose (caching disabled, lots of debug etc)
  • npm run deploy:prod - production settings, run inside compose on your local machine (useful for testing prod against dev)
  • docker stack deploy -c docker-compose.yml - the command issued on production to bring up the app

I'm using both deploy:prod and the docker stack command so that I can ensure production works whilst still on my local machine. Stacks can only be deployed to a swarm, hence this will not work on your dev environment.

Sum up

We've been using this setup for our Shortener project and so far, it's worked really well and we've not encountered any problems with it. Our production image (currently v2.0.10) is weighing in at 50MB compressed which I think is acceptable, but there is certainly more we could do to decrease this. If anyone has any tips on reducing this, please let me know on Twitter @developius 😇

The only thing left to do is CI/CD which is something I'd like to do soon. We currently have a Docker swarm-hosted version of the app available at https://subr.pw, so automatically deploying to the swarm would be pretty cool.