Dockerize an Express app and MongoDB
Tags: devops, javascript, database, docker
Published: 2023-05-15 16:24:53

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](https://github.com/charlesLoder/docker-express-todo).


## 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](https://www.docker.com/products/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](https://github.com/vinita2000/ToDo-app-node-js-express-js-mongodb) 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](https://docs.docker.com/glossary/#image)

Ours looks like this:

```Dockerfile
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](https://hub.docker.com/_/node). 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](https://docs.docker.com/glossary/#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`:

```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_](https://docs.docker.com/glossary/#image). An image defines our container. A [_container_](https://docs.docker.com/glossary/#container) is an instance of an image running. In this section, we are going to use [_compose_](https://docs.docker.com/glossary/#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`.

```.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

```yml
# 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.

```yml
# version: "3.8" no longer needed
volumes:
services:
```

`version:`
You'll often see version number at the top, but this is [no longer needed](https://docs.docker.com/compose/compose-file/03-compose-file/).

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](https://docs.docker.com/compose/compose-file/compose-versioning/). 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

```yml
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

```yml
services:
  app:
  db:
```

We have two containers — `app` and `db`.


#### services:app

```yml
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

```js
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](https://stackoverflow.com/a/54278208) explains it well.

```
depends_on:
  - db
links:
  - db
```
These last two go hand-in-hand for our case. The [`depends_on`](https://docs.docker.com/compose/compose-file/05-services/#depends_on) definition means that Docker will wait for the `db` service, defined below, to start first. The [`links`](https://docs.docker.com/compose/compose-file/05-services/#links) definition enables the two services to communicate. We want our app container to be able to talk to our database container.


#### services:db

```yml
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](https://hub.docker.com/_/mongo).

`restart: always`
[Restarts](https://docs.docker.com/engine/reference/commandline/compose_restart/) 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](https://www.mongodb.com/docs/manual/tutorial/manage-mongodb-processes/#:~:text=By%20default%2C%20MongoDB%20listens%20for%20connections%20from%20clients%20on%20port%2027017%2C%20and%20stores%20data%20in%20the%20/data/db%20directory.). 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](https://hub.docker.com/_/mongo#:~:text=Environment%20Variables).

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:

```js
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!