Dockerising a Next.js project
Tags: nextjs, javascript, docker, devops
Published: 2020-06-10 10:52:21

I recently made the switch from Mac to Windows - I won't get into the reasons why, other than to mutter a few grumbles about keyboards. What I will say is that having our projects well Dockerised proved to be invaluable in making the move. Where previously I'd have lost days to getting my dev environment setup, a simple `docker-compose up dev` worked seamlessly, out of the box.

My dev environment isn't the only area where Docker is valuable, of course. While it may seemingly go against convention, we generally choose to deploy our [Next.js](https://nextjs.org/) apps as [Fargate](https://aws.amazon.com/fargate/) services. I won't get into the many Dev Ops related reasons for this, but I will show you how we use Docker and Docker Compose to run our Next.js apps effectively, across environments...

I'm going to assume you have [Docker Desktop](https://www.docker.com/products/docker-desktop) installed, know the difference between Docker and [Docker Compose](https://docs.docker.com/compose/), and have a working knowledge of Next.js. 

With those prerequisites out of the way, let's start with our `Dockerfile`:

```docker
FROM node:current-alpine AS base
WORKDIR /base
COPY package*.json ./
RUN npm install
COPY . .

FROM base AS build
ENV NODE_ENV=production
WORKDIR /build
COPY --from=base /base ./
RUN npm run build

FROM node:current-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /build/package*.json ./
COPY --from=build /build/.next ./.next
COPY --from=build /build/public ./public
RUN npm install next

EXPOSE 3000
CMD npm run start
```

This may not look like the `Dockerfile` you were expecting... This is a ["multi-stage" Dockerfile](https://docs.docker.com/develop/develop-images/multistage-build/), which can be used for both development and production deploys. There are various reasons you may want to do this, but the primary one is that the size of our docker images can be reduced dramatically as they only bundle the result of the final step.

Let's take a look at that first step:

```docker
FROM node:current-alpine AS base
WORKDIR /base
COPY package*.json ./
RUN npm install
COPY . .
```

This looks more or less like any other node related Dockerfile; it extends from the [official node image](https://hub.docker.com/_/node/), copies our package.json and installs it, then adds the working project files.


This next stage is where things get interesting - this is where we compile our next app:

```docker
FROM base AS build
ENV NODE_ENV=production
WORKDIR /build
COPY --from=base /base ./
RUN npm run build
```

Each stage of a multi-stage Dockerfile is self-contained, so we have to explicitly copy any files we want from the base step. This step only relates to a production build, so we're explicitly setting the `NODE_ENV` to `production`, copying the files from the base step, and running the build script specified in our package.json.

With our app compiled, we're on to the final step: creating a lean, production-ready image.

```docker
FROM node:current-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /build/package*.json ./
COPY --from=build /build/.next ./.next
COPY --from=build /build/public ./public
RUN npm install next

EXPOSE 3000
CMD npm run start
```

From the previous build step, we copy our package.json, the `.next` directory which contains our compiled app, and the directory which contains our public assets across. Finally, it installs the next package, uses it to start our compiled app, and exposes it at `localhost:3000`. The only files this final image contains are the ones that we copied across - the essentials - keeping it super lean. We've ditched our heavy node_modules directory, among other things.

| Note: |
| - |
| You may have noticed I specified `ENV NODE_ENV=production` again in this step. This is because `ENV` variables aren't shared between steps, so need to be duplicated. |

That's our Dockerfile done; now how do we run `next dev` with this thing?

Simple: we need a `docker-compose.yml` file:

```yml
version: "3.7"

x-common-props: &common-props
  build:
    context: ./
    target: base
  working_dir: /base
  volumes:
    - ./app:/base
    - node_modules:/base/node_modules

services:
  npm:
    <<: *common-props
    entrypoint: npm
  dev:
    <<: *common-props
    ports:
      - "3000:3000"
    command: npm run dev
volumes:
  node_modules:
```

This gives me two local services; `npm` and `dev`. Both use the `base` step from our `Dockerfile`, but:

* `npm` specifies the `npm` command as it's entry point, so I can write convenient commands like `docker-compose run npm i -s moment`.
* `dev` specifies the `dev` script from our `package.json`, so I can start the whole thing up with `docker-compose up dev` and see my app running at `localhost:3000`.

I have some `common-props` to share attributes between services, and these include mounting a volume for my `node_modules` - a useful trick for sharing modules between containers and saving _a lot_ of time.

---

To sum up: adding these two files to the root of any standard next.js project should have you up and running in no time - with:

* Out of the box, cross-platform development for your whole team.
* Blazing fast, fully containerized production deploys.

If you've any reservations about how performant a containerised next.js app, compared to one targetting serverless, I leave you with this timeline from the dynamic, database driven homepage of one of our projects:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/nwxlhy4dfehvbkofajty.png)