DEV Community

loading...
Cover image for Reducing Docker image size of a Nuxt SSR application

Reducing Docker image size of a Nuxt SSR application

Denis
Hey there 👋 Internet is full of guides and articles, but sometimes it's still hard to find useful information. I decided to share my knowledge at some point, to pay back the googling karma
Updated on ・4 min read

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
Enter fullscreen mode Exit fullscreen mode

Now check the size:

➜ docker images | grep demo-frontend
demo-frontend     latest     151ebafca257   1 minute ago   782MB
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

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
Enter fullscreen mode Exit fullscreen mode

🥁 ...

demo-frontend     latest     2e2ca36f6c2e   30 seconds ago   195MB
Enter fullscreen mode Exit fullscreen mode

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

Discussion (9)

Collapse
artalus profile image
Artalus

How about experiment with something like

RUN yarn install && yarn build --standalone && rm -rf node_modules && yarn add "nuxt-start@${NUXT_VERSION}"
Enter fullscreen mode Exit fullscreen mode

This should further decrease image bloat because node_modules will be created and deleted as part of a single layer creation "function".

Collapse
fbjorn profile image
Denis Author

Thanks for a suggestion, sounds pretty interesting. Need to try it out.

I'm using self-hosted agents for CI, so they keep docker cache between the builds. This way I'm able to skip yarn install part cause it's available in cache. Anyway I'll try your suggestion on the same codebase for clarity and report back soon!

Collapse
fbjorn profile image
Denis Author

@artalus please check 🙂

Thread Thread
artalus profile image
Artalus

My initial concern was about "why even have node_modules in final docker image at all", and I actually was hoping for 100Mb decrease. But couple of minutes after posting the suggestion I actually realized that, well, nuxt-start is a Node application itself, and it will likely require a lot of dependencies to work - because that's just the reality of Node applications ¯\_(ツ)_/¯. So the only gain would be due to removal of a slice of node_modules content that is required by yarn build but not by nuxt-start. But small gain is still a gain 🙃

Collapse
kcq profile image
Kyle Quest • Edited

Nice post!

It's great if Alpine works for your app and your environment. There's a lot of gotchas with it though and many people that initially adopt it migraine away from it eventually.

For some containerized apps DockerSlim is a good option. It lets you use regular and developer friendly base images minifying the images as a post-processing step. We have nuxt SSR apps too and I'll be happy to share our setup :)

Collapse
artis3n profile image
Ari Kalfus

I hit an error with bootstrap-vue trying this on my project and didn't want to troubleshoot why that didn't get bundled into the standalone build, but overall this is great :)

Collapse
king11 profile image
Lakshya Singh

Great article thanks

Collapse
maxim_bashevoy_74309f949e profile image
Maxim Bashevoy

Does this work for nuxt PWA or am I missing something? Getting this error: FATAL Cannot find module '@nuxtjs/pwa' Require stack: /app/node_modules/@nuxt/core/dist/core.js

Collapse
msoler75 profile image
Marcel Soler / Pigmalión Tseyor

Same here with strapi module:

Error: Cannot find module '@nuxtjs/strapi'

Require stack: │
│ - /app/node_modules/nuxt-start/node_modules/@nuxt/core/dist/core.js │