Docker Multi Stage Build for Nuxt Generate

February 7th 2020

Nuxt.js is our go to platform for front-end development, single page apps and universal apps – offering server side rendering (SSR) and progressive web app (PWA) integration. The node package also comes with a few commands that help with the development, build and deploy process – depending on what “mode” you want to deploy in. In this tutorial we’ll use the nuxt generate command to build a “static” representation of our app. We’ll also use Docker’s multi stage build process to create our image that will install node and package dependencies, run our generate command, install lightweight nginx image and copy our statically generated output to be served from the nginx service. Let’s jump in.

Let’s create a new new nuxt project that we can use for our deployment image.

npx create-nuxt-app nuxt-docker

You can select all the default options available as we will keep this a simple example. Side note on npx. Npx comes installed with npm later than 5.7 and “mimics” a global installed package command without installing the package globally. Calling npx <command> when <command> isn’t already in your $PATH will automatically install a package with that name from the NPM registry for you, and invoke it. When it’s done, the installed package won’t be anywhere in your globals, so you won’t have to worry about pollution in the long-term.

Alright, so we have a basic nuxt app. Let’s change into the directory and start it up in development mode to get a look at it.

cd nuxt-docker && yarn dev

You can navigate to http://localhost:3000 and see the simple nuxt app. We’re not going to do any development to our nuxt app but we can pretend we did and say we are ready for deployment. Let’s start building our Dockerfile which we’ll use to build our image.

Before we forget let’s create a .dockerignore file so we don’t copy over a bunch of unnecessary files: touch .dockerignore and edit it:

node_modules
npm-debug.log
.git
.gitignore

Done and done. Let’s create our Dockerfile touch Dockerfile and we can start editing that. We’ll start by adding the first stage which as you will see soon we set a reference to it called “build”:

### STAGE 1: Build ###
FROM node:latest as build

This will look for a local cache image of node:latest and if it doesn’t exist on your machine it will pull from Docker Hub. Next we need to setup some folder structure on our image:

### STAGE 1: Build ###
FROM node:latest as build
RUN mkdir /usr/src/app
WORKDIR /usr/src/app

The RUN mkdir creates a new folder structure and then the WORKDIR command declares our working directory that all other RUN commands run in. We first will use a little caching trick from docker by copying all node_module executables to our PATH variable in our container. This will ensure any commands from node packages install work such as CMD ["nuxt", "build"] which we won’t use in this tutorial but could be useful it you are to run nuxt server instead of nginx:

### STAGE 1: Build ###
FROM node:latest as build
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH

Next we will copy over our package.json file from our project directory to the WORKDIR and then we’ll use yarn (which comes with the node:latest image) to install our dependencies:

### STAGE 1: Build ###
FROM node:latest as build
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
COPY package.json /usr/src/app/package.json
RUN yarn install --silent

Now that all of our dependencies are installed (which are defined in our package.json file we copied over) we can copy the rest of our application files over. Luckily, we created a .dockerignore file so all of our /node_modules/ packages are not copied in the process:

### STAGE 1: Build ###
FROM node:latest as build
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
COPY package.json /usr/src/app/package.json
RUN yarn install --silent
COPY . /usr/src/app

Finally, we will run our nuxt generate command to generate our static dist folder with the necessary files:

### STAGE 1: Build ###
FROM node:latest as build
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
COPY package.json /usr/src/app/package.json
RUN yarn install --silent
COPY . /usr/src/app
RUN yarn run generate

Note, we left the “run” keyword in the yarn run generate command for explicit reasons but you can leave this out to run a script in yarn. The syntax we use here is similar to npm run command. When this executes it should build a /usr/src/app/dist folder with all our necessary static files that we will reference later to copy to our nginx folder. STAGE 1 is done and now we’re going to start building our STAGE 2 in our multi stage image.

STAGE 2 is going to inherit from nginx-alpine which is much smaller than most distribution base images (~5MB), and thus leads to much slimmer images in general.

### STAGE 1: Build ###
FROM node:latest as build
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
COPY package.json /usr/src/app/package.json
RUN yarn install --silent
COPY . /usr/src/app
RUN yarn run generate

### STAGE 2: NGINX ###
FROM nginx:stable-alpine

Next we will need to copy our dist folder from our STAGE 1 build step (luckily we have a reference to it) into our nginx server root directory so our files will be served:

### STAGE 1: Build ###
FROM node:latest as build
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
COPY package.json /usr/src/app/package.json
RUN yarn install --silent
COPY . /usr/src/app
RUN yarn run generate

### STAGE 2: NGINX ###
FROM nginx:stable-alpine
COPY --from=build /usr/src/app/dist /usr/share/nginx/html

Then we can expose the port 80 on all containers generated from our image. The EXPOSE instruction indicates the ports on which a container listens for connections. In our case the underlying nginx server defaults to listen on 80 so we need to expose port 80 for external connections:

### STAGE 1: Build ###
FROM node:latest as build
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
COPY package.json /usr/src/app/package.json
RUN yarn install --silent
COPY . /usr/src/app
RUN yarn run generate

### STAGE 2: NGINX ###
FROM nginx:stable-alpine
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
EXPOSE 80

Finally, we need to start the nginx command. There are a few options here for nginx command, but we will keep our command pretty simple. If you want to use other options you can reference https://hub.docker.com/_/nginx. One important key to note from the docs: If you add a custom CMD in the Dockerfile, be sure to include -g daemon off; in the CMD in order for nginx to stay in the foreground, so that Docker can track the process properly (otherwise your container will stop immediately after starting)!

### STAGE 1: Build ###
FROM node:latest as build
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
COPY package.json /usr/src/app/package.json
RUN yarn install --silent
COPY . /usr/src/app
RUN yarn run generate

### STAGE 2: NGINX ###
FROM nginx:stable-alpine
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Alright, our Dockerfile is done and ready to use to build our image. We’ll use the docker build command and we will give a “tag” to our image so we can reference it when we use our docker run to spin up a container. From our project directory (where our Dockerfile is defined) we can run:

docker build -t nuxt:nginx .

We can confirm our image was build by running docker images

REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
nuxt                nginx               977b2cf68082        3 minutes ago       22MB
<none>              <none>              ccae36d81fa3        4 minutes ago       1.11GB
node                latest              07e774543bdf        23 hours ago        939MB
nginx               stable-alpine       5fad07aba15a        2 weeks ago         21.8MB

Great, we can see that our nuxt:nginx image has been generated. You will also notice that the node:latest and nginx:stable-alpine have been pulled from docker hub and cached. But what’s with the <none>:<none> image that was created? During multi stage builds Docker creates intermediate images that are not tagged but could be used again for future image builds. This is also called a dangling image and depending on your preferences for building the image again you can either leave it or remove. These dangling images can build up in size so you’ll want to monitor them. Now the moment of truth…we want create a container from our image. Let’s run:

docker run --name basic_nuxt --rm -d -p 3333:80 nuxt:nginx

Let’s break this down. We use docker run and we name our container “basic_nuxt” with the –name flag. We also use the –rm flag to delete the container once it is stopped so we don’t have to do run a docker rm basic_nuxt once we stop our running container. This keeps our machine clean from containers. The -d flag means that we want to run in detached mode and the container is run in the background, otherwise the output will be sent to our terminal window we run the command with. Finally, we map the host port (in this case our computer) to the exposed port on the container by specifying -p 3333:80. The last piece is the image we are creating the container from nuxt:nginx which is what we tagged it with.

With any luck we can navigate our browser to http://localhost:3333/ and we should see our nuxt app in generate mode! Wow, we did it, and came a long ways. This is a powerful way to containerize a nuxt app and serve it using a lightweight nginx image. Until next time, stay curious, stay creative!

UPDATE
If you want to learn how to mount the /dist folder to a docker volume in our container for persisting the data that nuxt generate creates, then head on over to the blog on mounting volumes with docker: https://hashinteractive.com/blog/mounting-docker-volume-on-nuxt-generate-directory/