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:

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:

➜ 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:

➜ yarn installdu -sh node_modules
386M    node_modules

➜ yarn install --productiondu -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:

➜ 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? 🤔
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. 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:

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?

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! 🚀