Setup production docker and docker-compose with Node.js, MongoDB, Redis, and Next.js (even as a beginner)
By: Zied Hamdi
Tags: Nextjs, Nodejs, Docker Compose, Docker, Redis

I’ve been writing the MVP of the WeAlly project for a while now, and I was always postponing the deployment details to keep focusing on what matters most at that moment. As an entrepreneur, you don’t think the same way as a corporate business, you have to be pragmatic in each decision you make. And having a solid deployment environment for a project that still doesn’t exist is one of the ways to forget your main objective: talking with your customers. To achieve that (for a B2C product), you have to put an MVP in their hands and see how they react as soon as you can.

Photo by Dominik Lückmann on Unsplash

The product advancement being at its last phase, last week, I decided to finally include Docker to be confident that there will be no surprise the day I’ll want to launch. In fact, dependencies started to reach a critical mass where you can’t expect things will just work fine.

At deployment (on a physical server), I’ll have to install MongoDB, Redis, Nginx, and git clone my three servers (GraphQL backend, GraphQL subscriptions, and Next.js SSR frontend), manage running all of them automatically at startup, be careful to close access to all non-relevant ports to the exterior world (to avoid security issues), manage my different server logs, and not least: be able to upgrade any of these components without breaking anything.

The more operations you do, the more chances there are you will make a mistake.

An interruption, or a data loss in the production environment, can do very serious harm to your business. Therefore, the objective that led me to use Docker was that I want my manipulations on my physical server to be done once for all at first setup. Even with this in mind, there still are many things to do: register an SSL certificate, open the 80, 443, and WebSocket ports, and install Docker and docker-compose. However, I think I can manage to keep focused on doing three things without making mistakes, and mainly, once this will be done, I won’t get back to it (when I will need to deploy new versions of my product)

Here’s a brief description of the project architecture. This step is needed to be able to understand the scripts below. Data is saved in a MongoDB instance, and WebSocket push notifications are handled by a Redis engine at the backend. The frontend server (next_server) needs to access the database to do the authentication of users, it then decorates any API request that comes from the browser by adding a JWT token and forwarding it to the backend server (graphql_server).

My 3 projects in the middle layer are Node projects that need to be compiled with babel and minified before being put to production. The idea is to create a Docker file for each of them to build a production-ready image that we can then run with docker-compose.

If you don’t know it, a docker file specifies how to bundle (build) and how to run (run) a project in the same file. This was confusing for me at the beginning, but it showed to have many advantages: each time you want to deploy a version, tag it in your git repository, then run a docker build with that tag. The build process will clone that version in a docker temporary directory, then run your build scripts, and finally create a docker image. That image is kept along with the previous images, so that you can instantly rollback by running a previous version if things don’t work as expected.

Show me some code!

Ok, so this is how I organized things:

I created a project in a git repository, which’s only purpose is deployment. The project contains only 4 files: .env (environment variables for scripts), install.sh (clones git repositories by tag, compiles for production, and builds docker images), install_docker.sh (utility to install docker on a new machine), docker-compose.yml (runs the different servers). The first thing I do on a new machine/VM is cloning that repository, I can then have my toolbox by hand for the next steps. I also included a readme.md file with instructions of use and troubleshooting commands, I’ll share that at the end of this article.

The first thing that the script above does is to read the .env file. I opted to write down which version of each repository I want to deploy and push to git a new version of the deployment project so that I can simulate the production environment in a VM then just run a git pull of that configuration project in the production machine and run ‘install.sh’. You remember: ‘no manual operations in production deployment’

The .env file is pretty straitforward GRAPHQL_SERVER_VERSION=0.9.2

NEXT_SERVER_VERSION=0.9.2 GRAPHQL_SERVER_PORT=4000

After checking the needed variables are set in the environment, the script gets out of the configuration project, deletes an eventual older version of the project sources. Clones the project at lines 27 and 37. depth=1 means no git history is imported, only the files as in the tagged version, this executes in less than a second.

The script then navigates to the clones project and builds an image with docker. Each project has its own embedded Dockerfile that describes the building and running steps. Here’s an example Dockerfile

As mentioned above, the docker file is at the root of each project we clone from git. I started by including a list of commented docker commands. These are very helpful when you are writing your Dockerfile, there is a lot of troubleshooting, and you need to understand what’s happening inside the docker image. Let’s break this file into simple statements:

FROM node:14.17.3-alpine

This is to tell Docker you need a Node.js server to run this image. It will install that node version for you and understand how to run it.

ENV NODE_ENV=production

You can add environment variables in the docker file so that when you run your image, it will find them. This is about runtime, not build-time of your image, it is the equivalent of the .env file in your project. You can also specify environment variables in the docker-compose file, which is all about the runtime process description for your docker images.

WORKDIR /app

This line is to tell Docker (build-time), when I point to the current directory, translate it to ‘/app’. We see its effect immediately in the next line

COPY . .

Means: copy the files in the current directory I’m running docker build from (the git cloned weallyback dir in our example) to the docker image working directory (which translated to /app instead of ./ because we specified that as the work- directory for the docker file)

RUN

Even though this is called run, it’s actually run at uild-time. This line instructs docker to run a list of commands. If you have multiple lines, add && \ to the end of each line to instruct Docker that it’s another command.

CMD

This is about runtime, it specifies the default command to be called to run the image. This can be overridden by the docker-compose file.

How to debug a docker build process

Remember, when docker builds an image, it saves it in its list of images, even if the build fails. Then you can run/stop/restart any specific image as many times as you like to? A running image specification is called a container in the docker jargon.

When you run a docker container, things rarely happen as expected. You need to enter that docker-machine instance and see what’s happening from the inside. To be able to do that, the machine must be running. But usually, when things go wrong, the machine shuts down, and it becomes impossible to see what happened inside.

But first things first, let’s see how we can list/remove images. When you build an image, you use the following syntax:

docker build . -t graphql:0.9.2

If you run this command twice, the second image will override the first one since you specified the same tag (-t). After your build succeeds, the following command will show you your newly created image in the list of available tags.

docker image ls

The list contains a list of tags, each tag is linked to an image with a unique imageId. You can tag the same image multiple times with different tag names.

You can delete an image with the (remove image) following command

docker rmi [imageId]

Now that you know how to navigate in your created images, you can run an image(ex:graphql:0.9.2) making it a named container(ex:graphql_server). This is the right moment to name your container with the — name parameter.

docker run -d — name graphql_server graphql:0.9.2

Like images, Containers can be removed by name with the operation:

docker rm [name]

Now that your image is running, you need to know how healthy it is. Because you included -d (daemon) flag, you won’t see the logs your container is producing. But docker is your friend. You can run the following command to see the logs:

docker logs [name]

If your container threw an error, it will shut down. You can list the containers (even the stopped ones) with the command

docker ps -a

At this point, if you have a stopped container, logs usually don’t suffice, you need to see the folders and files, execute some commands to check why your server stopped or your build process failed. Fortunately, you can ‘log in’ in a terminal window to the docker container with the following command:

docker exec -it [name] /bin/sh

Containers are useful to do rollbacks, but not only: you can comment out the original CMD instruction in your docker file and use the ‘sleep infinity’ instruction to keep your container running and be able to explore them from the inside. To do that, you have to run the specific container. In our example, the command would be:

docker exec -it graphql_server /bin/sh

The second /bin/sh argument tells docker what command to run, you can replace it with bash or another command. I’ll get to that later. The point here is that when you run this command, you can do all Linux command line operations inside your container.

For example, in my case, when I was building my docker image, the command ‘yarn build’ was generating an error saying ‘babel’ was not found. I had to enter in my container to see that in fact, it was not present in ‘node_modules/.bin’. It showed up that ‘yarn install’ runs in production mode by default, it didn’t download ‘babel’ since it was in my dev dependencies. That’s why I had to run ‘yarn install — production=false’, build to create a dist directory, then delete the node_modules and install again dependencies in production mode.

I also called ‘netstat -peanut’ in each container to verify which ports were exposed to the other machines.

Now that we’ve got docker what are we gonna do with it?

You might think the road is still long since I didn’t talk about docker-compose, but things are pretty straightforward now: the same way you start a container with the ‘docker run’ command, you can start multiple containers that communicate together with docker-compose. Thus, you don’t have to type the arguments by hand, and you can define how they talk to each other.

Here’s my docker-compose file:

The script is self-explanatory, let’s dig in what deserves to dig in.

Launch directory:

Since our images are already built and kept by Docker, we can launch the command ‘docker-compose up’ from anywhere. I chose to launch it directly from my deployment_config project since I have a .env file where I specify how I want this script to be run.

Docker-compose environment variables:

This is maybe confusing, but running the docker-compose.yml without parameters would be better. For example, I want to run my images by pointing them with their corresponding source code version as specified in their git tag identifier. Having those parameters in a .env file can let us know which versions of our servers are currently running. I’m using the same .env file I’m reading in the ‘install.sh’ script (described at the start of this article), therefore, I execute ‘git clone’ with the same git tag that I’m using to name my images, and then I read these tags again at launch to run the just built images. I’m using ${NEXT_SERVER_VERSION} and ${GRAPHQL_SERVER_VERSION} to insert variables from the .env file.

Project environment variables:

You probably have a .env file in your dev project that you planned to override at deployment. Good news! You don’t have to, you can declare things you want to override in production runtime here. This is different from the .env file in the deployment_config project I created. Those parameters are passed to you server, not to docker-compose.

Note: Next.js has a safer way to handle environment variables by compiling them directly into the js code instead of having them hanging around in your system. Sensible keys shoud be put in .env(.*).local to benefit from the hardcoded at build time keys feature. This is beyond the topic of this article, but a best practice I want to grab your attention about.

Network:

As long as you don’t expose a port, it will not be accessible from the outside. This is a pretty safe default, since you have to explicitly free up port numbers out of the docker container. However, containers that are in the same docker-compose file share the same network and can therefore see each other by default. You can explicitly name your network and control it (I just named it here).

Keep in mind, each container is a machine by itself with its own IP address. So to address a machine, you have to use its host name which happens to be the same as the container name specified in each container with the ‘container_name’ key. For example, we access the mongodb through its container name ‘mongo-server ’: mongodb://mongo-server:27017/weally

Command:

In the file I temporarily commented out ‘graphql_pubsub_server’ that is my websocket server backed by Redis. You can however see that it uses the same image as ‘graphql_server’, the only difference resides in its command instruction. In my case, I have the same graphql schema and resolvers for both regular graphql queries and subscriptions. I therefore can use the same image but call a different js file to start my server as a regular HTTP server or as a graphql-subscribe server.

Volumes:

By default, docker will create a machine for each container. So when you run the same image with a different container name, your previous data will remain in the old container. Typically, if you deploy a new version of your server, you want your mongodb data to be still accessible. We therefore need to tell docker that we need that data to be kept outside the container. To do that, we use Volumes. As you can manage images and containers, you can manage volumes in docker. For example ‘docker volume ls’ will list the volumes. Volume location can be manager by docker or explicitly bound to a machine directory. The bind syntax and the file above is equivalent to the commented one line instruction ‘/var/weally/mongodb:/data/db’ which means: if the container asks for ‘/data/db’ point him to the physical machine directory ‘/var/weally/mongodb’.

That’s all folks!

I’m seeing the article is long, sorry for that. I wanted you to have access to all info at one place instead of searching each syntax by googling it.

The purpose of this article was to help you feel it is not difficult to create a production ready docker infrastructure with multiple projects and the ability to rollback without risks.

You can bootstrap your docker production environment by copying these files, then modifying incrementally using the techniques I described to understand what’s behind the curtains.

I’d really like to hear from you if this article helped you, or at opposite confused you. I’ll use your comments to understand what needs to be clarified. In a following article I’ll show you how to create a development environment with hot code reloading so that you can see bugs that would appear in production early while in the development phase.

I also join the script I use to install docker and docker-compose on a new Ubuntu machine.

Special thanks go to Nicols Peidro for helping me troubleshooting and giving me directions on how to organize the whole thing.