Dockerize an Express app and MongoDB
Charles Loder
Posted on May 15, 2023
I don't like dealing with databases, and I don't like doing tutorials with no idea of what's going on.
Somehow, in someway, something always goes wrong.
This tutorial has two goals:
- show how to set up an express app with a mongo database running in Docker
- explain what is happening in the Docker configs
The banner image was made with DreamStudio. I should really learn how to do prompt engineering.
What we'll be building
In this tutorial, we'll be building a simple todo app. The server is built with express, and the database will be MongoDB. The key is that the whole app will be dockerized, making dealing with the database easier, but we'll still be able to make changes to our server for development.
I won't get into the details of how Docker works internally (I don't think I could even begin to do that!) but rather make all the configuration needed understandable.
We'll look over
- the app itself
- the
Dockerfile
in the app - the
docker-compose.yml
file
A github repo with all the code can be found here.
Prereqs
The only things you need to have installed are Node (which if you're reading this, you probably have), and Docker.
The easiest way to get started with Docker is by installing Docker Desktop.
How the app works
The app is pretty simple.
There is only one view route /
. When a user hits /
, the app lists out all the todos in the database.
Whenever the user adds a new todo, deletes a todo, or deletes all the todos, a request is made to the appropriate route, the action is done (like add a new todo), and the app redirects them to /
page, where all the todos, including the one just added, are displayed.
This app is just a clone of this repo with some minor modifications.
Dockerizing
Here are our main building blocks for getting set up with Docker:
- the
Dockerfile
- the
docker-compose.yaml
Dockerfile
: our app
The first building block is the Dockerfile
. This is a set of instructions that tells Docker how to build our image
Ours looks like this:
FROM node:18
WORKDIR /app
COPY package*.json .
RUN npm install
COPY . .
CMD [ "npm", "run", "start:dev" ]
Let's break it down.
FROM node:18
First we use the node 18 image. This is an express app, so we need node.
WORKDIR /app
The next line sets the working directory to /app
. We don't have an /app
directory in our source, but Docker creates one inside of the container. This is sort of like a mkdir
and cd
command in one. It creates a new directory and sets that as our working directory.
COPY package*.json .
The COPY
command copies contents from your local machine to the working directory in the container.
Its syntax is like this:
COPY <src> <dest>
This command copies the package.json
and package-lock.json
files to the container.
RUN npm install
Pretty straightfoward — in the container's working directory, run npm install
.
COPY . .
As we've seen from the previous copy command, this copies the contents of our local machine into the container. This particular command, however, copies all the contents of repo (unless we have a .dockerignore
file).
CMD [ "npm", "run", "start:dev" ]
Now we start the app! You'll commonly see these commands expressed as an array like this.
The command npm run start:dev
is defined in our package.json
:
{
"name": "dcoker-todo",
"scripts": {
"start": "node app.js",
"start:dev": "nodemon -L app.js"
}
docker-compose.yaml
: running everything
Now we get to the good stuff — actually running our express app with a Mongo database.
Docker terminology can be a bit tricky at times, so let's recap.
In the previous section, we used a
Dockerfile
to create an image. An image defines our container. A container is an instance of an image running. In this section, we are going to use compose to build an application that's composed of multiple containers.
Before we run our app, there is one thing we need to so first; set up an .env
.
PORT=3000
MONGODB_URI=mongodb://db:27017
MONGODB_USER=admin
MONGODB_PASSWORD=admin
MONGODB_DATABASE=todos
The only value that isn't arbitrary is MONGODB_URI
. We'll look at that later.
We need to set up a docker-compose.yml
, this tells the compose command how to structure our application.
It can be a bit overwhelming at first
# version: "3.8" no longer needed
volumes:
db:
services:
app:
container_name: todo_app
build: .
ports:
- "3000:3000"
environment:
- PORT=${PORT}
- MONGODB_URI=${MONGODB_URI}
- MONGODB_DATABASE=${MONGODB_DATABASE}
volumes:
- .:/app
# if you comment out the line below, you will have to run npm install locally
- /app/node_modules
depends_on:
- db
links:
- db
db:
container_name: mongodb
image: mongo:latest
restart: always
volumes:
- db:/data/db
command: mongod
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD}
MONGO_INITDB_DATABASE: ${MONGODB_DATABASE}
ports:
- 27017:27017
The big picture
Let's break it down.
# version: "3.8" no longer needed
volumes:
services:
version:
You'll often see version number at the top, but this is no longer needed.
The version number is the version of the docker-composer.yml
file NOT the version of Compose.
In June 2023 they are updating docker compose
to v2. This tutorial is assuming you're using v2 (I'm on v2.10.2).
volumes:
Volumes are how data is persisted outside of the container. So when you tear down the container, the data (i.e. our todos) will still be there.
services:
These are the container in your app.
Now, let's dig into volumes more.
volumes
volumes:
db:
That's it! No, really. All we have to do is define a volume called db
, and we're good to go. We could add extra configuration, but we don't need to do that for this tutorial.
What does matter, however, is that we have to use the name db
everywhere to reference this volume. That will matter for our services, and it matters for our .env
file above. That's why MONGODB_URI
has to connect to mongodb://db:27017
. The db
has to match the name of the volume. The port number is the default port.
note: often, services is defined at the top of the file and volumes below. The ordering makes no difference.
services
services:
app:
db:
We have two containers — app
and db
.
services:app
services:
app:
container_name: todo_app
build: .
ports:
- "3000:3000"
environment:
- PORT=${PORT}
- MONGODB_URI=${MONGODB_URI}
- MONGODB_DATABASE=${MONGODB_DATABASE}
volumes:
- .:/app
- /app/node_modules
depends_on:
- db
links:
- db
container_name:
Just a name!
build: .
This tells docker which image to use to build the container. In this case, it's our Dockerfile
we defined above.
ports:
- "3000:3000"
This says to map port 3000 on our machine to port 3000 in the container (i.e. we can access the app by going to localhost:3000
)
environment:
- PORT=${PORT}
- MONGODB_URI=${MONGODB_URI}
- MONGODB_DATABASE=${MONGODB_DATABASE}
The sets the environment variables within the app from our .env
file. So when we do
var port = process.env.PORT || 3000;
app.listen(port, ...)
The PORT
is injected to the container from our .env
file.
volumes:
- .:/app
- /app/node_modules
This is probably one of the more confusing parts of the set up.
As said before, volumes
are ways to persist data. With the first definition, the .
directory is mapped to /app
in the container. Remember, /app
from the Dockerfile? That's our working directory where everything is installed. It essentially replaces /app
in our container with .
.
The second definition is trickier. Notice that there is no :
, which means there is no mapping. This definition will mount /app/node_modules
to the previous mapping, i.e. your host. As a side effect, it creates an empty node_modules
!
This means that the node_modules
in the container is being used.
<Aside>
If you were to remove this definition, the app wouldn't work unless you ran npm install
on the host (i.e. your machine).
You would also want to add a .dockerignore
ignore to your repo like this:
node_modules
npm-debug.log
</Aside>
The answer here explains it well.
depends_on:
- db
links:
- db
These last two go hand-in-hand for our case. The depends_on
definition means that Docker will wait for the db
service, defined below, to start first. The links
definition enables the two services to communicate. We want our app container to be able to talk to our database container.
services:db
services:
# app: goes here
db:
container_name: mongodb
image: mongo:latest
restart: always
volumes:
- db:/data/db
command: mongod
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD}
MONGO_INITDB_DATABASE: ${MONGODB_DATABASE}
ports:
- 27017:27017
We defined our app service, now we have to define a service for our database.
We already defined this as a volume
, but we also need to define it as a service (i.e. a container). The name of the service, needs to match the name of the volume.
container_name: mongodb
Again, just a name!
image: mongo:latest
Unlike the build
command in our previous service, image
tells Docker to pull down a prebuilt image; in this case, mongodb.
restart: always
Restarts the service if something happens.
volumes:
- db:/data/db
Another volume! We know that :
means we are mapping something. In this case, we are mapping the volume defined at the top of our file to the directory /data/db
in the container, which is the default storage location. Remember that a mapping essentially replaces the right hand side. So when the container starts, /data/db
is replaces by the volume that Docker created to persist our data.
command: mongod
The command to start the database.
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD}
MONGO_INITDB_DATABASE: ${MONGODB_DATABASE}
I think that this is one of the coolest things! We can set a default username, password, and database when the container starts. These environment variables are specific to mongo, and there is more information in the docs.
What's cool is that we can define these in our .env
and use them to connect to the database with our server, like this:
mongoose
.connect(`${process.env.MONGODB_URI}`, {
dbName: `${process.env.MONGODB_DATABASE}`,
user: `${process.env.MONGODB_USER}`,
pass: `${process.env.MONGODB_PASSWORD}`,
useNewUrlParser: true,
useUnifiedTopology: true,
useFindAndModify: false,
})
This let's us keep our code DRY!
ports:
- 27017:27017
Another mapping; this time for mongo's default port.
Running it.
If you clone the repo, all you have to do it is run it:
docker compose up
That should do it!
A little extra
Ok, that's a lot of output. We really only want to see the output of our app. We can run:
docker compose up --attach app
to get output only from our app container. It will look something like this:
todo_app |
todo_app | > dcoker-todo@1.0.0 start:dev
todo_app | > nodemon -L app.js
todo_app |
todo_app | [nodemon] 2.0.22
todo_app | [nodemon] to restart at any time, enter `rs`
todo_app | [nodemon] watching path(s): *.*
todo_app | [nodemon] watching extensions: js,mjs,json
todo_app | [nodemon] starting `node app.js`
todo_app | Successfully connected to the server at 'http://localhost:3000/'
todo_app | Connected to todos as admin
Of course, todos
and admin
will be whatever you set them as in the .env
.
Additionally, after you shut down the container, you'll still want to run
docker compose down
to stop the mongodb container.
Let me know if it works, and I hope it help!
Posted on May 15, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.