Andrew Welch · Insights · #JavaScript #docker #node.js

Published , updated · 5 min read ·


Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.

Running Node.js in Docker for local development

You don’t need to know Dock­er to ben­e­fit from run­ning local dev Node.js build­chains & apps inside of Dock­er con­tain­ers. You get easy onboard­ing, and less hassle.

Devops folks who use Dock­er often have no desire to use JavaScript, and JavaScript devel­op­ers often have no desire to do devops.

How­ev­er, Node.js + Dock­er real­ly is a match made in heaven.

Hear me out.

You don’t have to learn Dock­er in-depth to reap the ben­e­fits from using it.

Whether you’re just using Node.js as a way to run a build­chain to gen­er­ate fron­tend assets that uses Grunt / Gulp / Mix / web­pack / NPM scripts, or you’re devel­op­ing full-blown Node.js apps, you can ben­e­fit from run­ning Node.js in Docker.

In this arti­cle, we’ll show you how you can uti­lize Dock­er to run your Node.js build­chains & apps in local dev with­out need­ing to know a whole lot about how Dock­er works.

Unless you install every NPM package you use globally, you already understand the need for containerization

We’ll be run­ning Node.js on-demand in Dock­er con­tain­ers that run in local dev only when you’re build­ing assets with your build­chain or devel­op­ing your application.

All you’ll need to have installed is Dock­er itself.

If you’re the TL;DR type, you can check out the exam­ple project we used at: eleven­ty-blog-base feature/​docker branch, or look at the master..feature/docker diff.

Link Why in the world would I use Docker?

I think this tweet from Adam Wathan is a per­fect exam­ple of why you would want to use Docker:

“Upgraded Yarn, which broke PHP, which needs Python to reinstall, which needs a new version of Xcode, which needs the latest version of Mojave, which means I need a beer and it’s only noon.”—Adam Wathan

Adam’s cer­tain­ly not alone, this type of depen­den­cy hell” is some­thing that most devel­op­ers have descend­ed down into at some point or another.

Depen­den­cy hell

And hav­ing one glob­al install for your entire devel­op­ment envi­ron­ment only gets worse from here:

  • Updat­ing a depen­den­cy like the Node.js ver­sion for one app may break oth­er apps
  • You end up using the old­est pos­si­ble ver­sion of every­thing to keep the tee­ter­ing devel­op­ment envi­ron­ment running
  • Try­ing new tech­nolo­gies is cost­ly, because your whole devel­op­ment envi­ron­ment is at risk
  • Updat­ing oper­at­ing sys­tem ver­sions often means putting aside a day (or more) to rebuild your devel­op­ment environment
  • Get­ting a new com­put­er sim­i­lar­ly means putting aside a day (or more) to rebuild your devel­op­ment environment

Instead of hav­ing one mono­lith­ic local devel­op­ment envi­ron­ment, using Dock­er adds a lay­er of con­tainer­iza­tion that gives each app you are work­ing on exact­ly what it needs to run.

Your computer isn’t disposable, but Docker containers are

Is it quick­er to just start installing stuff via Home­brew on your com­put­er? Sure.

But peo­ple often con­fuse get­ting start­ed quick­ly with speed. What mat­ters more is the speed (and san­i­ty) with which you finish.

So let’s give Dock­er a whirl.

Link Docker setup overview

We’re not going to teach you the ins and outs of Dock­er here; if you want that, check out the An Anno­tat­ed Dock­er Con­fig for Fron­tend Web Devel­op­ment article.

I also high­ly rec­om­mend the Dock­er Mas­tery course (if it’s not on sale now, don’t wor­ry, it will be at some point).

Instead, we’re just going to put Dock­er to work for us. Here’s an overview of how this is going to work:

Make­file + Dock­er­file = Dock­er Container

We’re using make with a Make­file to pro­vide a nice easy way to type our ter­mi­nal com­mands (yes, Vir­ginia, depen­den­cy man­ag­ing build sys­tems have been around since 1976).

Then we’re also using a Dock­er­file that con­tains the infor­ma­tion need­ed to build & run our Dock­er container.

We then lever­age NPM scripts in the scripts sec­tion of our package.json to run our build­chain / application:

Run­ning scripts from package.json in our Dock­er container

So we’ll type some­thing like:

make npm run build

And it will spin up our Node.js Dock­er con­tain­er, and run the build script that’s in the scripts sec­tion of our package.json.

Since we can put what­ev­er we want in the scripts sec­tion of our package.json, we can run what­ev­er we want.

It may seem complicated, but it’s actually relatively simple how it all works

So let’s have a look at how this all works in detail.

Link Docker setup detail

So as to have a real-world exam­ple, what we’re going to do is cre­ate a Dock­er con­tain­er that builds a web­site using the pop­u­lar 11ty sta­t­ic site generator.

I cre­at­ed a pull request (PR) for this 11ty Dock­er set­up, too, so who knows, it might be rolled into the eleven­ty-blog-base repo.

If you want to run this your­self with­out fol­low­ing the steps below, you can just clone my fork of the repo via:

git clone --branch feature/docker https://github.com/nystudio107/eleventy-base-blog.git

and then fol­low the Run Eleven­ty via Dock­er docs to get it up and running.

Keep in mind that this is just an example, we could be containerizing any Node.js buildchain or app

Now let’s take this step by step to get up and running.

So what we’ll do is make a clone of the eleven­ty-base-blog repo:

git clone https://github.com/11ty/eleventy-base-blog

Then we’ll make just one change to the package.json that comes from the repos­i­to­ry, adding an install npm script:

{
  "name": "eleventy-base-blog",
  "version": "5.0.2",
  "description": "A starter repository for a blog web site using the Eleventy static site generator.",
  "scripts": {
    "install": "npm install",
    "build": "eleventy",
    "watch": "eleventy --watch",
    "serve": "eleventy --serve",
    "start": "eleventy --serve",
    "debug": "DEBUG=* eleventy"
  },

MAKE­FILE

Next we’ll cre­ate a Makefile in the project direc­to­ry that looks like this:

TAG?=12-alpine

docker:
	docker build \
		. \
		-t nystudio107/node:${TAG} \
		--build-arg TAG=${TAG} \
		--no-cache
npm:
	docker container run \
		--name 11ty \
		--rm \
		-t \
		-p 8080:8080 \
		-p 3001:3001 \
		-v "${CURDIR}":/app \
		nystudio107/node:${TAG} \
		$(filter-out $@,$(MAKECMDGOALS))
%:
	@:
# ref: https://stackoverflow.com/questions/6273608/how-to-pass-argument-to-makefile-from-command-line

The way make works is that if you type make, it looks for a Makefile in the cur­rent direc­to­ry for the recipe to make. In our case, we’re just using it as a con­ve­nient way to cre­ate alias­es that are local to a spe­cif­ic project.

So we can use make as a short­cut to run much more com­pli­cat­ed com­mands that aren’t fun to type:

  • make docker — this will build our Node.js Dock­er image for us. You need to build a Dock­er image from a Dock­er­file before you can run it as a container
  • make npm run xxx — once built, this will run our Dock­er con­tain­er, and exe­cute the NPM script named xxx as list­ed in the package.json. For instance, make npm run build will run the build script

The TAG?=12-alpine line pro­vides a default Node.js tag to use when build­ing the image, with the num­ber part of it being the Node.js ver­sion (“alpine” is just a very slimmed down Lin­ux distro).

If we want­ed, say, Node.js 14, we could just change that to be TAG?=14-alpine and do a make docker or we could pass it in via the com­mand line for a quick tem­po­rary change: make docker TAG=14.alpine

It’s just that easy to switch the Node.js version

While it’s not impor­tant that you learn the syn­tax of make, let’s have a look at the two com­mands we have in our Makefile.

The \ you see in the Makefile is just a way to allow you to con­tin­ue a shell com­mand on the next line, for read­abil­i­ty reasons.

  • docker: # the com­mand alias, so we run it via make docker
    • docker build \ # Build a Dock­er con­tain­er from a Dockerfile
    • . \ # …in the cur­rent directory
    • -t nystudio107/node:${TAG} \ # tag the image with nystudio107/node:12-alpine (or what­ev­er ${TAG} is)
    • --build-arg TAG=${TAG} \ # pass in our ${TAG} vari­able as an argu­ment to the Dockerfile
    • --no-cache # Do not use cache when build­ing the image
  • npm: # the com­mand alias, so we run it via make npm run xxx, where xxx is the npm script to run
    • docker container run \ # Run a Dock­er con­tain­er from an image
    • --name 11ty \ # name the con­tain­er instance 11ty”
    • --rm \ # remove the con­tain­er when it exits
    • -t \ # pro­vide a ter­mi­nal, so we can have pret­ty col­ored text
    • -p 8080:8080 \ # map port 8080 from inside of the con­tain­er to port 8080 to serve our hot reloaded files from http://localhost:8080
    • -p 3001:3001 \ # map port 3001 from inside of the con­tain­er to port 3001 to serve the Browser­Sync UI from http://localhost:3001
    • -v "${CURDIR}":/app \ # mount a vol­ume from the cur­rent work­ing direc­to­ry to /app inside of the Dock­er container
    • nystudio107/node:${TAG} \ # use the Dock­er image tagged with nystudio107/node:12-alpine (or what­ev­er ${TAG} is)
    • $(filter-out $@,$(MAKECMDGOALS)) # a fan­cy way to pass any addi­tion­al argu­ments from the com­mand line down to Docker

We do the port map­ping to allow 11ty’s hot reload­ing to work dur­ing development.

DOCK­ER­FILE

Now we’ll cre­ate a Dock­er­file in the project root directory:

ARG TAG=12-alpine
FROM node:$TAG

WORKDIR /app

CMD ["run build"]

ENTRYPOINT ["npm"]

Our Dockerfile is pret­ty small, but let’s break down what it’s doing:

ARG TAG=12-alpine — Set the build argu­ment TAG to default to 12-alpine. If a --build-arg is pro­vid­ed, it’ll over­ride this so you can spec­i­fy oth­er Node.js version

FROM node:$TAG — Des­ig­nate which base image our con­tain­er will be built from 

WORKDIR /app — Set the direc­to­ry where the com­mands in the Dock­er­file are run to /app

CMD ["run build"] — Set the default com­mand to run build

ENTRYPOINT ["npm"] — When the con­tain­er is spun up, it’ll exe­cute npm xxx where xxx is an argu­ment passed in via the com­mand line, or it’ll fall back on the default run build command

Link Taking Docker for a spin

So let’s take Dock­er for a spin on this project. First we’ll make sure we’re in the project root direc­to­ry, and build our Dock­er con­tain­er with make docker:

❯ make docker
docker build \
		. \
		-t nystudio107/node:12-alpine \
		--build-arg TAG=12-alpine \
		--no-cache
Sending build context to Docker daemon  438.8kB
Step 1/5 : ARG TAG=12-alpine
Step 2/5 : FROM node:$TAG
 ---> 18f4bc975732
Step 3/5 : WORKDIR /app
 ---> Running in 6f5191fe0128
Removing intermediate container 6f5191fe0128
 ---> 29e9346463f9
Step 4/5 : CMD ["build"]
 ---> Running in 38fb3db1e3a3
Removing intermediate container 38fb3db1e3a3
 ---> 22806cd1f11e
Step 5/5 : ENTRYPOINT ["npm", "run"]
 ---> Running in cea25ee21477
Removing intermediate container cea25ee21477
 ---> 29758f87c56c
Successfully built 29758f87c56c
Successfully tagged nystudio107/node:12-alpine

Next let’s exe­cute the install script we added to our package.json via make npm install. This runs an npm install, which we only need to do once to get our node_module depen­den­cies installed:

❯ make npm install
docker container run \
		--name 11ty \
		--rm \
		-t \
		-p 8080:8080 \
		-p 3001:3001 \
		-v "${CURDIR}":/app \
		nystudio107/node:12-alpine \
		install

> eleventy-base-blog@5.0.2 install /app
> npm install

npm WARN deprecated core-js@2.6.11: core-js@<3 is no longer maintained and not recommended for usage due to the number of issues. Please, upgrade your dependencies to the actual version of core-js@3.

> core-js@2.6.11 postinstall /app/node_modules/core-js
> node -e "try{require('./postinstall')}catch(e){}"

> ejs@2.7.4 postinstall /app/node_modules/ejs
> node ./postinstall.js

Thank you for installing EJS: built with the Jake JavaScript build tool (https://jakejs.com/)

npm WARN lifecycle eleventy-base-blog@5.0.2~install: cannot run in wd eleventy-base-blog@5.0.2 npm install (wd=/app)
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@~2.1.2 (node_modules/chokidar/node_modules/fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@2.1.3: wanted {"os":"darwin","arch":"any"} (current: {"os":"linux","arch":"x64"})

added 437 packages from 397 contributors and audited 439 packages in 30.004s

15 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Final­ly, let’s fire up a hot reload­ing devel­op­ment serv­er, and build our site via make npm run serve. This is the only step you’ll nor­mal­ly need to do in order to work on your site:

❯ make npm run serve
docker container run \
		--name 11ty \
		--rm \
		-t \
		-p 8080:8080 \
		-p 3001:3001 \
		-v "${CURDIR}":/app \
		nystudio107/node:12-alpine \
		serve

> eleventy-base-blog@5.0.2 serve /app
> eleventy --serve

Writing _site/feed/feed.xml from ./feed/feed.njk.
Writing _site/sitemap.xml from ./sitemap.xml.njk.
Writing _site/feed/.htaccess from ./feed/htaccess.njk.
Writing _site/feed/feed.json from ./feed/json.njk.
Writing _site/posts/fourthpost/index.html from ./posts/fourthpost.md.
Writing _site/posts/thirdpost/index.html from ./posts/thirdpost.md.
Writing _site/posts/firstpost/index.html from ./posts/firstpost.md.
Writing _site/404.html from ./404.md.
Writing _site/posts/index.html from ./archive.njk.
Writing _site/posts/secondpost/index.html from ./posts/secondpost.md.
Writing _site/page-list/index.html from ./page-list.njk.
Writing _site/tags/second-tag/index.html from ./tags.njk.
Writing _site/index.html from ./index.njk.
Writing _site/tags/index.html from ./tags-list.njk.
Writing _site/about/index.html from ./about/index.md.
Writing _site/tags/another-tag/index.html from ./tags.njk.
Writing _site/tags/number-2/index.html from ./tags.njk.
Copied 3 files / Wrote 17 files in 0.74 seconds (43.5ms each, v0.11.0)
Watching…
[Browsersync] Access URLs:
 -----------------------------------
       Local: http://localhost:8080
    External: http://172.17.0.2:8080
 -----------------------------------
          UI: http://localhost:3001
 UI External: http://localhost:3001
 -----------------------------------
[Browsersync] Serving files from: _site

We can just point our web brows­er at http://localhost:8080 and we’ll see our web­site up and running:

eleven­ty-blog-base up and run­ning in a Node.js Dock­er container

If we make any changes, they’ll auto­mat­i­cal­ly be hot reloaded in the brows­er, so away we go!

“Yeah, so what?” you say?

Real­ize that with the Makefile and Dockerfile in place, we can hand our project off to some­one else and onboard­ing becomes bliss:

  • We won’t need to care what ver­sion of Node.js they have installed
  • They don’t even have to have Node.js installed at all, in fact

Addi­tion­al­ly, we can come back to the project at any time and:

  • The project is guar­an­teed to work, since the devops need­ed to run it is shrink wrapped” around it
  • We can eas­i­ly switch Node.js ver­sions with­out affect­ing any­thing else

No more nvm. No more n. No more has­sles switch­ing Node.js ver­sions.

Link Containerization as a way forward

Next time you have the oppor­tu­ni­ty to start fresh with a new com­put­er or a new oper­at­ing sys­tem, con­sid­er tak­ing it.

Don’t install Homebrew.

Don’t install Node.js.

Don’t install dozens of packages.

Instead, take the con­tainer­iza­tion chal­lenge and just install Dock­er, and run every­thing you need from containers

I think you may be pleas­ant­ly sur­prised at how it’ll make your life easier.