A great thing about modern front end projects is the speed at which you can iterate. Watch builds can take less than a second and immediately refresh the browser.

The naive approach here is to dump all our node_module work into docker itself, and every time any file within the container’s source changes, we npm install each time.

But ideally, we want our docker builds related to the front end to be:

restarted as infrequently as possible

as fast as possible

First Steps

We definitely need to get our node_modules and source over to docker, the question is how.

# Dockerfile for dev

FROM node:latest EXPOSE 8080

WORKDIR /bindmount # More on this in a bit # Hopefully you'd never actually do this, just copy everything, including locally installed node_modules

COPY ./ ./ RUN npm install --no-progress --ignore-optional CMD npm run start:dev # webpack-dev-server --host 0.0.0.0 --hot --inline

This and a webpack.config that looks like the following:

const path = require('path') module.exports = {

mode: 'development',

entry: {

'main': './index.js'

},

plugins: [

new require('copy-webpack-plugin')([

{ from: './index.html' }

])

],

// Necessary for file changes inside the bind mount to get picked up

watchOptions: {

aggregateTimeout: 300,

poll: 1000

}

}

[(full source here)](https://github.com/wegry/docker-hot-reload/tree/naive). Running docker-compose up --build and hitting http://0.0.0.0:8080/ gets you Hello, docker in the dev console

The problem with this is that there is no way to communicate local changes to docker-compose without rerunning the whole npm install and restarting webpack.

Adding a bind mount allows us to propagate changes from the host machine to docker proper. The bind mount (confusingly, the ./ in the volume’s section of the docker-compose.yaml, see below) ensures that the only reason we need to restart docker compose, or the frontend service, is if the package.json , webpack.config.js , or package-lock.json changes. Any other file changes locally get pulled into the docker-compose running and reflected immediately.

# docker-compose.yaml

version: "3" services:

frontend:

image: webpack

build:

context: ./app

ports:

- 8080:8080

volumes:

- ./app:/bindmount:rw

Some Tweaks

This works, but there’s an immediate speed bump. We really only want to reinstall node_modules when a library version has changed, or the package-lock.json or yarn lock file changes.

The trick is to use docker’s layering. https://hackernoon.com/using-yarn-with-docker-c116ad289d56 attempts to solve this problem in a different way. Our Dockerfile for dev looks like this:

FROM node:latest EXPOSE 8080

WORKDIR /bindmount

COPY package-lock.json package.json ./

RUN npm install --no-progress --ignore-optional CMD npm run start:dev # webpack-dev-server --host 0.0.0.0 --hot

There are a couple problems though. Bind mounts open you up to compatibility issues with native binaries. node-sass , for example, being something that you’d like to pull into your project, installing node_modules locally on a non-linux host will break the build in the docker container. That is if you do an npm install on the host machine after start up docker compose

The way around this, is to use a docker volume. Basically, all platform dependent code, or at least, the tainted code, lives only within docker, and persists between builds. We add a couple lines to the docker-compose.yaml:

# docker-compose.yaml

version: "3" services:

frontend:

image: webpack

build:

context: ./app

ports:

- 8080:8080

volumes:

- ./app:/bindmount:rw

# The volume is effectively hiding node_modules from the host and must be in this order

- node_modules:/src/node_modules volumes:

node_modules:

and add the corresponding sass loader to the webpack-config:

const path = require('path') module.exports = {

mode: 'development',

entry: {

'main': './index.js'

},

module: {

rules: [{

test: /\.scss$/,

use: [{

loader: 'style-loader'

}, {

loader: 'css-loader'

}, {

loader: 'sass-loader'

}]

}]

},

plugins: [

new require('copy-webpack-plugin')([

{ from: './index.html' }

])

],

watchOptions: {

aggregateTimeout: 300,

poll: 1000

}

}

[Full source for this bit](https://github.com/wegry/docker-hot-reload/tree/on-a-volume) once again. Now we can use native binaries and have hot reloading together. Once it’s up and running, it’s hard to go back to a non-hot reloaded setup.

Wrapping up

This setup seems to work relatively well. The biggest snag is updating node_modules. Sometimes package bumps don’t get propagated through. I’m not particularly sure of what actually causes this, maybe it’s services being killed and not stopped gracefully maybe not.

There’s two ways around node_modules version bumps not going through. docker-compose exec [service-name] npm install will reinstall the node_modules in your running docker-compose service.

Enjoy.