Dockerizing an Express App For Development and Production
Learn how to use Docker to build and run Express applications in development and production.
Table of Contents 📖
- Why Dockerize a Node App?
- Project Initialization
- Creating a Development Node Docker Image
- Creating a Production Node Docker Image
Why Dockerize a Node App?
Dockerizing a project is a good idea because it solves the "it only works on my machine" problem by providing a consistent and isolated environment. For example, Dockerizing a Node app will ensure that the program behaves the same on a MacOS machine as it would on a Windows or Linux machine.
Project Initialization
To begin, lets initialize an empty directory as an npm project.
npm init es6 -y
Now lets install some dependencies. We will install Express as a dependency and nodemon as a development dependency.
npm i express
npm i nodemon -D
Express will handle user requests while nodemon will provide live code updates to speed up the development process. Finally, lets create a src folder to hold our source code, including a server.js file.
mkdir src
cd src
touch server.js
Inside this server.js file, lets set up a simple route that just returns the current node environment.
import express from 'express';
const app = express();
const PORT = process.env.PORT;
const NODE_ENV = process.env.NODE_ENV;
app.get('/', (req, res) => {
return res.status(200).send(`<h1>Hello there! NODE_ENV is ${NODE_ENV}</h1>`);
});
app.listen(PORT, () => {
console.log(`Server is running on port: ${PORT}.`);
});
Creating a Development Node Docker Image
Now lets create a development Node Docker image. We can do this by using a Dockerfile called Dockerfile.dev.
touch Dockerfile.dev
A Dockerfile is a text file that Docker uses to build a Docker image. This Dockerfile will tell Docker how to build a Docker Node image for development.
FROM node:20-alpine
ENV NODE_ENV development
ENV PORT 3000
WORKDIR /app
COPY package*.json .
RUN npm i
CMD [ "npm", "run", "start:dev" ]
- FROM node:20-alpine - Build a Node image using Node version 20 with the Alpine linux distribution. Alpine provides a small build environment that includes a minimal set of tools and libraries.
- ENV NODE_ENV development - Create a NODE_ENV environment variable with the value development.
- ENV PORT 3000 - Create a PORT environment variable with the value 3000.
- WORKDIR /app - For any RUN, CMD, ENTRYPOINT, COPY, or ADD instruction in the Dockerfile, it should be executed in the /app directory.
- COPY package*.json . - Copy over the package.json and package-lock.json files to the /app directory.
- RUN npm i - Install the project dependencies, including development dependencies.
- CMD [ "npm", "run", "start:dev" ] - Start the application using the npm start:dev command when the container is running.
Lets create the start:dev npm script inside package.json.
"start:dev": "nodemon ./src/server.js"
This script will then be executed when the container is up and running. Now lets build the image using the docker build command. We will create an npm script to do this for us.
"docker-build:dev": "docker build -t my-node-image-dev:0.0.1 -f Dockerfile.dev ."
This command creates a docker image from our Dockerfile.dev file. The image is given the name my-node-image-dev and the version 0.0.1. Now if we run this npm command we should see the following output:
npm run docker-build:dev
> docker-build:dev
> docker build -t my-node-image-dev:0.0.1 -f Dockerfile.dev .
[+] Building 1.6s (10/10) FINISHED docker:desktop-linux
=> [internal] load .dockerignore 0.1s
=> => transferring context: 2B 0.0s
=> [internal] load build definition from Dockerfile.dev 0.1s
=> => transferring dockerfile: 211B 0.0s
=> [internal] load metadata for docker.io/library/node:20-alpine 1.0s
=> [auth] library/node:pull token for registry-1.docker.io 0.0s
=> [1/4] FROM docker.io/library/node:20-alpine@sha256:2f46fd49c767554c089a5eb219 0.0s
=> [internal] load build context 0.4s
=> => transferring context: 146B 0.4s
=> CACHED [2/4] WORKDIR /app 0.0s
=> CACHED [3/4] COPY package*.json . 0.0s
=> CACHED [4/4] RUN npm install 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:d6253704afef7f44040b82c1a90a67a8a05da1aa7608c6a9b7e61 0.0s
=> => naming to docker.io/library/my-node-image-dev:0.0.1 0.0s
This indicates that our image was built successfully, now lets check the presence of this image by running the command docker images.
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
my-node-image-dev 0.0.1 21d18b2268f8 10 seconds ago 262MB
Now that we have our image we need to create a container from it. To do that, we use the docker run command. Lets also make an npm script for this.
"docker-run:dev": "docker run -it -p 3000:3000 --name my-node-c-dev -v ./:/app -v my-node-modules:/app/node_modules my-node-image-dev:0.0.1"
- docker run - Creates a container from an image. The image here is my-node-image-dev:0.0.1. The image we created with our Dockerfile.dev file.
- -p 3000:3000 - Exposes port 3000 on the container to port 3000 on the host.
- -it - Allows us to interact with the container through the terminal.
- --name my-node-c-dev - Sets the name of the container to be my-node-c-dev.
- -v ./:/app - Sets up a volume between the host and the container so any changes in the project on our host machine are reflected in the container.
- -v my-node-modules:/app/node_modules - Ensures that the node_modules installed on the container are specific to its operating system. Certain packages, such as bcrypt, have operating system dependent files so we don't want to overwrite the container OS specific files with the host OS specific files.
Now we just need to run our application with npm.
npm run docker-run:dev
> docker-run:dev
> docker run -it -p 3000:3000 --name my-node-c-dev -v ./src:/app/src my-node-image-dev:0.0.1
> start:dev
> nodemon ./src/server.js
[nodemon] 3.0.3
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,cjs,json
[nodemon] starting `node ./src/server.js`
Server is running on port: 3000.
Now lets make a curl to localhost:3000 and see what we get.
curl localhost:3000
<h1>Hello there! NODE_ENV is development</h1>
Now, as we have set up a docker volume between our computer and the docker container, when we update our source code on our computer it will be reflected in the container. As a demonstration, lets change the HTML returned to say "Download WittCepter! The best chrome extension!".
curl localhost:3000
Download WittCepter! The best chrome extension!
However, because we don't map our node_modules host folder to the one in Docker, we will need to run npm install both inside the container and outside. So say we wanted to install the npm package bcrypt, first we would install it locally with npm i bcrypt.
npm i bcrypt
Now, because of our source code volume, the container's package.json file will have bcrypt listed. So now we just need to run npm install inside the container which can be done with the docker exec command.
docker exec my-node-c-dev sh -c "npm i"
This tells docker to execute the string "npm i" as a command inside the docker container called my-node-c-dev. After doing this, the container's node_modules will contain all the dependencies for bcrypt specific to its environment.
Creating a Production Node Docker Image
Now lets create a production Node Docker image. First, lets create a separate Dockerfile to build this image. We will call it Dockerfile.prod.
touch Dockerfile.prod
There will be a few key differences between this file and our development file.
FROM node:20-alpine
ENV NODE_ENV production
ENV PORT 3000
WORKDIR /app
COPY package*.json .
RUN npm install --omit=dev
COPY . .
CMD [ "npm", "start" ]
- FROM node:20-alpine - Build a Node image using Node version 20 with the Alpine linux distribution.
- ENV NODE_ENV production - Create a NODE_ENV environment variable and set it to production.
- ENV PORT 3000 - Create a PORT environment variable and set it to 3000.
- WORKDIR /app - For any RUN, CMD, ENTRYPOINT, COPY, or ADD instruction in the Dockerfile, it should be executed in the /app directory.
- COPY package*.json . - Copy over the package.json and package-lock.json files to the /app directory.
- COPY . . - Copy over our source code to the /app directory.
- RUN npm i --omit=dev - Install the project dependencies, excluding development dependencies. This will lead to a smaller node_modules folder and hence a smaller image.
- CMD [ "npm", "start" ] - Start the application using the npm start command when the container is running.
Now lets create the production version of our npm scripts.
"start": "node ./src/server.js",
"docker-build": "docker build -t my-node-image:0.0.1 -f Dockerfile.prod .",
"docker-run": "docker run -it -p 3000:3000 --name my-node-c my-node-image:0.0.1"
The first command simply starts the application using node. The second command builds our image using our production Dockerfile, Dockerfile.prod. The final command creates a container from our image. Note that we don't include a volume in the production code. This is because in production we don't want to map code to our host compuater, we just copy it over when the image is being built in Dockerfile.prod.