DEV Community

Simplr
Simplr

Posted on

Hosting Your Next.js App with Docker: A Multi-Stage Approach

Original Post at our Blog blog.simplr.sh

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

The goal here isn't just to get it running—we want to do it right. This multi-stage Dockerfile achieves several key things:

  1. Clean separation: Keeps our build process distinct from the final image, leading to a smaller final image size.
  2. Layer caching: Optimizes the Docker build process so subsequent builds are faster.
  3. Security conscious: Running the app under a non-root user for enhanced security.
  4. 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.

  1. The base image: A solid base layer that other stages rely on, we are using a standard Node alpine base image
  2. Dependency install: We Install all of our project's dependancies for build/production
  3. Building stage: Takes dependancies and source code, executes next's build step
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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 layer base - 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
Enter fullscreen mode Exit fullscreen mode
  • 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 through apk 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 installation
  • RUN ...: 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
Enter fullscreen mode Exit fullscreen mode
  • 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 from deps 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 correctly
  • ENV 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"]
Enter fullscreen mode Exit fullscreen mode
  • FROM base AS runner: Our final stage is here. Notice, we’re starting FROM 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 state
  • RUN addgroup --system --gid 1001 nodejs and RUN 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 using root, 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 directory
  • COPY --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 hosting
  • COPY --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 create nextjs rather than default user such as root for enhanced security.
  • EXPOSE 3000: Marks port 3000 available on this machine
  • ENV 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 local 127.0.0.1). By specifying 0.0.0.0 we explicitly target accessable networking
  • CMD ["node", "server.js"]: Starts the app, running in this CMD. Important server is at root, as that folder has node process output tracing in.

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

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

Where

  • -p declares the host port you are calling docker machine at. And second number the container running, therefore host-machine:container is relation for forwarding
  • my-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!

Top comments (0)