Create React App + Docker — multi-stage build example. Let’s talk about artifacts!

Artifact (software development), one of many kinds of tangible byproducts produced during the development of software

Have you ever been part of a project that required some kind of ‘build’ or ‘compilation’ step? Perhaps a project where you manually edit your ‘source files’ first, then you run some command to produce the final ‘production-ready’ assets?

Just want to see the code?, you can skip straight to the repo I setup for this post

Terminology: Every time you see the work ‘artifact’ in this post, I’m referring to something like an executable, directory or anything else that is produced as part of your workflow — it’s typically the thing you want to run on a server & it’s almost always excluded from version-control.

Example from create-react-app — node_modules contains about 22676 files here, none of which are required to serve the application in production.

Build tools have extremely different requirements to those of your production App.

This is a big problem. Whether it’s a simple blog generated from Markdown files, a fully-fledged SPA written in something like Angular or React, or any other type of project that uses tooling — the dependencies required to ‘build’ your project — that is, take your source files and produce the actual thing you want to throw on a server — are vastly different.

Just take a look inside your `node_modules` directory (or equivalent in your chosen lang/env) — I bet all (or most) of those packages will only be required for the build process — and if that’s true, they have no place on a production server, ever!

Current solutions.

Of course, no-one right now would admit to building their projects on the same server that runs their App, but we all know it happens… too often.

To get around this problem, the more responsible projects out there will tend to do one of the following:

  • 1) Have developers run the ‘build’ command locally, producing an artifact that is then ‘uploaded’ somewhere, or added to a docker image etc…
  • 2) Have a separate CI service sitting in between Github & the production server — the artifact can be produced there instead and then deployed to a server…
  • 3) Run the build process on the same server as that which will run the app in production…

But now, for those using Docker, there’s a better way. A technique that allows you to consolidate your build + production setup to a single Dockerfile. This has huge implications for the future as it allows things such as auto-builds/deployments often without the need for yet another 3rd party service

Docker multi-stage builds

No need for jargon here, the concept is so simple it’s brilliant.

  • 1) Create the environment needed for your build process
  • 2) Run that build process to produce your artifact
  • 3) Create your production environment
  • 4) Copy the artifact into your production environment
  • 5) Discard EVERYTHING ELSE from the build environment.
  • 6) profit?…

The fact that Docker handles all of this complexity is amazing — now let’s run through a real-world example to fully understand it.

I’m going to use the popular create-react-app CLI tool in this example, but you can take the concept and apply it to any similar situation.

Tutorial using 'create-react-app'

Step 1: Install create-react-app

yarn global add create-react-app

Step 2: Create a new project

create-react-app docker-build

Notes:

  • After creating a new project, you’ll notice you have a ‘src’ directory containing the files you should edit in development.

Step 4: Add build process to Dockerfile

We’ll build upon the latest official NodeJS Docker image, which comes with yarn pre-installed.

FROM node:7.10 as build-deps
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn
COPY . ./
RUN yarn build

Notes:

  • On line 1, we’re using the FROM <image:tag> as <name> format which is new to Docker 17.05.
  • The as build-deps part allows us to name this part of the build process. That name can then be referred to when configuring the production environment later.
  • On lines 4 & 5 we copy package.json and yarn.lock into the image and then run yarn — this separates the dependency installation from the edits to our actual source files. This allows Docker to cache these steps so that subsequent builds — one’s in which we only edit source files and don’t install any new dependencies — will be faster.
  • Next on lines 6 & 7 we copy everything else into the image and then run the build command. This will produce the ‘artifact’ inside of the build directory — just as it would if you were to run this command locally!
  • Be careful, copy . ./ can be quite dangerous is it will copy the entire current directory into a build context, which may be huge! Add a .dockerignore file to combat this, mine would look something like https://gist.github.com/anonymous/f1b3e2cc530900338a0c38bce5e0e4c1

Step 5: Add production environment to the SAME Dockerfile

This is where things start to get seriously interesting! In the exact same Dockerfile we can add the setup for our production environment, right below the setup for the build process!

When Docker sees a second FROM statement, it will begin an entirely new ‘build stage’ — which includes NOTHING from the first step. That’s right, the whole thing is discarded… kind of. Crucially it does allow you access to the previous builds file system. So, this is where everything starts to come together and make sense, because Docker allows you to selectively copy anything you like from the first build step, into the second one!

This means we can grab hold of the build directory, which is our ‘artifact’ and discard everything else from the first step. So everything about the base NodeJS Docker image is discarded, along with all the files we don’t choose to copy over into the new build step.

In this example, with create-react-app , that means we get to wave goodbye to the 22,676 files required to build the project — none of those are needed to serve the application, so we don’t want them lingering around!

Let’s see it in action

FROM nginx:1.12-alpine
COPY --from=build-deps /usr/src/app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Notes:

  • We’re using one of the official nginx images here to serve our application, but this could be any other type of server — I only chose nginx as it’s popular & I know how to configure it :)
  • On line 2 is the shiny new stuff. The COPY statement has been around since Docker first hit the scenes, but so far it’s been limited to copying files from a context (like a host) into an image. The new part is the flag --from=build-deps — if you remember back to the first stage, build-deps is the name we gave that stage, and this is how we can refer to it here.
  • Again on line 2, we know that create-react-app creates a build directory as an artifact, so we add that path to the working directory and end up with: /usr/src/app/build this is the absolute path of our artifact inside the first stage.
  • So, we know how to access the artifact from the first stage, now we just need to copy it into the correct place in our production environment — and because we’re using stock nginx, that directory is /usr/share/nginx/html
  • The final 2 lines are just the regular docker commands to expose a port and run the server when a container start.

Step 6: Build the image!

Now we get to put it all together — we have both our development build process & production environment specified in a single Dockerfile — it should look something like:

No giant images here, everything from Stage 1 will be discarded, after we copy out the artifact!

Now we can instruct Docker to create an image from this:

docker build . -t shakyshane/cra-docker

Notes:

  • docker build . instructs Docker to use the current directory as it’s build context
  • -t shakyshane/cra-docker instructs Docker to ‘tag’ this particular build. In this case I’m naming the image as it would appear on my Docker Hub account, but you can use any tag name you want.

Step 7: Run it locally to test it works!

After running the previous build command, you can now use the tag name to start a container from this image.

docker run -p 8080:80 shakyshane/cra-docker

Notes:

  • -p 8080:80 allows us to map the port 8080 on our local dev machine to port 80 inside the container — you can omit the first part if you’re happy for Docker to assign a random port for you, eg: -p 80 will result in something like http://localhost:32888 — which will change each time you run it.
  • If it all worked well you should now be able to see the following in your browser:
That’s it! A fully Docker-ised create-react-app.

Next steps

Now that you’ve seen how to build and serve your project with Docker, you can go ahead and take advantage of everything that containers have to offer, some examples are:

  • 100% consistent builds across any machine that can run Docker
  • Fully automated deployments via a service like Docker Cloud (check this example which includes fully-automated SSL certs)
  • Run your EXACT production setup locally before deploying
  • Combine with other services, eg: for a frontend App you might want to add something like a CouchDB instance — this is a trivial task with Docker.
  • etc etc

Docker is taking over the world, and with every release it’s getting easier for regular devs to utilise its power!

Final Notes:

The example given here does not include the production-ready configuration for the nginx server — I didn’t want the details of such a thing to cloud the content of this post — if there’s demand I can follow up with a post detailing that!

Links:

Like this? If you did, and you find yourself doing any front-end work, perhaps you’d enjoy some of my lessons on https://egghead.io/instructors/shane-osbourne— many are free and I cover Vanilla JS, Typescript, RxJS and more.