Reduce Next.js Docker Image Size and Fix LCP Performance

nextjs docker docker optimization lcp performance next/image core vitals

Two changes solve both problems at once. First, reduce Next.js Docker image size by enabling standalone output mode and using a multi-stage build to drop your image from ~1GB to under 150MB. Second, fix LCP by using next/image with the priority prop on your above-the-fold image and preloading it, which typically brings LCP under 2.5 seconds. These are independent fixes, but they compound: a smaller image deploys faster, and a properly optimized hero image renders faster.

Step 1: Enable Standalone Output

Standalone output mode copies only the files your application needs at runtime, stripping out dev dependencies and unused node_modules. Add this to your next.config.js.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone",
  images: {
    // Allow external image domains if needed.
    remotePatterns: [
      { protocol: "https", hostname: "cdn.example.com" },
    ],
  },
};

module.exports = nextConfig;

Step 2: Multi-Stage Dockerfile

This Dockerfile uses three stages: one to install dependencies, one to build, and a final slim runner stage based on node:20-alpine. The runner stage contains only the standalone output, static assets, and the public folder. It includes no node_modules directory, no source code, and no dev tooling.

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

Why This Cuts Image Size So Dramatically

A naive COPY . . followed by npm run build in a single stage keeps all of node_modules (often 500MB+), the full source tree, and build caches in the final image. Standalone output mode traces require() calls with @vercel/nft and copies only the files your server imports. Combined with the Alpine base image (~50MB vs ~350MB for Debian), you land around 120 to 150MB.

A common gotcha: if you use sharp for image optimization (which Next.js recommends for production), you need to install it in the runner stage because the standalone trace doesn't always bundle native binaries correctly on Alpine. Add this line before the EXPOSE directive in the runner stage.

# Install sharp for Next.js image optimization on Alpine.
RUN apk add --no-cache libc6-compat && \
    npm install --os=linux --cpu=arm64 sharp@0.33.5 && \
    rm -rf /root/.npm

Replace --cpu=arm64 with --cpu=x64 if you deploy on amd64 machines. If you build on a Mac with Apple Silicon but deploy to amd64, set --platform=linux/amd64 on your docker build command to avoid architecture mismatches.

Step 3: Fix Largest Contentful Paint (LCP)

LCP measures how long the largest visible element (usually a hero image or heading) takes to render. The two most common Next.js LCP killers are lazy-loading the hero image (the default behavior of next/image) and not specifying explicit dimensions so that the browser can't allocate space during layout. You fix both like this.

import Image from "next/image";

export default function HeroSection() {
  return (
    <section>
      <Image
        src="/hero-banner.webp"
        alt="Product overview showing the dashboard interface"
        width={1200}
        height={630}
        // Disables lazy loading and adds a preload link tag.
        priority
        // Tells the browser this image will be large.
        sizes="100vw"
        quality={80}
      />
    </section>
  );
}

The priority prop does two things: it sets loading="eager" on the <img> tag, and it injects a <link rel="preload"> into the <head>. This alone typically improves LCP by 500ms to 1.5 seconds because the browser starts fetching the image during HTML parsing instead of waiting for JavaScript hydration.

The sizes prop is equally important. Without it, Next.js defaults to a srcset that might serve an image wider than the viewport, wasting bandwidth. Setting sizes="100vw" for a full-width hero (or sizes="(max-width: 768px) 100vw, 50vw" for a two-column layout) lets the browser pick the smallest adequate file from the generated srcset.

Gotchas and Common Mistakes

Don't mark more than one or two images as priority. Each one generates a preload hint, and too many preloads compete for bandwidth, defeating the purpose. Only the LCP candidate image should get this treatment.

If your LCP element is text rather than an image, the fix is different: preload your web font. Next.js 13+ with next/font handles this automatically, but if you use a custom font loaded through CSS, add a manual preload in your layout.

import localFont from "next/font/local";

// Preloads the font file and applies font-display: swap.
const headingFont = localFont({
  src: "./fonts/heading.woff2",
  display: "swap",
  variable: "--font-heading",
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={headingFont.variable}>
      <body>{children}</body>
    </html>
  );
}

Verifying Your Improvements

After deploying, measure both changes. For Docker image size, compare before and after with docker images. For LCP, use Lighthouse in Chrome DevTools or the PageSpeed Insights API. Target an LCP under 2.5 seconds on mobile with a simulated 4G connection. If you're still above that threshold, check the Lighthouse treemap for large JavaScript bundles that block the main thread, and consider dynamic imports for below-the-fold components.

# Compare image sizes.
docker images | grep nextjs-app
# Expected output:
# nextjs-app  optimized  abc123  120MB
# nextjs-app  naive      def456  1.08GB

# Run Lighthouse from the CLI.
npx lighthouse https://your-app.example.com \
  --only-categories=performance \
  --output=json \
  --output-path=./lcp-report.json

One final pitfall: if you use a CDN or reverse proxy in front of your Next.js server, make sure that it forwards the Accept header. Next.js uses this header to decide whether to serve WebP or AVIF. If the CDN strips it, every user gets the fallback PNG/JPEG, which can double your image payload and undo your LCP gains.

← Back to all articles