<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Mahmoud Mokaddem</title>
    <description>The latest articles on Forem by Mahmoud Mokaddem (@mahmoudmkdm).</description>
    <link>https://forem.com/mahmoudmkdm</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3909585%2F39a29977-b4f3-4382-88a2-577755b8e95a.png</url>
      <title>Forem: Mahmoud Mokaddem</title>
      <link>https://forem.com/mahmoudmkdm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/mahmoudmkdm"/>
    <language>en</language>
    <item>
      <title>Dockerizing Next.js for production</title>
      <dc:creator>Mahmoud Mokaddem</dc:creator>
      <pubDate>Sat, 02 May 2026 21:19:38 +0000</pubDate>
      <link>https://forem.com/mahmoudmkdm/dockerizing-nextjs-for-production-18b0</link>
      <guid>https://forem.com/mahmoudmkdm/dockerizing-nextjs-for-production-18b0</guid>
      <description>&lt;p&gt;Most Dockerfiles for Next.js you'll find online ship a 1.2 GB image, leak environment variables at build time, and rebuild every layer on a one-line change. They work on the demo. They don't work in production.&lt;/p&gt;

&lt;p&gt;This is the Dockerfile I actually run. Multi-stage, ~150 MB final image, build-time and runtime env vars cleanly separated, layer caching that survives a &lt;code&gt;package.json&lt;/code&gt; change. I'll walk through every line, explain why each stage exists, and call out the four gotchas that account for most "it worked locally" production failures.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full setup (Dockerfile plus docker-compose, GitHub Actions deploy pipeline, auth, testing) is in a production-grade Next.js + NestJS starter I'm building. Free for email subscribers — subscribe at &lt;a href="https://mahmoud-mokaddem.com" rel="noopener noreferrer"&gt;mahmoud-mokaddem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Dockerfile, up front
&lt;/h2&gt;

&lt;p&gt;If you're in a hurry, copy this and skip to Common gotchas. The rest of the post explains every line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 1: deps&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci

&lt;span class="c"&gt;# Stage 2: builder&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NEXT_TELEMETRY_DISABLED=1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="c"&gt;# Stage 3: runner&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runner&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production NEXT_TELEMETRY_DISABLED=1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--gid&lt;/span&gt; 1001 nodejs &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--uid&lt;/span&gt; 1001 nextjs
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/public ./public&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=nextjs:nodejs /app/.next/standalone ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; nextjs&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PORT=3000 HOSTNAME=0.0.0.0&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three stages: &lt;code&gt;deps&lt;/code&gt;, &lt;code&gt;builder&lt;/code&gt;, &lt;code&gt;runner&lt;/code&gt;. The first two do work; only the third ships.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why multi-stage
&lt;/h2&gt;

&lt;p&gt;A naïve Dockerfile copies your source, installs dependencies, builds, and runs — all in one stage. The image you ship to production carries everything that helped you build it: the full Node toolchain, npm's cache, dev dependencies, build artifacts you don't need at runtime, your &lt;code&gt;.git&lt;/code&gt; directory if you weren't careful with &lt;code&gt;.dockerignore&lt;/code&gt;. Easily 1+ GB.&lt;/p&gt;

&lt;p&gt;Multi-stage builds let you do all that work in a "fat" intermediate image, then copy only the artifacts that need to ship into a clean final image. Each &lt;code&gt;FROM&lt;/code&gt; starts a fresh image; &lt;code&gt;COPY --from=&lt;/code&gt; reaches back into a previous stage to grab specific files.&lt;/p&gt;

&lt;p&gt;For Next.js, the practical result: &lt;strong&gt;~150 MB final image vs ~1.2 GB single-stage.&lt;/strong&gt; Why this matters in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Faster registry pulls on small VPSes or autoscaling platforms. Pulling 1.2 GB on a 100 Mbps link takes ~96 seconds; pulling 150 MB takes ~12.&lt;/li&gt;
&lt;li&gt;Faster cold starts on platforms like Fly.io and Cloud Run, where containers start on demand.&lt;/li&gt;
&lt;li&gt;Lower registry cost when you push every commit.&lt;/li&gt;
&lt;li&gt;Smaller security surface — fewer packages carrying potential CVEs in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mental shortcut: &lt;em&gt;do the messy work in a fat intermediate image, ship only the artifacts that need to run.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 1 — Dependencies
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;node:20-alpine&lt;/code&gt; is a deliberate trade-off. Alpine Linux is ~50 MB; &lt;code&gt;node:20-slim&lt;/code&gt; is ~340 MB; &lt;code&gt;node:20&lt;/code&gt; (Debian-based) is ~1 GB. Alpine wins on size and is fine for almost every Next.js app.&lt;/p&gt;

&lt;p&gt;The catch: Alpine uses musl libc instead of glibc. Some npm packages with prebuilt native binaries (historically &lt;code&gt;canvas&lt;/code&gt;, &lt;code&gt;sharp&lt;/code&gt;, certain database drivers) ship glibc binaries that don't load on Alpine. If you hit a binary-compatibility error during &lt;code&gt;npm ci&lt;/code&gt;, the fix is usually to switch this stage's base to &lt;code&gt;node:20-slim&lt;/code&gt; and accept the larger image. For a vanilla Next.js app, you'll never see this.&lt;/p&gt;

&lt;p&gt;Notice we copy only &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;package-lock.json&lt;/code&gt;, not the source. This is &lt;strong&gt;layer-caching discipline&lt;/strong&gt;. Docker caches each layer; if a layer's input hasn't changed, it reuses the cached output. By isolating the dependency install to the lockfile, we get full cache reuse on every commit that doesn't touch dependencies — which is most of them. If we copied the source first, every code change would re-run &lt;code&gt;npm ci&lt;/code&gt; from scratch.&lt;/p&gt;

&lt;p&gt;About &lt;code&gt;npm ci&lt;/code&gt; vs &lt;code&gt;npm install&lt;/code&gt;: &lt;code&gt;ci&lt;/code&gt; is deterministic, installs exactly what's in the lockfile, fails if the lockfile is out of date, and is faster. Always &lt;code&gt;ci&lt;/code&gt; in Docker. (Yarn: &lt;code&gt;yarn install --frozen-lockfile&lt;/code&gt;. pnpm: &lt;code&gt;pnpm install --frozen-lockfile&lt;/code&gt;.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 2 — Builder
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NEXT_TELEMETRY_DISABLED=1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fresh stage, fresh Alpine, &lt;code&gt;node_modules&lt;/code&gt; pulled forward from stage 1. &lt;code&gt;COPY . .&lt;/code&gt; brings in the source tree (filtered by &lt;code&gt;.dockerignore&lt;/code&gt;, covered below).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The standalone output mode is the one Next.js config flag you actually need.&lt;/strong&gt; Add it to your &lt;code&gt;next.config.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this flag, &lt;code&gt;npm run build&lt;/code&gt; produces the standard Next.js build output and your final image has to ship the entire &lt;code&gt;node_modules&lt;/code&gt; tree (~300 MB+). With it, Next.js traces every dependency actually used by your built routes and emits a self-contained &lt;code&gt;server.js&lt;/code&gt; plus only those traced packages in &lt;code&gt;.next/standalone/node_modules&lt;/code&gt;, typically ~15 MB. &lt;strong&gt;That one flag is the biggest size win in this Dockerfile.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm run build&lt;/code&gt; produces three things we care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.next/standalone/&lt;/code&gt; — the self-contained server plus traced &lt;code&gt;node_modules&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.next/static/&lt;/code&gt; — built static assets (JS bundles, CSS) for &lt;code&gt;_next/static/*&lt;/code&gt; routes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;public/&lt;/code&gt; — static files you put in the &lt;code&gt;public&lt;/code&gt; folder, which Next.js doesn't bundle into standalone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stage 3 copies these three things and nothing else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build-time vs runtime env vars
&lt;/h3&gt;

&lt;p&gt;This is the most common Next.js + Docker bug I see, so it gets its own callout.&lt;/p&gt;

&lt;p&gt;Variables prefixed &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; are &lt;strong&gt;baked into the client-side JavaScript bundle at build time&lt;/strong&gt;. They are not read at runtime from the container's environment. If you set &lt;code&gt;NEXT_PUBLIC_API_URL&lt;/code&gt; only at runtime via &lt;code&gt;docker run -e&lt;/code&gt;, your client code will see whatever value it had at build time (usually empty), not what you set at runtime.&lt;/p&gt;

&lt;p&gt;Two ways to handle it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(a) Pass &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; as &lt;code&gt;--build-arg&lt;/code&gt; and rebuild per environment:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; NEXT_PUBLIC_API_URL&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--build-arg&lt;/span&gt; &lt;span class="nv"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; my-app &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;(b) Keep &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; for things that don't change per deploy&lt;/strong&gt; (your domain, public Stripe key, public Sentry DSN), and put environment-specific config behind server-side data fetching where you can read &lt;code&gt;process.env&lt;/code&gt; at runtime.&lt;/p&gt;

&lt;p&gt;I prefer (b). Fewer images, simpler pipeline. Use (a) only when you genuinely need the value baked into the client bundle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 3 — Runner
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runner&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production NEXT_TELEMETRY_DISABLED=1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--gid&lt;/span&gt; 1001 nodejs &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--uid&lt;/span&gt; 1001 nextjs
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/public ./public&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=nextjs:nodejs /app/.next/standalone ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; nextjs&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PORT=3000 HOSTNAME=0.0.0.0&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Final stage. Fresh Alpine, no toolchain, no dev dependencies. This is what ships.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NODE_ENV=production&lt;/code&gt; matters. Next.js skips dev-only logging and telemetry, and many libraries optimize behavior based on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The non-root user&lt;/strong&gt;: &lt;code&gt;addgroup&lt;/code&gt; creates a system group, &lt;code&gt;adduser&lt;/code&gt; creates a user in it, &lt;code&gt;USER nextjs&lt;/code&gt; switches the runtime to that user. Many container platforms (Kubernetes, ECS, Fly with strict modes) refuse to run containers as root by default. Even when they don't, running as root expands the impact of any container-escape CVE. This costs nothing; do it now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The three copies&lt;/strong&gt; are where the standalone output pays off:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;COPY --from=builder /app/public ./public&lt;/code&gt; — &lt;code&gt;public/&lt;/code&gt; is not part of the standalone output. Forget this line and all your favicons, robots.txt, and static images return 404. The first time. Always.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./&lt;/code&gt; — the actual server. &lt;code&gt;--chown&lt;/code&gt; makes the non-root user own the files, otherwise it can't read its own runtime.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static&lt;/code&gt; — also not in standalone. Forgetting this gives you a site with no JS or CSS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;EXPOSE 3000&lt;/code&gt; is documentation, not a port-open. It tells &lt;code&gt;docker run -p&lt;/code&gt; and orchestrators "this app expects to be reachable on 3000."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HOSTNAME=0.0.0.0&lt;/code&gt; is required to accept connections from outside the container. Next.js's standalone server defaults to &lt;code&gt;localhost&lt;/code&gt;, which means your container would only accept traffic from itself.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;CMD ["node", "server.js"]&lt;/code&gt;, &lt;strong&gt;not &lt;code&gt;npm start&lt;/code&gt;&lt;/strong&gt;. &lt;code&gt;npm&lt;/code&gt; wraps the process and intercepts signals, so your container won't gracefully shut down on &lt;code&gt;SIGTERM&lt;/code&gt;. Orchestrator-driven restarts hang for 30+ seconds before the kernel kills it. &lt;code&gt;node server.js&lt;/code&gt; handles signals correctly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The .dockerignore file
&lt;/h2&gt;

&lt;p&gt;This file gets skipped a lot, and it's often the answer to "why is my build context 2 GB?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;node_modules&lt;/span&gt;
.&lt;span class="n"&gt;next&lt;/span&gt;
.&lt;span class="n"&gt;git&lt;/span&gt;
.&lt;span class="n"&gt;env&lt;/span&gt;*
&lt;span class="n"&gt;README&lt;/span&gt;.&lt;span class="n"&gt;md&lt;/span&gt;
*.&lt;span class="n"&gt;log&lt;/span&gt;
&lt;span class="n"&gt;coverage&lt;/span&gt;
.&lt;span class="n"&gt;vscode&lt;/span&gt;
.&lt;span class="n"&gt;idea&lt;/span&gt;
.&lt;span class="n"&gt;DS_Store&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why each entry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;node_modules&lt;/code&gt; — gets reinstalled in the deps stage.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.next&lt;/code&gt; — build artifacts get rebuilt; carrying old ones in confuses Next.js's cache.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.git&lt;/code&gt; — your version history shouldn't ship in the container.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env*&lt;/code&gt; — &lt;strong&gt;never bake secrets into images.&lt;/strong&gt; Pass at runtime.&lt;/li&gt;
&lt;li&gt;Logs, IDE folders, coverage reports — clutter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;.env*&lt;/code&gt; line is a security concern worth dwelling on. If you've ever had an &lt;code&gt;.env.local&lt;/code&gt; sitting in your working directory, &lt;code&gt;.dockerignore&lt;/code&gt; is what keeps it out of the image. An image with &lt;code&gt;.env.production&lt;/code&gt; baked in can be pulled by anyone with read access to your registry. Put real secrets in your runtime environment, not in the image.&lt;/p&gt;




&lt;h2&gt;
  
  
  Image size walkthrough
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Final image&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Naïve single-stage on &lt;code&gt;node:20&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;~1.2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-stage on &lt;code&gt;node:20&lt;/code&gt; (no standalone)&lt;/td&gt;
&lt;td&gt;~600 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-stage on &lt;code&gt;node:20-alpine&lt;/code&gt; (no standalone)&lt;/td&gt;
&lt;td&gt;~400 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-stage on &lt;code&gt;node:20-alpine&lt;/code&gt; + standalone&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~150 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Numbers are approximate; your app's specific dependencies move them ±20%.&lt;/p&gt;

&lt;p&gt;What this saves you: deploy time drops from ~96 seconds to ~12 on a 100 Mbps registry pull. Cold start time on Fly or Cloud Run becomes meaningful at the standalone size. The biggest win is the standalone output flag. The Alpine base is second. Multi-stage is the structural decision that makes both composable.&lt;/p&gt;




&lt;h2&gt;
  
  
  docker-compose for local dev
&lt;/h2&gt;

&lt;p&gt;This Dockerfile builds the production image. For local dev you usually want hot reload, a local Postgres, maybe Redis. A minimal compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3000:3000'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://user:pass@db:5432/myapp&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pass&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;db_data:/var/lib/postgresql/data'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs the production build locally, which is useful for catching prod-only bugs but not for hot reload. For real dev work you want a separate &lt;code&gt;docker-compose.dev.yml&lt;/code&gt; with the source mounted as a volume and &lt;code&gt;next dev&lt;/code&gt; running. That's a full post in itself — coming next in this series.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;p&gt;The four bugs that account for most "it worked locally" production failures with this setup:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Public folder not appearing.&lt;/strong&gt; You forgot &lt;code&gt;COPY --from=builder /app/public ./public&lt;/code&gt;. Symptom: 404s on every static asset. Fix: add the line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; env vars not reaching the client.&lt;/strong&gt; They were set at runtime, not build time. Symptom: client-side code reads &lt;code&gt;undefined&lt;/code&gt; or stale values. Fix: pass via &lt;code&gt;--build-arg&lt;/code&gt; (per the Stage 2 section) or restructure so the value isn't needed in the client bundle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Container exits immediately, no logs.&lt;/strong&gt; You're using &lt;code&gt;npm start&lt;/code&gt; instead of &lt;code&gt;node server.js&lt;/code&gt;. &lt;code&gt;npm&lt;/code&gt; wraps the process and hides what's happening. Fix: &lt;code&gt;CMD ["node", "server.js"]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. OOM during &lt;code&gt;npm run build&lt;/code&gt; on a small VPS.&lt;/strong&gt; Hetzner CX11 / DigitalOcean $4 droplets often can't fit a Next.js build in RAM. Symptom: build fails with &lt;code&gt;JavaScript heap out of memory&lt;/code&gt; or gets SIGKILLed. Fix: build in CI/CD and push the image to your registry, then pull on the VPS; or add a swap file on the VPS.&lt;/p&gt;

&lt;p&gt;Each one has happened to me. Each one looks unrelated to Docker until you find it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to deploy this
&lt;/h2&gt;

&lt;p&gt;The Dockerfile doesn't change; the deploy target does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hetzner VPS + Coolify or Dokploy&lt;/strong&gt; — cheapest, most control. What I'd pick for indie projects. Push the image to GitHub Container Registry; Coolify pulls and runs it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DigitalOcean App Platform&lt;/strong&gt; — push the Dockerfile, get a URL. Good middle ground.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fly.io&lt;/strong&gt; — global edge deploy, generous free tier for hobby work. &lt;code&gt;fly launch&lt;/code&gt; auto-detects Next.js and writes a &lt;code&gt;fly.toml&lt;/code&gt; for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS ECS / Fargate&lt;/strong&gt; — enterprise default. More setup overhead, but the right call if you're already in AWS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these gets its own deploy walkthrough later in this series. The Dockerfile above works on all of them unchanged.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Two follow-ups in this series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;docker-compose for Next.js + NestJS local dev&lt;/strong&gt; — the full dev-mode compose file with hot reload, Postgres, and Redis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How I structure a NestJS project for production&lt;/strong&gt; — the architecture conventions in the starter, with rationale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've shipped this Dockerfile to a deploy target I didn't cover, I'd be curious what platform you picked and what bit you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full setup (Dockerfile, .dockerignore, docker-compose, GitHub Actions deploy pipeline, auth, testing) is in a production-grade Next.js + NestJS starter I'm building. Free for email subscribers — &lt;a href="https://mahmoud-mokaddem.com" rel="noopener noreferrer"&gt;subscribe at mahmoud-mokaddem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>docker</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
