Make your Next.JS Docker images microscopic!

Published on , 1066 words, 4 minutes to read

Do standalone builds on Alpine

An image of A series of brown and white balls of light on a black background, the result of intentionally defocusing the lens against a chandelier
A series of brown and white balls of light on a black background, the result of intentionally defocusing the lens against a chandelier - Photo by Xe Iaso, Helios 44-2 58mm f/2

So you've been hacking up a Next.JS app and you wanna put it into production (be it on Fly.io, Kubernetes, or whatever). Docker (or at least Docker images) are the least common denominator. If you can shove your app into a Docker image, it'll run pretty much anywhere.

ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim as base

# Next.js app lives here
WORKDIR /app

# Set production environment
ENV NODE_ENV="production"


# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build node modules
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3

# Install node modules
COPY --link package-lock.json package.json ./
RUN npm ci --include=dev

# Copy application code
COPY --link . .

# Build application
RUN npm run build

# Remove development dependencies
RUN npm prune --omit=dev


FROM base AS run

# Copy built app
COPY --from=build /app /app

# Run the app
CMD [ "npm", "run", "start" ]

To be honest, this is really good from a Dockerfile optimization standpoint. You have two stages for the build, one of them builds the app and then the run stage copies over the built app and starts it in production. There's honestly not much that can be gained by doing further optimization at the Dockerfile level.

The main problem is that the resulting image is a gigabyte:

$ docker images
REPOSITORY                           TAG       IMAGE ID       CREATED              SIZE
chonker                              latest    0548acbc5aa9   About a minute ago   1.01GB
Cadey is coffee
<Cadey>

These size numbers are for aarch64 (arm64) Linux Docker images. Apparently amd64 Linux Docker images should be slightly smaller. Either way, don't focus on the exact numbers too hard.

1 gigabyte to do nothing but show the default "hello world" page? That seems wasteful. Especially for an image that will be pushed and deployed multiple times per day. If you use a platform that charges you per gigabyte of container image registry space, that's a classic recipe for unbounded cost growth.

Cadey is coffee
<Cadey>

This is not good for financial solvency.

Most of the damage seems to be contained to the /app folder:

$ docker run -it --rm --entrypoint /bin/bash chonker
root@5e8843246475:/app# cd ..
root@5e8843246475:/# du -hs app
497M	app

Standalone builds

One of the quickest wins is enabling standalone builds in your next.config.mjs file:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone",
};

export default nextConfig;

This makes next build crap out the minimal subset of dependencies you need in production at /app/.next/static. Once you enable standalone mode in your configuration, then you need to change your COPY commands in the run layer to something like this:

FROM base AS run

# Copy standalone app
COPY --from=build /app/.next/standalone /app
COPY --from=build /app/.next/static /app/.next/static
# Omit me if you don't have static files in your public folder yet
COPY --from=build /app/public /app/public

This cuts out even more:

$ docker images
REPOSITORY                           TAG       IMAGE ID       CREATED          SIZE
standalone                           latest    a55c81de1fc9   8 seconds ago    363MB

That saved a whole 550 megabytes of space! However, we can do more.

Alpine Linux

Depending on the needs of your workload, you can likely get away with basing your image on Alpine Linux. In order to use that, you need to change your Dockerfile a little.

First, change the FROM directive at the top of the Dockerfile to make everything based on node:${NODE_VERSION}-alpine:

ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine AS base
Mara is hacker
<Mara>

When the Dockerfile is being evaluated, this will expand out to node:22-alpine. This lets you bump your container image's version of Node.js in lockstep with the version you use in development on your MacBooks. You can also replace the node version by using the --build-arg flag:

$ docker build --build-arg NODE_VERSION=18 -t chonker-18 .

Then, in the build stage, change the apt-get command into an apk command:

# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build node modules
RUN apk -U add build-base gyp pkgconfig python3

When you build this, you get absolutely puny Docker images:

$ docker images
REPOSITORY                           TAG       IMAGE ID       CREATED              SIZE
standalone-alpine                    latest    3a51c7bea5e6   About a minute ago   243MB

Even better, the base image gets shared between builds, so when you push images you only really push the changes in the /app folder!

$ docker run --rm -it --entrypoint /bin/sh standalone-alpine
/app # du -hs /app
24.2M   /app

That means that when you push new versions of your app to prod, you're only really pushing about 24 MB of data. This makes your deploys faster and saves you space on the registry.

Going deeper

However, we can go deeper, we have the technology. The Node 22.x.y image uses Alpine Linux version 3.19. You can install the version of Node in Alpine's repos and make your image even smaller!

Change your FROM directive to alpine:3.19:

FROM alpine:3.19 AS base

Then add nodejs and npm to your apk add command:

# Install packages needed to build node modules
RUN apk -U add build-base gyp pkgconfig python3 nodejs npm

Install nodejs in the run image:

FROM base AS run

# Install node.js
RUN apk add nodejs

And finally change the command to start Next.js directly:

# Run the app
CMD [ "node", "server.js" ]

This cuts out even more from the image, leaving you with a microscopic image:

$ docker images
REPOSITORY                           TAG       IMAGE ID       CREATED          SIZE
standalone-alpine-bean               latest    f37491197fbb   32 seconds ago   123MB

This is perfect to deploy to production.

Next steps

From here you can do anything you want with confidence that you aren't going to break the bank by continuously deploying to production.

If you want to see all of the examples in fully complete and working forms, check out the GitHub repo Xe/nextjs-image-optimizations.

Otherwise, have a great day! If you're going to be at Small Data SF tomorrow, I'm going to be doing a workshop about how to make something like Mystery Science Theater 3000 with vision models. It'll be a hoot.


Facts and circumstances may have changed since publication. Please contact me before jumping to conclusions if something seems wrong or unclear.

Tags: