Dockerize an Express Application
How to dockerize an ExpressJS project built with Typescript
Today we will Dockerize our NodeJS(Express) Application. We will also add Hot reloading support for local development.
We will assume that we already have a NodeJS application up and running. If you are interested to see how we built the express application go to the following article.
https://www.mohammadfaisal.dev/blog/create-express-typescript-boilerplate
Get the NodeJS boilerplate
First, clone the express boilerplate repository where we have a working Express application with Typescript, EsLint and Prettier already set up.
We will integrate Docker on top of this.
git clone https://github.com/Mohammad-Faisal/express-typescript-skeleton.git
Why Docker?
I am glad you asked. Docker can abstract away most of the pain points of environment-related errors because Docker ensures that your application will be the same on your machine and on any other machine in the world.
Let's say your machine has NodeJS 14 installed. But you got an application that used NodeJS 12. So what do you do? Install both versions of Node? Yes, that's possible to do using some tools like NVM.
But if you dockerize your application, you don't need any extra burden on your machine. You can run any version of NodeJS inside the docker container.
I hope you get an idea of the benefits. Let's jump into the code!
Install Docker
If you don't have Docker already installed, just go to the the following link and follow the steps to install it.
Then create our Dockerfile
touch Dockerfile
Define environment
Let's open the'Dockerfile` and start building the configuration for Docker.
The first thing we will need is to define the environment. For our application, we will use NodeJS 16. You can see the available versions on Docker Hub
FROM node:16
But most of the time, we don't need the entire NodeJS environment. We can use a lightweight alternative.
FROM node:lts-alpine
Usually, alpine versions are lightweight, but it provides enough functionality for your application.
Create a working directory
The docker image will have a separate directory structure. So we need someplace inside the image to hold our application code.
Let's create a directory for our application.
WORKDIR /usr/src/app
Install dependencies
The image that we defined at the top already comes with NodeJS and NPM installed, so we don't have to worry about that.
The next thing we will need is to copy our package.json
file into the working directory that we just defined above.
# we are using a wildcard to ensure that both package.json and package-lock.json file into our work directory
COPY package*.json ./
Then install the dependencies. We can take advantage of Docker's RUN command.
RUN npm install
If you want to build the code for production, we can use the following command. The explanation about the command npm ci can be found here.
RUN npm ci --only=production
Copy the application code
You might be wondering why we didn't copy the whole application code into the working directory in the previous step. That's because Docker creates an intermediate image for each layer. And a layer is created for each command.
As we didn't need the application codes in the previous steps, it would be a waste of space, and the process would be less efficient if we copied the whole code in the previous steps.
If you are interested to learn more. Go here
Let's get the code now.
COPY . .
Now let's expose the internal application port. Because by default, Docker images don't expose any port for security reasons.
EXPOSE 3000
Running the application
Now we have all the codes inside the working directory. Let's build our application now.
RUN npm build
Now we will define the command to run our server.
As We will build our bundle inside the dist folder (Which we defined inside the tsconfig.json file), we have to run the built image.
CMD [ "node", "dist/index.js" ]
Notice we didn't use npm start to run the application because running with Node makes it efficient. Secondly, it causes exit signals such as SIGTERM and SIGINT to be received by the Node.js process instead of npm swallowing them.
Final Docker file
Now your docker file should look something like this:
FROM node:lts-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
# If you are building your code for production
# RUN npm ci --only=production
COPY . .
EXPOSE 3000
RUN npm run build
CMD [ "node", "dist/index.js" ]
Create a dockerignore file
We can further optimize our Docker image by avoiding copying some resources into the application. Create a .dockerignore
file and paste the following code there
Dockerfile
docker*.yml
.git
.gitignore
.config
.npm
.vscode
node_modules
README.md
Build our image
Let's build our image by running the following command.
docker build . -t express-typescript-docker
Notice the .
in the command. It is not accidental. and also, we are tagging our application by passing the -t
option
You can see the built image by running the following command.
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
express-typescript-docker latest 11b3fd2ab6d4 29 seconds ago 262MB
Run our image
Let's run our image inside a container by running the following command.
docker run -p 3000:3000 -d express-typescript-docker:latest
Notice the -p
option that maps the exposed docker port with our local machine port. In our Dockerfile, we exposed port 3000. And we mapped that same port to our machine.
We can do this if we want to run our application on port 4000 on our local machine.
-p 4000:3000
You can also pass environment variables into the container by passing -e "NODE_ENV=production"
into the command.
Inspect the Docker Containers
You can see the container state by running the following command.
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9be24fc778bb express-typescript-docker:latest "docker-entrypoint.s…" 8 seconds ago Up 7 seconds 0.0.0.0:3000->3000/tcp, 8080/tcp eager_bardeen
Test the endpoint
Now go to your browser or some HTTP client like Postman and hit the following URL.
http://localhost:3000
and see the result
{ "message": "Hello World!" }
Also, you can see the logs inside the container by running the following command. Get the container_id from the docker ps
command we ran earlier.
docker logs container_id
Connected successfully on port 3000
{ message: 'Hello World'}
Stop the container.
You can stop the running container by running the following command.
docker stop container_id
And check with `docker ps`` to see if that command succeeded.
Development Environment with Docker
The above configuration can work for production. But during development, we can't afford to build the image every time we change our code. To solve that problem, we need some kind of development set up so that our code changes are reflected instantly.
To do that, we can create a separate Dockerfile.dev
and add the following configuration
FROM node:lts-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
Let's create a docker-compose.yml
file in the root directory to achieve that.
and add the following configuration there
version: '3'
services:
express-typescript-docker:
environment:
- NODE_ENV=development
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- ./:/usr/src/app
container_name: express-typescript-docker
expose:
- '3000'
ports:
- '3000:3000'
command: npm run dev
Notice we are pointing to the Dockerfile.dev
to tell docker-compose about the file that is required to use.
It will create an image with the name express-typescript-docker and run it in development mode.
And we can run the following command to see it in action:
docker-compose up
Now you can try to change any code and hit save. Your code will be automatically updated!
You can stop the containers by hitting CTRL+C or running the following command.
docker-compose down
Using Docker compose in production
We can use docker-compose in production as well. To do that, let's create a separate Dockerfile.prod
FROM node:lts-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
Notice here we don't need the EXPOSE
and CMD
commands like before because docker-compose will take care of that.
Now let's create a docker-compose.prod.yml
file.
version: '3'
services:
ts-node-docker:
environment:
- NODE_ENV=production
build:
context: .
dockerfile: Dockerfile.prod
command: node dist/index.js
This will take the configuration from the Dockerfile.prod
and run the command node dist/index.js from there. The rest configuration will be taken from the default docker-compose.yml
file.
To start out the container in production, let's run the following command.
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Notice the -f flag, which tells docker-compose which files to use for configuration.
You can verify that the containers are running by running the following command.
docker ps
Or by hitting the http://localhost:3000
Improvements
Typing all these commands can be time-consuming so let's create a Makefile at the root of the project to make our lives easier!
up:
docker-compose up -d
up-prod:
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up
down:
docker-compose down
Now we can just run make up
or make up-prod
to run the containers.
Resources
Github repo
https://github.com/Mohammad-Faisal/express-typescript-docker