Dockerizing a React App | Introduction to Multi-Stage Builds

Dockerizing a React App | Introduction to Multi-Stage Builds

Featured on daily.dev

Have you ever wondered🤔, the dockerized React app that we use in development is not the same that is deployed in production? There is an extra build step required! But wait, isn't that the primary purpose of containerization, i.e, to have the same environment and same code in the development and production. This extra build step beats the whole idea of the same environment and same code. Well, let's have a look further.

The Development Setup

The Dockerfile

A dockerized React app in development has a Dockerfile that looks something like this,

FROM node

WORKDIR /app

COPY package.json .

RUN npm install

COPY . .

EXPOSE 3000

CMD ["npm", "start"]

This is a pretty standard Dockerfile that we would write to containerize a React app during development. But we won't deploy the same in production.

But Why?

Generally, any front-end framework or library be it React, Angular, Vue, etc, requires a build step. Consider a React app, where we have a build script that spits out JS code that runs in the browser. The requirement of a build step is due to reasons such as,

  • The front-end code is meant to run in the browser so it ships with its own mini development server basically a nodeJS server that compiles the code(JSX) to browser executable code and also supports live reload.

  • The mini development nodeJS server that a react app ships with serves the index.html file which in turn imports the transformed JS code from our src folder! That's how these front-end projects work.

  • The development code cannot run in the browser natively because we use experimental JS features that we use to write code during development hence compilation is required.

  • The npm start command runs the start-script which uses a third-party package react-scripts to transform JSX into browser-friendly code, optimizes it, shrinks it to make it as small as possible, and does a bunch of other stuff behind the scenes.

  • If the same setup is used/ deployed in Production, it would be too resource-heavy and too inefficient!

  • The build step basically is an optimization script that needs to be executed after Development but before Deployment.

So, the development server is not meant to run in production. It is not optimized for that and it would be way too slow to be used in production. For production, React projects, and all similar projects bring their build script.

build will not start any server instead it compiles and optimizes our code and spits out this transformed and optimized code in a separate folder that we can then serve ourselves using any web server of our choice.

The Production Setup

From the above Dockerfile we have a great development setup for our containerized React setup but we cannot simply replace the CMD ["npm", "start"] instruction with something like CMD ["npm", "run", "build"] as a final command because it will not start any web server or process that would be reachable by any HTTP request. Instead, it will just give us the finished code.

Therefore we have to find a way of building a Dockerfile which can be used to build a container that runs this application in production.

Creating a build-only container

Our React app needs to be executed differently in development and in production. We need separate containers for development and the final build/deployment process. Therefore we are setting up two different environments because our React app forces us to do so.

So, let's build the other container! We will use a separate Dockerfile for the build container. We can name this Dockerfile Dockerfile.production.

Dockerfile.production

FROM node:14-alpine

WORKDIR /app

COPY package.json .

RUN npm install

COPY . .

CMD ["npm", "run", "build"]

Here, we have changed the base image from node to node:14-alpine which is even a slimmer and lighter version of nodeJS. We are not going to use nodeJS as our production server. It is for the build step only hence a slimmer version to speed up the build process.

Also, you can see that rest of the instructions are the same as our original Dockerfile. Except for the last one where we use a CMD ["npm", "run", "build"] instruction to generate the finished code inside the container. But, wait this is not a finished Dockerfile. This will just give us the finished code but not a running server.

If we want to use this container in production we also need a server along with the finished files(optimized code). To solve this problem let me introduce you to the multi-stage build that docker offers.

Introducing Multi-Stage Builds

Multi-stage builds allows us to have one Dockerfile but define multiple build steps/stages or setup steps inside of that Dockerfile. These stages can copy results from one another. This means that in our React application,

  • One stage to create the optimized files.

  • Another stage to serve the optimized files.

We can either build the complete image going through all the stages step by step from top to bottom or we can select individual stages up to which we want to build that will skip all stages that would come after them.

So we can consider the above dockerfile.production up to the CMD ... instruction as our first stage where we build our deployable source code.

So let's build the second stage and the completed Dockerfile will look like this,

FROM node:14-alpine as build

WORKDIR /app

COPY package.json .

RUN npm install

COPY . .

RUN npm run build

FROM nginx:stable-alpine

COPY --from=build /app/build /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

Let's see what we are doing here in this Dockerfile.production.

Stage 1

  • We are using the node:14-alpine as our base image for the build process but as soon as we write a second FROM ... instruction Docker discards our previous step/stage and we will switch to a new base image.

  • But we don't want to discard our previous stage as we need our finished deployable code from our build step, i.e, RUN npm run build in our second stage. So, we add a special instruction that can be added after every FROM ... instruction. We use the as keyword and we can give any name of our choice. I have taken build as the name of this step here.

  • Rest all the instructions up to COPY . . are the same as our development Dockerfile. The RUN npm run build marks the end of stage 1 of the build process where instead of CMD ... we use RUN ... so that we can continue with more steps thereafter.

Stage 2

  • The final container will only include the second stage but it will build stage 1 to derive the final stage.

  • The FROM nginx:stable-alpine instruction marks the start of our second stage where we switch from node:14-alpine to nginx:stable-alpine as our base image. This image is also a very lightweight and trimmed down version of nginx. More details on the image on dockerhub.

  • Now we do something special in the COPY ... instruction of our second stage. We copy from the first stage using the --from flag. Then we specify the name of the stage from which we want to copy.

  • We have our first stage named build so we specify build after --from=. This specifies that we copy the final content from the build stage to the second stage.

  • This tells Docker that the COPY ... instruction in our second stage does not refer to our localhost project on our local machine but instead to the file system from the build stage.

  • We specify the source path for what we need to copy. So in the case of React app production builds the generated serveable code lives inside the build folder of the project root. Inside our container, we have /app as our working directory. So we need to copy from our /app/build.

  • We also need to specify the default folder from where nginx will try to serve the files. So we specify the location usr/share/nginx/html. More details can be found on the official nginx image on Dockerhub.

  • Also, nginx exposes port 80 internally by default. So we specify the EXPOSE 80 instruction to expose the container port.

  • Then we specify the final instruction CMD ["nginx", "-g", "daemon off;"] which will start the nginx server. And as the official documentation states on Dockerhub, we should add the -g daemon off; option if we start the server ourselves which we are doing here, i.e, we start the server after we copy our custom code from the build step.

This is all we need to do! We have completed our multi-stage Dockerfile and this will successfully build a container that has production-ready code and is ready to be used in production.

Deployment

As you can see we have 2 dockerfiles here. So normally if you are developing your app then docker will pick up the default Dockerfile that is just named Dockerfile. Build your production-ready image by specifying the correct Dockerfile by using the command below with the -f flag.

docker build -f frontend/Dockerfile.production  -t <your_repository_name> ./frontend

Then push your build image to the repository you are using. In my case, I am using Dockerhub. So,

docker push <your_repository_name>

In the case of deployment on AWS ECS

If your URL's inside your react app resembles something like http://localhost/users it won't work. This is due to the fact that on AWS ECS localhost is a special keyword that will allow your Dockerized application to send requests to other containers running in the same AWS ECS task.

In short, the react code runs on the browser of end-users so localhost will refer to their machine instead. The proper domain depends on how you are deploying your application.

In my case, I deployed my app in the same task as my nodeJS REST API which means that my react app will be reachable via the same URL. So I am using just/users instead of http://localhost/users.

It totally depends on your method of deployment. So I would recommend having a good look at the documentation of your own Cloud Service Provider.

Conclusion

We can have as many stages as we might need in the complete build of our application. This is a very powerful feature offered by Docker because it's great for situations like this where we have an application which we can't serve the way it is shipped to us but we need to build first. That's why the multi-stage builds are awesome😍And overall Docker is awesome!

I hope you liked this post and an emoji on the post would definitely make me happy😄. Feel free to ask your question regarding this post in the comments. Happy Coding👨🏽‍💻👩‍💻!