Dockerizing modern web apps
Most websites these days are Single Page Applications (SPA for short) where a single entry file handles all routes that a user might visit. Swept up in the ongoing trend of hosting in the cloud you might find yourself needing to “dockerize” your SPA. That is to say wrap it inside a Docker image and run it as a container.
In this post we will explore how we can get that done. We are going to build a simple SPA that just tells the user which route of our website they are currently visiting. That means you should be able to visit not only /
but also any route you might think of, such as /unicorn
or /rainbow
. This SPA will be a super simple and hand made one but you can see it as representative for any complex React, Angular or Vue app you might want to deploy. Finally we are going to build our SPA into a Docker image and deploy that as a container.
We will run through all the basics of what we are doing. So whether you are seasoned with Docker and just can’t get that SPA to run on your cluster or you are a great web developer tasked to do this Docker thing, this post is for you.
The website
Our website will be super simple. Just a headline and a paragraph telling our user where they are by checking window.location
. Below that we will offer links to navigate to a few routes.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Simple SPA</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body style="font-family: sans-serif;">
<h1>Welcome to a simple SPA</h1>
<p>You are on: <span id="locationSpan"></span></p>
<p>You could go to:</p>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/unicorn">Unicorn</a></li>
<li><a href="/rainbow">Rainbow</a></li>
</ul>
<script>
const span = document.querySelector('#locationSpan')
span.innerHTML = window.location.pathname
</script>
</body>
</html>
To test this locally you could initialize a package.json, install live-server and add a start script to your package.json "start": "live-server --entry-file=index.html"
.
npm init
npm i -D live-server
# Now add the script o your package.json before running:
npm start
Go ahead and click on a few of the links to move around or enter another path into your navigation bar.
You might already notice that we are doing something to make our SPA work as we expect it to. We need to tell live-server to serve our index.html on all routes that it can't find a file for. We do this using --entry-file=index.html
. Feel free to try running the ive-server without the — entry-file parameter and see what happens in that case.
Keep this flag in mind as we will need to do something equivalent for our dockerization.
See the website in action at dockerized-spa.now.sh. (Already dockerized and hosted on Now.sh.)
Naive Docker attempt
Docker is a system to create images which can then be run as containers. You can think of docker images as super lightweight Virtual Machines that can be run on many platforms (in this image containers are running VMs). The awesomeness of all this is that once you build a docker image and run it somewhere you will get the same thing running everywhere. Once we manage to build a Docker image locally that we can run as a container successfully we know that it will also run on AWS, GCP, Portainer or whatever else your company might be using.
Docker solves the infamous “it works on my machine” problem. Containers run the same wherever you start them!
First you need a Dockerfile. Let us start out with a Naive-Dockerfile. Within it we will define the steps needed to create an image. In our case we just want an image that can serve websites and hold a copy of our Single Page Application.
FROM nginx
COPY index.html /usr/share/nginx/html
Here we base our image on the nginx
image. NGINX is a simple and lightweight webserver that can serve our index.html. To enable this we copy our website into the folder that NGINX will be serving. Now that we have this, let’s build our image and run it.
Above we first create the Docker image using Docker build. Using the -f
Flag we tell Docker which “Dockerfile” to use, which files holdes the configuration to build our image. The -t
flag “tags” our Docker image. It gives it a name we can use to run it.
Using Docker run we then start the image as a container. By using -p
we can specify a mapping for exposed ports, in this case that we want to reach the exposed port 80 at port 8888 on our local machines. So go ahead and open http://localhost:8888/ to checkout what we got.
Thats not how we imagined this would go…
With our “naive Docker” approach all but our entry route /
display NGINXs 404 page.
docker rmi -f docker-spa
Lets clean up after ourselves using the Dockerrmi command and -f
forcing it to remove our created image. Time to pull up our sleeves and get this fully working.
Empowering SPA capabilities
Remember how up above we needed to pass --entry-file=index.html
to live-server in order for it to serve our index.html file for each route where it couldn’t find a file to serve? What we need now is the equivalent of this parameter for NGINX.
For that we will use an NGINX configuration and add it to our Docker image.
server {
listen 80;
listen [::]:80 default ipv6only=on;
root /usr/share/nginx/html;
index index.html;
server_name _; # all hostnames
location / {
try_files $uri /index.html;
}
}
In the above config we tell NGINX to accept traffic on port 80 no matter the domain. Then we tell it to resolve paths ending in a slash as index.html
and finally specify that for all routes it should check if there is a file and otherwise serve the index file.
After adding the above to our project we can now also COPY it into our Docker image to tell NGINX to use it.
FROM nginx
COPY nginx.config /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html
And once again you can test this locally using the same commands as above with our new Dockerfile. Remember to point your browser to http://localhost:8888/unicorn to see it in action.
docker rmi -f docker-spa
Clicking through the links you will see that now it does work. Each route you visit is now served by our Single Page Application we build at the beginning.
Bonus level — compiling your SPA inside Docker
Chances are your application isn’t just a single, static HTML file. In fact you probably have a quite modern and sophisticated toolchain involving TypeScript, Webpack, Parcel or similar to build your application. You could easily do this build step inside a Docker file.
# ---- Base Node ----
FROM node:alpine AS base
# Copy project file
COPY . .
# Build project
RUN npm run build
# ---- Prod ----
FROM nginx
# Copy needed files
COPY nginx.config /etc/nginx/conf.d/default.conf
COPY --from=base build /usr/share/nginx/html
The above uses a multistage Docker build. It first builds an image based on Node in which we run our build script and then builds an image as we did before but copying the compiled application form the
build image instead of our local file system where we run docker run
from.
We can illustrate the usage of this with our simple SPA by adding a build script to our package.json.
"build": "rm -rf build && mkdir build && cp index.html build/index.html"
Go ahead and try it out.
docker rmi -f docker-spa
Take a look at all of this together in this repo illustrating how to host SPAs using Docker.
What a day
We built a Single Page Application, tested it locally, packaged it in a Docker image and finally enabled the Docker image to act as an SPA should, answering onall routes.
I hope you learned a thing or two today. Now, be brave, be bold, go out, apply your new found knowledge and host your application using Docker.