Rapid development with Node.js and Docker
Bullet proof guide on how to create tiny Docker images for Node.js in production, configure your dev environment, automate your release cycle and more!
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:
@developius didn't you guys dockerize your URL shortener?
— Leigh Capili (@capileigh) July 11, 2017
maybe heptio can use 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.