Dockerizing a React App | Introduction to Multi-Stage Builds
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 theindex.html
file which in turn imports the transformed JS code from oursrc
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 packagereact-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 secondFROM ...
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 everyFROM ...
instruction. We use theas
keyword and we can give any name of our choice. I have takenbuild
as the name of this step here.Rest all the instructions up to
COPY . .
are the same as our development Dockerfile. TheRUN npm run build
marks the end of stage 1 of the build process where instead ofCMD ...
we useRUN ...
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 fromnode:14-alpine
tonginx:stable-alpine
as our base image. This image is also a very lightweight and trimmed down version ofnginx
. 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 specifybuild
after--from=
. This specifies that we copy the final content from thebuild
stage to the second stage.This tells Docker that the
COPY ...
instruction in our second stage does not refer to ourlocalhost
project on our local machine but instead to the file system from thebuild
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 locationusr/share/nginx/html
. More details can be found on the officialnginx
image on Dockerhub.Also,
nginx
exposes port80
internally by default. So we specify theEXPOSE 80
instruction to expose the container port.Then we specify the final instruction
CMD ["nginx", "-g", "daemon off;"]
which will start thenginx
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 thebuild
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👨🏽💻👩💻!