Reducing Docker image size of a Nuxt SSR application
Tags: javascript, devops, nuxt, vue
Published: 2021-05-17 14:02:17

Recently I had to create a deployment of a NuxtJS application which is running in SSR mode. I have a separate backend that is already packed in Docker image, so it sounds pretty tempting to dockerize the frontend application as well and to run both using docker-compose. Basically, server side rendering implies that the frontend application becomes a server too, to some extent. 

To demonstrate the concept, I will show you two Dockerfiles, one is straightforward, without any optimizations, and another is what goes into production. 

First obvious idea is to get the smallest node-based image available. Of course, it's an Alpine one. 

So let's consider this `Dockerfile`, assuming we don't care about a final size too much:

```Dockerfile
FROM node:15.11.0-alpine3.12 as frontend

WORKDIR /src

ADD frontend ./
RUN yarn install && yarn build

ENTRYPOINT ["npx", "nuxt", "start"]
EXPOSE 3000
```

Now check the size:

```bash
➜ docker images | grep demo-frontend
demo-frontend     latest     151ebafca257   1 minute ago   782MB
```

I couldn't bear the thought that a simple frontend application will take almost 800MB of disk space. It's not a surprise though, cause `node_modules` is enormous. We could of course use multi-stage builds and install only production dependencies for runtime, but it would not cost the effort:

```shell
➜ yarn install
➜ du -sh node_modules
386M	node_modules

➜ yarn install --production
➜ du -sh node_modules
276M node_modules
```

And now the trick. Let's check what's inside of a `.nuxt` folder, that is generated by `nuxt build`:

```shell
➜ yarn build
➜ du -sh .nuxt/dist/*
5.5M	.nuxt/dist/client
1.2M	.nuxt/dist/server
```

It looks pretty strange that client-side code takes more space than the server-side, isn't it? :thinking:
Apparently, server-side code is relying on third-party libraries stored in the node modules. They are not bundled.

The good thing is that Nuxt offers a solution, a `--standalone` option that solves this issue. Let's try to rebuild and compare.

```
➜ yarn build --standalone
➜ du -sh .nuxt/dist/*
5.5M .nuxt/dist/client
 39M .nuxt/dist/server
```

Yep, something has changed for sure. Dependencies for a server runtime are now stored in `.nuxt` folder, so we don't need all the `node_modules` anymore.

And now the final insight: you don't need the entire `nuxt` package to run your code using `nuxt start`. There's a separate package that is optimized only for running bundles in SSR mode: [nuxt-start](https://www.npmjs.com/package/nuxt-start). So the final step is to install this package in a runtime Docker image and skip the rest.

Let's have a look on the final `Dockerfile`:

```Dockerfile
FROM node:15.11.0-alpine3.12 as frontend-build

WORKDIR /src

ADD frontend/yarn.lock frontend/package.json ./
RUN yarn install

ADD frontend ./
RUN yarn build --standalone

FROM node:15.11.0-alpine3.12

ENV NUXT_VERSION=2.15.6

WORKDIR /app

RUN yarn add "nuxt-start@${NUXT_VERSION}"

COPY --from=frontend-build /src/.nuxt /app/.nuxt
COPY --from=frontend-build /src/nuxt.config.ts /app/
COPY --from=frontend-build /src/static /app/

ENTRYPOINT ["npx", "nuxt-start"]
EXPOSE 3000
```

In case you wonder what we've just done:

In build image (that is not used in production):
1. Install the dependencies from `package.json`
2. Build an application in a standalone mode, so `.nuxt` folder contains everything we need

In runtime image (that is running in production)
1. Install `nuxt-start`, a package that will run our app
2. Copy the `.nuxt` folder from the build image, as well as static folder and NuxtJS config
3. Run the app

Now, how much the final image weighs?

```shell
demo-frontend     latest     f41a130ae000   21 seconds ago   208MB
```

Yep, that's true 🙂 We've just saved 574 MB of a disk space, final image became **3.75 times thinner** than initial!

Of course, it highly depends on the size of your dependencies, but I'm sure you got the idea. Please also keep in mind that it's a good idea to install `nuxt-start` with the same version as `nuxt` from your `package.json`.

TL;DR:
- Get Alpine as a base image
- Leverage multi stage builds
- Bundle dependencies into server code
- Run server using `nuxt-start` package

Happy deploying! 🚀

---
_Edit on May, 21:_

There was a suggestion in comments by @artalus to not use multi-stage builds, but to put all the logic into single `RUN` statement instead. In theory, it should result into even more space savings due to reducing the number of docker layers. Let's try it out!

```Dockerfile
FROM node:15.11.0-alpine3.12

ENV NUXT_VERSION=2.15.6

WORKDIR /app

ADD frontend ./
RUN : \
  && yarn install \
  && yarn build --standalone \
  && rm -rf node_modules \
  && rm package.json \
  && yarn add "nuxt-start@${NUXT_VERSION}" \
  && yarn cache clean \
  && :

ENTRYPOINT ["npx", "nuxt-start"]
EXPOSE 3000
```

🥁 ...

```shell
demo-frontend     latest     2e2ca36f6c2e   30 seconds ago   195MB
```

Awesome! 13MB may not sound that great, but now the total result is less than 200! Or officially **4x times thinner** than initial version.

I intentionally haven't modified original post to show you the idea of applying optimizations step-by-step. 

Please also note that using single `RUN` statement slows down your build to some point, cause `yarn install` step is not cached anymore. However, this is only relevant if you have caching enabled on your CI agent.

Cheers! 🎉