The Problem: “Bloated” Next.js Docker Images
If you’ve ever deployed Next.js to a VPS the traditional way, you’ve probably been stunned to see an image weighing over a GB. The issue usually lies in including the entire node_modules (both dev and prod) in the runtime environment. The result? Every push/pull to the Registry takes 5-7 minutes, consuming bandwidth and slowing down the entire CI/CD pipeline.
Why is a simple web app as heavy as desktop software? The answer lies in the “excess baggage”. Since version 12.2, Next.js supports Standalone Mode — a feature that filters out only what’s absolutely necessary for the app to function. By applying this method, I reduced my image size from 1.2GB to a mere 120MB.
Standalone Mode: How does it work?
Normally, the next start command requires the entire dependency directory. However, a production app doesn’t actually need the packages used for building or testing.
When standalone is enabled, Next.js uses @vercel/nft to intelligently analyze the source code. It automatically picks the necessary files and bundles them into the .next/standalone folder. This folder includes a trimmed version of node_modules, sufficient for the app to stand on its own without the original directory.
This is the key to implementing Multi-stage builds. We build the app in one stage, then copy only this compact folder to the final stage (runner). The final image will be extremely lean.
Step-by-Step Image Optimization Guide
Step 1: Configure Next.js
First, activate this feature in your next.config.js file:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig
Run npm run build, and you’ll see the .next/standalone folder appear. This is the “heart” of the application that we’ll put into Docker.
Step 2: Use .dockerignore
Don’t skip this file if you don’t want Docker wasting time scanning useless items. A proper .dockerignore file will significantly speed up the build process.
node_modules
.next
.git
.env*
README.md
Step 3: Optimized Dockerfile (Multi-stage)
Below is a 3-stage Dockerfile structure: deps, builder, and runner. This separation maximizes Docker’s layer caching.
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f package-lock.json ]; then npm ci; \
elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Stage 3: Runner - Production Environment
FROM node:20-alpine 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
# Set permissions
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Copy output from standalone mode
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
CMD ["node", "server.js"]
Why is Stage 3 so effective?
- Using
node:20-alpinekeeps the base image as light as possible. - Running
node server.jsdirectly instead of through npm reduces resource overhead. - We must manually copy
staticandpublicbecause standalone mode does not automatically bundle these static files.
Step 4: Verify the Results
Build and check your image size:
docker build -t nextjs-app .
docker images
You’ll see a massive difference. If your image was 1.2GB before, it should now be around 120MB-150MB. My actual deployment time on GitHub Actions dropped from 4 minutes to just 45 seconds.
Hard-Earned Real-World Tips
1. Environment Variables: Standalone mode hardcodes environment variables at build time. For flexibility, use process.env in your code and pass values via the -e flag when running docker run.
2. Quick configuration debug tip: When a Docker configuration fails, reading raw JSON logs in the terminal is annoying. I usually quickly copy those logs into the formatter at toolcraft.app/en/tools/developer/json-formatter to inspect the config — it’s much faster than struggling to install VS Code extensions. It helps me immediately see where things are misaligned between dev and prod environments.
3. Don’t forget the Sharp library: If your app uses next/image, install sharp in the builder stage. Without it, Next.js image optimization will be slow and CPU-intensive on the server.
Conclusion
Optimizing Docker isn’t just about saving a few GBs of disk space. It makes the entire process from build to deploy smoother and more professional. With Standalone Mode, you no longer have to worry about the server freezing due to lack of memory when pulling a heavy image.
I hope this technique helps you shorten your deployment times so you can focus on more important tasks. If you run into any configuration errors, feel free to leave a comment below!

