Dockerize an Express app and MongoDB

charlesloder

Charles Loder

Posted on May 15, 2023

Dockerize an Express app and MongoDB

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" ]
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The big picture

Let's break it down.

# version: "3.8" no longer needed
volumes:
services:
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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, ...)
Enter fullscreen mode Exit fullscreen mode

The PORT is injected to the container from our .env file.

volumes:
    - .:/app
    - /app/node_modules
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

</Aside>

The answer here explains it well.

depends_on:
  - db
links:
  - db
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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,
  })
Enter fullscreen mode Exit fullscreen mode

This let's us keep our code DRY!

ports:
  - 27017:27017
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

to stop the mongodb container.


Let me know if it works, and I hope it help!

💖 💪 🙅 🚩
charlesloder
Charles Loder

Posted on May 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related