The perfect multi-stage Dockerfile for Node.js apps

James Harrison
FL0 Engineering
7 min readFeb 2, 2023

--

If you’re reading this, chances are you’ve been scouring the web for a decent article on setting up a good multi-stage Dockerfile for your Node.js project. One that will hot-reload when you make a code change. One that has a production version that’s as small as possible. Well, this is the last article you’ll have to read!

Here’s the list of things we wanted to make sure our Dockerfile could do:

  1. Run locally with dev dependencies
  2. Hot-reload when code is changed on our machine
  3. Build a production-ready version that excludes dev dependencies
  4. Produce in a reasonably small container image file size
  5. Edit: Debugging with breakpoints!

Seems simple enough, right? Well there are a lot of blog posts out there for each one of those dot points, but we aim to put them all together in a simple guide.

Getting Started

Example Repository

We’re going to use the fl0zone/blog-express-pg-sequelize from a previous blog post as our starting point. Check out that code, and make sure to use the branch called building-deploying-30-mins!

And if you want to skip ahead to the answers, you can find the completed code from this article in the same repo on the multi-stage-dockerfile branch.

Environment Variables

Copy the .env.example file in the root of the repo and call it .env.

Deploying to the cloud

If you want a really simple way to deploy your container to a scalable infrastructure, check out FL0! It’s a platform that makes it as simple as possible to go from code to cloud, complete with dev/prod environments, databases and more. All you have to do is commit your code and FL0 will handle the rest.

The Basics

Our sample repository already has a basic docker-compose.yml file which is currently being used to spin up a local Postgres database. To run our app, use these commands:

$ npm install
$ docker compose up -d
$ npm run start:dev

Verify your app is working by visiting http://localhost:3000/ in your browser.

Now, instead of running our app on our local machine, we want to run it inside a Docker container. Let’s start by creating a file called Dockerfile in the root of the repository:

# Use a Node.js base image so we don't have to install a bunch of extra things
FROM node:16

WORKDIR /usr/src/app

# Copy the package.json and package-lock.json files over
# We do this FIRST so that we don't copy the huge node_modules folder over from our local machine
# The node_modules can contain machine-specific libraries, so it should be created by the machine that's actually running the code
COPY package*.json ./

# Now we run NPM install, which includes dev dependencies
RUN npm install

# Copy the rest of our source code over to the image
COPY ./src ./src

EXPOSE 80

# Run our start:dev command, which uses nodemon to watch for changes
CMD [ "npm", "run", "start:dev" ]

The comments in the file explain each step. It’s a pretty simple Dockerfile!

Next, in order to use this Dockerfile we need to modify our docker-compose.yml and add a new service called app that uses the Dockerfile.

version: '3'
services:
app:
build: .
env_file: .env
ports:
- 3000:80
depends_on:
- db
db:
image: postgres:14
restart: always
environment:
POSTGRES_USER: ${PGUSER}
POSTGRES_PASSWORD: ${PGPASSWORD}
POSTGRES_DB: ${PGDATABASE}
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- 5432:5432
volumes:
postgres-data:

As you can see, it specifies the current directory as the build context (which means it reads our new Dockerfile), passes in the .env file we already have, maps port 3000 of our local machine to port 80 of the Docker container and makes sure the db service is already running before starting.

Two last change to make, and they’re both to our .env file.

  1. The PORT variable needs to be set to 80 instead of 3000, because our Dockerfile exposes 80 and then gets mapped to 3000 on our local machine with the docker-compose.yml file.
  2. The DATABASE_URL needs to be updated to point to the db service instead of localhost. This is because comms between our app and database is now happening between two containers, not from our machine into a Docker image.

Make sure your .env file looks like this:

NODE_ENV=local
PORT=80
PGUSER=admin
PGPASSWORD=admin
PGDATABASE=my-startup-db
DATABASE_URL=postgres://admin:admin@db:5432/my-startup-db

Let’s test it out! Open up a terminal and run the following command:

$ docker compose up

Verify it’s working by reloading http://localhost:3000/ in your browser.

Hot-Reloading

This is great, but if you make a change to your code you will see that the Docker image isn’t being updated. This is because we copied the source code over, and there is one copy on our local machine and another in the Docker image.

To fix this, we can mount a volume on our Docker image so that our local machine and container both share the same code. Update your docker-compose.yml file to look like this, noticing the volumes section:

version: '3'
services:
app:
build: .
env_file: .env
ports:
- 3000:80
volumes:
- ./src:/usr/src/app/src
depends_on:
- db
db:
image: postgres:14
restart: always
environment:
POSTGRES_USER: ${PGUSER}
POSTGRES_PASSWORD: ${PGPASSWORD}
POSTGRES_DB: ${PGDATABASE}
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- 5432:5432
volumes:
postgres-data:

Now, if we make a change to our source code, we can refresh the browser and see the changes updated instantly! Run this to make the changes take effect:

$ docker compose up --build

Building for Production

The solution we have so far is great for working locally, but there are a couple of problems if we want to use this for Production. The image size is way too big. Run docker container ls -s to see your app container is probably around 800mb). The other problem is that we ran npm install, which includes dev dependencies and things we may not want to ship.

One of the great things about Docker is its ability to have multi-stage builds inside a single Dockerfile. This means we can keep our Development build, and do some optimization for production. And what’s even better is we can copy files from one “stage” into another. Here’s what we’ll do:

  1. Keep our “Development” stage as-is
  2. Create a new “Builder” stage that extends from the “Development” stage but removes the node_modules folder and runs an NPM clean install with production-only dependencies
  3. Create a “Production” stage using a much smaller Docker image and copy the built files across from the “Builder” stage

Open up your Dockerfile and alter it to look like this:

# Use a Node.js base image so we don't have to install a bunch of extra things
FROM node:16 as development

WORKDIR /usr/src/app

# Copy the package.json and package-lock.json files over
# We do this FIRST so that we don't copy the huge node_modules folder over from our local machine
# The node_modules can contain machine-specific libraries, so it should be created by the machine that's actually running the code
COPY package*.json ./

# Now we run NPM install, which includes dev dependencies
RUN npm install

# Copy the rest of our source code over to the image
COPY ./src ./src

EXPOSE 80

# Run our start:dev command, which uses nodemon to watch for changes
CMD [ "npm", "run", "start:dev" ]

# "Builder" stage extends from the "development" stage but does an NPM clean install with only production dependencies
FROM development as builder
WORKDIR /usr/src/app
RUN rm -rf node_modules
RUN npm ci --only=production
EXPOSE 80
CMD [ "npm", "start" ]

# Final stage uses a very small image and copies the built assets across from the "builder" stage
FROM alpine:latest as production
RUN apk --no-cache add nodejs ca-certificates
WORKDIR /root/
COPY --from=builder /usr/src/app ./
CMD [ "node", "src/index.js" ]

We now have three FROM statements, each extending on from the last. Each stage has a different CMD as well, so we can run with watch mode turned on in Development but use Node directly in Production.

If we were to build this Dockerfile now, it would execute all three stages. Which is great except when we want to run something in Development. To do that, we need to change our docker-compose.yml file to specify which target to build. Open it up and change it to look like this:

version: '3'
services:
app:
build:
context: .
target: development
env_file: .env
ports:
- 3000:80
volumes:
- ./src:/usr/src/app/src
depends_on:
- db
db:
image: postgres:14
restart: always
environment:
POSTGRES_USER: ${PGUSER}
POSTGRES_PASSWORD: ${PGPASSWORD}
POSTGRES_DB: ${PGDATABASE}
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- 5432:5432
volumes:
postgres-data:

The build section which now specifies a context of the current directory, and a target of our Development stage.

Go ahead and try it out! Run docker compose up again and notice the hot-reloading works. If you want to try out your production build, you can run this:

$ docker build . -t my-startup:latest

And check this out…the image size of the Development stage is 884MB vs 73.7MB for Production! That’s going to be way faster to work with and deploy.

Debugging with Breakpoints

To take this a step further and debug the code inside your container with an IDE, see my follow-up article The best way to debug a Node.js app running in a Docker container.

Deploying

Speaking of deploying, how can we easily get this application up and running in a cloud environment in a scalable way, complete with database? Well if you’re using FL0, you can just commit your code and the platform will handle the rest! Check out the FL0 docs on how to get setup.

--

--