Skip to content

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

Published: at 01:42 AM

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:

  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

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

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

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

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"]

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

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

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

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!


Previous Post
Unlock AI Magic – Completely Free with Gemini 2.0 Flash & Google AI Studio
Next Post
Google API to Fetch Favicons for any domain