DEV Community

Wences Martinez
Wences Martinez

Posted on

Optimizing Nuxt + Prisma in Docker: How we cut our image size by 84%

By now, Nuxt hardly needs an introduction as one of the most used full-stack frameworks. For those new to the Vue ecosystem, it is essentially the counterpart to Next.js in the React world.

So, Nuxt is not just a frontend framework. With Nitro as the server engine, you get SSR, API routes, server middleware… it’s a real full stack framework. We’ve been using it at Resizes for a long time to build complex applications, and it works really well!

That sounds really cool, right? But what is the most efficient, fastest, and most lightweight way to build a Docker image for a Nuxt app?

Let’s talk about it!


Our Use Case

At Resizes we have developed several applications with Nuxt4, and one of them has several modules and dependencies; an auth library, an ORM to handle database migrations and even an embedded database, so dockerizing it efficiently was not as straightforward as it seems.

Our Nuxt stack

In this post I’ll share how we do it, with real numbers from our GitHub Actions pipeline and the hidden traps we found along the way.

The stack

The following package.json reveals some of our most used dependencies regarding a Nuxt 4 application:

// package.json
...
"scripts": {
    "dev": "nuxt dev",
    "build": "nuxt build",
    "generate": "nuxt generate",
    "preview": "nuxt preview",
    "postinstall": "nuxt prepare && prisma generate",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix"
},
...
"dependencies": {
    "better-sqlite3": "~12.0.0",
    "@prisma/client": "~7.2.0",
    "@prisma/adapter-better-sqlite3": "~7.3.0",
    "@anthropic-ai/sdk": "~0.72.1",
    "better-auth": "~1.4.17",
    "zod": "~4.3.5",
    "prisma": "~7.2.0",
    "dotenv": "~17.2.3"
    ...
  },
  "devDependencies": {
    "nuxt": "~4.2.2",
    "@nuxt/ui": "~4.3.0",
    "@nuxt/eslint": "~1.12.1",
    "vue": "~3.5.27",
    "@vueuse/nuxt": "~14.1.0",
    "typescript": "~5.9.3",
    "eslint": "~9.39.2",
    "eslint-config-prettier": "~10.1.8",
    "prettier": "~3.8.1",
    "tsx": "~4.21.0",
    ...
  }
Enter fullscreen mode Exit fullscreen mode

The Dockerfile: single vs multi stage

Can you deploy a Nuxt application with a basic Dockerfile? Of course you can! But that doesn’t mean you should.

While a single-stage Dockerfile will technically build your app, it drags a huge amount of baggage into production. We’re talking about build tools, dev dependencies, source code, and artifacts.

You end up shipping the entire kitchen sink instead of just the lean, compiled application.

An example of a basic single-stage Dockerfile:

# Single-stage Dockerfile (for comparison only — DO NOT use in production)
FROM node:24-slim
RUN apt-get update && apt-get install -y python3 make g++ --no-install-recommends && rm -rf /var/lib/apt/lists/*

WORKDIR /app
ARG DATABASE_URL="file:./dummy.db"
COPY . .

RUN npm ci
RUN npx prisma generate && npx nuxt build

ENV NODE_ENV=production
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000

# Still need to fix Nitro stubs even in single-stage
RUN rm -rf .output/server/node_modules
RUN cp -r node_modules .output/server/node_modules

COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh

EXPOSE 3000

ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", ".output/server/index.mjs"]
Enter fullscreen mode Exit fullscreen mode

We’ve done the test and we’ve built our app with the above Dockerfile vs a multi-stage Dockerfile to compare the size of the final image between them.

The results are crazy: 4.05 GB vs 637MB. The multi-stage Dockerfile is almost x7 times smaller than the single-stage Dockerfile 🫢

Single-stage vs multi-stage image

Docker Layer Anatomy: Where Does All That Space Go?


But… how we did it?

Multi-stage build separates the process into phases:

  1. Build stages: have everything needed to compile. They’re temporary images that get discarded from the final image!
  2. Runner stage: contains only the minimum code to run the app. It’s the only image pushed to the registry! The concrete benefits:

Smaller final image.

  • Reduced attack surface — no compilers in production.
  • Faster deploys — less pull time on each Kubernetes node.
  • Automatic parallelism — Docker runs independent stages in parallel (if required).
  • Granular caching — if you only change code, the dependencies stage is fully cached.

Our Dockerfile has 4 stages. Let’s go through each one.

Dockerfile multistage steps

Stage 1: Base tools

# -------- Base --------
FROM node:24-slim AS base
RUN apt-get update && apt-get install -y python3 make g++ openssl --no-install-recommends && rm -rf /var/lib/apt/lists/*
WORKDIR /app
Enter fullscreen mode Exit fullscreen mode

You’re probably wondering why the heck do we need python3, make & g++ dependencies if this is a node environment application.

Easy, we’re using SQLite as a embedded database through the super fast better-sqlite3 library, and this library needs to compile native C++ bindings.

SQLite is a library written in C, and to use it from Node.js, better-sqlite3 includes a “binding” (a bridge between JavaScript and compiled C code).

This binding is compiled during npm install using node-gyp, which needs:

  • g++ — C++ compiler
  • make — orchestrates the compilation
  • python3 — because node-gyp is written in Python

The result is a .node file (binary addon) specific to your architecture and Node version.

These tools take up ~293MB, but they won’t be in the final image 💪🏼

Stage 2: Full dependencies


# -------- Dependencies --------
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
In the second stage we’re only copying the package.json and package-lock.json and run npm ci with the ignore-scripts flag.
Enter fullscreen mode Exit fullscreen mode

In the second stage we’re only copying the package.json and package-lock.json and run npm ci with the ignore-scripts flag.

This flag is really important if you want to avoid any unnecessary drama. Tools like Prisma or Nuxt love to run ‘postinstall’ scripts the second they’re downloaded, but since we haven’t copied the full source code yet, those scripts would just crash.

By skipping the installation with this flag, we keep it lightning-fast, let Docker cache the layer perfectly, and save the actual heavy lifting for the Build stage.

Stage 3: Building the application

This is where the heavy lifting happens!

Now that we have our dependencies ready, we perform a triple threat of critical tasks: generating the Prisma client, compiling the Nuxt bundle, and rebuilding better-sqlite3.

This last step is vital — it ensures our SQLite driver is natively compiled for the Linux environment, avoiding those dreaded “mismatched binary” errors in production.

# -------- Build --------
FROM base AS build
ENV NODE_OPTIONS="--max-old-space-size=4096"
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ARG DATABASE_URL="file:./dummy.db"
RUN npx prisma generate && npx nuxt build && npm rebuild better-sqlite3
Enter fullscreen mode Exit fullscreen mode

Key points:

  • npx prisma generate: Generates the Prisma Client based on your schema.
  • npx nuxt build: This command triggers the full production build, bundling the client and server-side code through the Nitro engine into a standalone directory (.output folder)
  • npx rebuild better-sqlite3: The “secret sauce” for SQLite. This recompiles the native C++ bindings for our database driver directly inside the Linux container. It ensures the binary perfectly matches our production environment, preventing the dreaded “mismatched architecture” errors.

Stage 5: The production image

# -------- Runtime --------
FROM node:24-slim AS runner
RUN apt-get update && apt-get install -y openssl --no-install-recommends && rm -rf /var/lib/apt/lists/*
WORKDIR /app

ENV NODE_ENV=production
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000

# Minimal Prisma CLI for migrations - must run BEFORE package.json is copied,
# otherwise npm sees the app's package.json and installs ALL dependencies.
COPY package-lock.json ./
RUN npm install --ignore-scripts \
    "prisma@$(node -p "require('./package-lock.json').packages['node_modules/prisma'].version")" \
    "dotenv@$(node -p "require('./package-lock.json').packages['node_modules/dotenv'].version")" \
    && npm cache clean --force \
    && rm package-lock.json

# Copy compiled app (keeps Nitro's runtime packages in .output/server/node_modules/)
COPY --from=build /app/.output ./.output

# Replace better-sqlite3 with the Linux binary compiled in the build stage.
RUN rm -rf .output/server/node_modules/better-sqlite3
COPY --from=build /app/node_modules/better-sqlite3 ./.output/server/node_modules/better-sqlite3

# Copy Prisma config + migrations folder for migrate deploy
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts

COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh

EXPOSE 3000

ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", ".output/server/index.mjs"]
Enter fullscreen mode Exit fullscreen mode

The final image uses node:24-slim; no compilers, no Python, no dev tools. Just Node.js and your app.

Three important details here:

  • We leave .output/server/node_modules intact — except for better-sqlite3, which we delete and replace with a freshly compiled binary (in build stage).
  • We install only prisma and dotenv into /app/node_modules, the bare minimum needed to run prisma migrate deploy in the entrypoint script. The rest of the app’s dependencies are already bundled inside .output/server/node_modules by Nitro.
  • Automatic Prisma migrations with entrypoint.sh, this script runs prisma migrate deploy every time the container starts, ensuring your database always has the latest schema:
#!/bin/sh

echo "Applying database migrations..."

export NODE_PATH="/app/node_modules"
/app/node_modules/.bin/prisma migrate deploy

echo "Starting application..."
exec "$@"
Enter fullscreen mode Exit fullscreen mode

GitHub Actions pipeline results

These are real numbers from our GitHub Actions pipeline for AMD64 architecture when building the app’s image:

GitHub Actions Runner results

💡 Multi-arch tip: If you build ARM64 images on AMD64 runners via QEMU, expect 4–7x slower builds. Use native ARM runners if speed matters!


Hidden Gotchas

Nitro can’t bundle everything… some packages must stay in node_modules!

Nitro uses Rollup/esbuild to bundle your server code into optimized .mjs files — but not everything can be bundled.

Modules like better-sqlite3 contain native C++ bindings (.node files), which are platform-specific binaries that simply cannot be converted into a .mjs file. For those, Nitro falls back to copying them — along with their full dependency tree — into .output/server/node_modules/.

So, we have to leave .output/server/node_modules intact, except for better-sqlite3, which we delete and replace with a freshly compiled binary (previously generated with npm rebuild better-sqlite3).

We install only prisma and dotenv into the root /app/node_modules; the bare minimum needed to run prisma migrate deploy in the entrypoint. The rest of the app’s dependencies are already bundled inside .output/server folder by Nitro.

💡 We explicitly mark better-sqlite3, among other dependencies, as external in the nuxt.config.ts file, which tells Nitro to skip bundling them entirely and copy them as-is into .output/server/node_modules directly:

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    esbuild: {
      options: { target: 'es2020' }
    },
    externals: {
      external: ['better-sqlite3', ...],
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

Dockerizing a full-stack Nuxt application isn’t trivial when you have native modules or you want to reduce significantly the final image size, but with a solid multi-stage build it’s completely viable.

Key takeaways:

  • Multi-stage build is not optional — it’s the difference between 637MB and 4.05 GB. A 84% image size reduction.
  • The numbers speak: ~4 minutes for a complete AMD64 build, lightweight final image ready for production.
  • Don’t delete .output/server/node_modules — Nitro copies the externalized packages there and needs them at runtime. Only replace the ones with native binaries (like better-sqlite3) that need to be recompiled for Linux.

If you’re evaluating Nuxt for your next full-stack project, give it a shot. The Vue ecosystem has a first-class full stack framework, and with Docker, deploying it is just matter of minutes!


This post is based on our real experience deploying Nuxt applications in production at Resizes. All timings and data are from our GitHub Actions pipeline.

Feel free to share this post among your community!

See you! 👋🏻

Top comments (0)