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 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
:
➜ 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):
- Install the dependencies from
package.json
- Build an application in a standalone mode, so
.nuxt
folder contains everything we need
In runtime image (that is running in production)
- Install
nuxt-start
, a package that will run our app - Copy the
.nuxt
folder from the build image, as well as static folder and NuxtJS config - 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! 🚀
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
🥁 ...
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! 🎉
Top comments (14)
How about experiment with something like
This should further decrease image bloat because
node_modules
will be created and deleted as part of a single layer creation "function".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!@artalus please check 🙂
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 ofnode_modules
content that is required byyarn build
but not bynuxt-start
. But small gain is still a gain 🙃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 :)
Hey Kyle I'd be very interested to see your setup!
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
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 │
same here while deploying on azure
"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate",
"lint:js": "eslint --ext .js,.vue --ignore-path .gitignore .",
"lint": "npm run lint:js"
},
"dependencies": {
"nuxt": "^2.15.8",
},
"devDependencies": {
"@nuxtjs/pwa": "^3.3.4",
"webpack": "^4.46.0"
}
try to add in buildModules instead of "module" in nuxt.config
Fixed:
adding the "@nuxtjs/pwa" in buildModules in case of SSR: true (default settings) , and remove "@nuxtjs/pwa" from modules
Hi, i've got an issue with axios when starting the server:
It's the first modules loaded in my nuxt.config.ts
Am i missing something ?
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 :)Great article thanks