I’ve spent enough time wrestling with Docker and Next.js to confidently guide you through this process. We’re going to take that multi-stage Dockerfile we’ve got and break down exactly how it builds a production-ready image for your Next.js app. It’s a great one – we’re making the image small, fast, and secure. Let’s get to it.
# syntax=docker.io/docker/dockerfile:1
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
The goal here isn’t just to get it running—we want to do it right. This multi-stage Dockerfile achieves several key things:
- Clean separation: Keeps our build process distinct from the final image, leading to a smaller final image size.
- Layer caching: Optimizes the Docker build process so subsequent builds are faster.
- Security conscious: Running the app under a non-root user for enhanced security.
- Reproducible builds: Ensures you’ll get the exact same result when building, thanks to
package.json
files and lockfiles
Here’s the detailed breakdown:
Understanding Multi-Stage Dockerfiles
Before diving into the specific instructions, let’s discuss the multi-stage build pattern we’re going to use here. We aren’t throwing everything into a single container. Instead, each stage focuses on one purpose.
- The base image: A solid base layer that other stages rely on, we are using a standard Node alpine base image
- Dependency install: We Install all of our project’s dependancies for build/production
- Building stage: Takes dependancies and source code, executes next’s build step
- The final stage: Just production runtime essentials: The results of build step, configs and public folder for rendering.
This methodology avoids adding development tooling and large intermediary build outputs in the final image. It’s a powerful approach that results in lightweight, portable containers.
Detailed Walkthrough
Let’s step through our Dockerfile stage-by-stage, explaining every detail
# syntax=docker.io/docker/dockerfile:1
This initial directive ensures the use of the latest Dockerfile syntax features that gives better quality-of-life updates to syntax for container builds. Always good to include.
Stage 1: Base Image (base)
FROM node:22-alpine AS base
FROM node:22-alpine AS base
: We’re starting with Node.js version 22 using an Alpine Linux base image.alpine
is very small which is perfect for lightweight container image. We name this first layerbase
- all stages are named making referring to it for later steps that more descriptive.
Stage 2: Dependencies (deps)
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
FROM base AS deps
: We are taking our base image to perform dependancy installations, refering to this stage later in the next one.RUN apk add --no-cache libc6-compat
: This line is essential. Some Node.js packages use the C standard library. The base image from node only uses the bare minimum needed, so to work with a wider breadth of packages, this lib adds to your container so no errors with node builds pop up due to a lack of standard C packages.--no-cache
keeps the layers small, it means we don’t keep local copies of files downloaded throughapk
tool.WORKDIR /app
: We set/app
as the working directory for all subsequent instructions in this stage. This creates the folder automatically if it doesn’t exist in the image already, important since base image does not.COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
: This line copies only essential files to be installed into container in case any package manager files are present for installationRUN ...
: Now comes the tricky part of auto detecting lockfile. Based on lockfiles being present the proper dependancy installation occurs in each individual manager-
If yarn.lock found, use
yarn install
-
If package-lock.json found use
npm ci
for a ‘clean install’ for npm dependancies -
if pnpm-lock.yaml found, we use corepack to install & lock pnpm version from env settings. use pnpm install
-
If lockfile not found we should stop process and prevent builds failing due to lack of package installations.
Note: It’s very important that the only files being added in this COPY command are ones pertaining to installation. It exploits Docker caching, such that on rebuilds if any of these change in the future then Docker will know only these layers are the changes. Not the whole codebase being changed. This makes repeated rebuilds faster!
-
Stage 3: Building (builder)
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
FROM base AS builder
: Takes our bare alpine base as build envrionment and giving name builder for ease of identification for next stages to refer to.WORKDIR /app
: Uses the folder we set as base in the previous layers as working directory, this creates a dir to have our final source build reside at same as base to avoid folder conflicts.COPY --from=deps /app/node_modules ./node_modules
: Re-adds previously installed packages to use during build. Crucially, it pulls these node modules fromdeps
stage, taking advantage of layer caching so only change when install change.COPY . .
: Copies over the rest of our app. Make sure your.dockerignore
includes.next
to stop an endless copy/rebuild loop that will fail, due to node_modules folders needing to install correctlyENV NEXT_TELEMETRY_DISABLED=1
: By default NextJs shares Telemtry info, this allows turning of this to protect security on deployed applications. Feel free to remove.RUN ...
: Runs the appropraite build command to begin building next production folder based off which lockfile it detect
Stage 4: Runner (The Final Image) (runner)
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
FROM base AS runner
: Our final stage is here. Notice, we’re startingFROM base
again, instead of from the build layer – the dependencies, source code are not being directly taken forward. This ensures only prodution related dependencies/assets get pushed to container.WORKDIR /app
: Again set the same as the working directory for the container.ENV NODE_ENV=production
: Sets environment variable.# ENV NEXT_TELEMETRY_DISABLED=1
: As before this option exists to turn of telemetry on this runtime stage as well, comment can be removed for active disable stateRUN addgroup --system --gid 1001 nodejs
andRUN adduser --system --uid 1001 nextjs
: Critical for security, we are setting up dedicated a group and user that will own the container and can avoid issues with unpermissive file locks/conflicts during development builds. This keeps app contained during running. This prevents usingroot
, for additional securoty this should be done.COPY --from=builder /app/public ./public
: This is used for hosting your websites public static files - images and such used throughout your website in the/public
directory in your project directoryCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
: Copies required parts from output tracing to avoid including node_modules for the container using next output traces to allow next js standalone setup, changing ownership during this as well for more granular permission for the app. This folder houses node executable for server side renderign your webpage when hostingCOPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
: Adds assets used by rendering engine to server webpages using NextJs, again changing ownership of files added here too.USER nextjs
: Sets the user in use. The rest of the instructions here on in will execute under the newly createnextjs
rather than default user such asroot
for enhanced security.EXPOSE 3000
: Marks port 3000 available on this machineENV PORT=3000
: This provides port to be accessed by application in this container as environment variable.ENV HOSTNAME="0.0.0.0"
: Makes service run at address which is accessible from all other hosts that use a computer on network. Crucially, it prevents a loop-back when service is behind a proxy such as nginx, docker networks do not do loopbacks for forwarding ports (such as with local127.0.0.1
). By specifying0.0.0.0
we explicitly target accessable networkingCMD ["node", "server.js"]
: Starts the app, running in thisCMD
. Important server is at root, as that folder has node process output tracing in.
Build Arguments and Secrets
It’s often necessary to pass environment variables during the Docker build process. For non-sensitive values, you can use build arguments (--build-arg
). For example, to set a NEXT_PUBLIC_APP_URL
, you’d include the following in your Dockerfile:
ARG NEXT_PUBLIC_APP_URL=https://default-value
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
Then, when building, you’d supply the actual value:
docker build --build-arg NEXT_PUBLIC_APP_URL=https://example.com -t my-app .
For sensitive values such as API keys, database URLs, or authentication tokens use build secrets. This approach avoids storing secrets in the Docker image or command history. You can utilize a similar ARG
setup as before but provide the secret value during the build process through a mechanism like Docker build secrets.
Layer Caching and cache-to
Mode
When using multi-stage Dockerfiles and pushing to a remote registry, Docker will typically only push the final layer of the build. If you then use this final layer as a cacheFrom
source in a subsequent build, it will trigger a full rebuild regardless, as Docker cannot effectively utilize layer caching in this configuration.
To fully utilize layer caching in remote registries, you need to include a cache-to
target with mode=max
option in your build process. This ensures all layers are saved during a push, and are then fully used during subsequent cacheFrom
operations. The specifics of how to set up cache-to
depend on your build tool and registry and are beyond the scope of this article.
Building the Docker Image
Now, that we understand each step of dockerfile, lets proceed to run it
Navigate to where the docker file resides, using the terminal, we’re going to build your image using below command:
docker build -t my-nextjs-app .
Where
my-nextjs-app
this will be the tag name (as named on build layer name) for your project- The
.
, directs docker to use this path as build context with our dockerfile there After running above it might take few mintues - based off if cache present to complete.
Running Your Application
Now for the most important part, we will now execute our freshly built docker file, by executing following command
docker run -p 3000:3000 my-nextjs-app
Where
-p
declares the host port you are calling docker machine at. And second number the container running, thereforehost-machine:container
is relation for forwardingmy-nextjs-app
: Targetting build container, by using layer tag named in earlier step when buiding.
Navigate to your machine’s local host port :3000
, for instance using a local browser or a test website
This should show application. If successful, congrats, you completed hosting a NextJs applicaiton using docker in your local machine. Next Step? deploying onto remote server… that another article :D
Important Notes and Considerations
-
.dockerignore: Crucial file which controls how to not load unwanted directories in build context of containers, add
/node_modules
here/out
,/.next
directory - as they might cause issues during the docker layer cache or unnecessary re-builds. -
Security: Always strive to be using specific node and docker base version so avoid unpredictable dependency conflict/cve issues, ensure a dedicated non-root user,
nextjs
is an easy common convention to follow -
Environment variables: Should set your application-related env in production env using this same command
This Dockerfile provides a reliable foundation for your Next.js app deployment. As your project grows more complex, you can adjust settings. I have also provided a template that handles most common pitfalls for a container hosting service. But do feel free to modify it for particular circumstances and customising your build for the best outcomes and maintainance that fit into your engineering requirements. Feel free to give follow-up if you get any problems, but with that - that is how you would correctly Dockerize a modern Nextjs App, and using a powerful method of using Docker multi-stage system
Hope this long explanation with technical details is understandable to someone who has some working knowledge, with what could of been just ‘a copy-pastable code-block’. Its important not only use provided code and get ‘the outcome’ without fully understanding its mechanisms, you also get good practices instilled on how you would think and maintain your services as good as professional engineer in long run. Cheers, hopefully that helped clarify!