<?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>DEV Community: Mahmoud Mokaddem</title>
    <description>The latest articles on DEV Community by Mahmoud Mokaddem (@mahmoudmkdm).</description>
    <link>https://dev.to/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>DEV Community: Mahmoud Mokaddem</title>
      <link>https://dev.to/mahmoudmkdm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mahmoudmkdm"/>
    <language>en</language>
    <item>
      <title>docker-compose for Next.js + NestJS local dev</title>
      <dc:creator>Mahmoud Mokaddem</dc:creator>
      <pubDate>Sat, 09 May 2026 09:21:00 +0000</pubDate>
      <link>https://dev.to/mahmoudmkdm/docker-compose-for-nextjs-nestjs-local-dev-4me5</link>
      <guid>https://dev.to/mahmoudmkdm/docker-compose-for-nextjs-nestjs-local-dev-4me5</guid>
      <description>&lt;p&gt;Most docker-compose tutorials for Next.js stop at "here's a service block, here's how to mount your code." Then you start it on macOS, type a character in your editor, and hot reload doesn't fire. You add a Postgres service, your NestJS API tries to connect before the database is ready, and the container crashes on first boot. You stop the stack, your &lt;code&gt;node_modules&lt;/code&gt; is mysteriously empty on the host. None of these are mysteries — they're all known issues with known fixes. Here's the &lt;code&gt;docker-compose.dev.yml&lt;/code&gt; I actually use.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This compose file is part of 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;. Follow-up to &lt;a href="https://mahmoud-mokaddem.com/posts/dockerizing-nextjs-for-production" rel="noopener noreferrer"&gt;Dockerizing Next.js for production&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The compose file, 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 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;web&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="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./web&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile.dev&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./web:/app&lt;/span&gt;           &lt;span class="c1"&gt;# bind-mount source&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/app/node_modules&lt;/span&gt;    &lt;span class="c1"&gt;# anonymous vol shadows host node_modules&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/app/.next&lt;/span&gt;           &lt;span class="c1"&gt;# anonymous vol preserves build cache&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;WATCHPACK_POLLING&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
      &lt;span class="na"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:4000&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_started&lt;/span&gt;

  &lt;span class="na"&gt;api&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="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./api&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile.dev&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;4000:4000'&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./api:/app&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/app/node_modules&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://dev:dev@db:5432/app&lt;/span&gt;
      &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;development&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&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;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run start:dev&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;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;5432:5432'&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;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&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;dev&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;app&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="s"&gt;db_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD-SHELL'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dev&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&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;Three services: &lt;code&gt;web&lt;/code&gt; (Next.js), &lt;code&gt;api&lt;/code&gt; (NestJS), &lt;code&gt;db&lt;/code&gt; (Postgres). Each decision in this file exists to work around a specific failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a separate &lt;code&gt;.dev.yml&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://mahmoud-mokaddem.com/posts/dockerizing-nextjs-for-production" rel="noopener noreferrer"&gt;production Dockerfile from the previous post&lt;/a&gt; builds a multi-stage image: it installs deps, compiles the app, then copies only the slim standalone output into a clean final image. No source code mounted, no dev dependencies, no hot reload. That's exactly correct for production and completely wrong for local work.&lt;/p&gt;

&lt;p&gt;Local dev needs almost the opposite: source code mounted so changes take effect without a rebuild, dev dependencies installed, &lt;code&gt;next dev&lt;/code&gt; and &lt;code&gt;nest start --watch&lt;/code&gt; running, and fast incremental compilation. A &lt;code&gt;Dockerfile.dev&lt;/code&gt; handles this — it installs deps and starts the dev server; it never runs &lt;code&gt;npm run build&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two-file approach gives you a clean mental model: &lt;code&gt;docker-compose.yml&lt;/code&gt; for production-like local runs (useful for final QA and catching prod-only bugs), &lt;code&gt;docker-compose.dev.yml&lt;/code&gt; for daily development. Run the dev stack with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.dev.yml up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth aliasing that flag away — I'll cover it in the workflow section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service: &lt;code&gt;web&lt;/code&gt; (Next.js)
&lt;/h2&gt;

&lt;p&gt;The dev Dockerfile for Next.js is intentionally minimal. It installs dependencies and starts the dev server; nothing more.&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;# web/Dockerfile.dev&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20-alpine&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="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["npm", "run", "dev"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No build stage, no standalone output, no multi-stage. The source arrives via the bind mount at runtime, not at build time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The volume pattern that keeps hot reload working
&lt;/h3&gt;

&lt;p&gt;This is the most important thing to get right in the whole file. The volume block for &lt;code&gt;web&lt;/code&gt; looks like this:&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./web:/app&lt;/span&gt;           &lt;span class="c1"&gt;# bind-mount source&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/app/node_modules&lt;/span&gt;    &lt;span class="c1"&gt;# anonymous vol shadows host node_modules&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/app/.next&lt;/span&gt;           &lt;span class="c1"&gt;# anonymous vol preserves build cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line is a bind mount: your &lt;code&gt;./web&lt;/code&gt; directory on the host is mounted into &lt;code&gt;/app&lt;/code&gt; inside the container. Every file you edit on the host is immediately visible inside the container — that's how hot reload works.&lt;/p&gt;

&lt;p&gt;The problem: the bind mount overwrites &lt;em&gt;everything&lt;/em&gt; at &lt;code&gt;/app&lt;/code&gt;, including &lt;code&gt;node_modules&lt;/code&gt; and &lt;code&gt;.next&lt;/code&gt;. Your host's &lt;code&gt;node_modules&lt;/code&gt; was either compiled on macOS (wrong architecture for Linux), or it doesn't exist at all if you didn't run &lt;code&gt;npm install&lt;/code&gt; locally. Either way, the container's freshly-installed &lt;code&gt;node_modules&lt;/code&gt; gets stomped.&lt;/p&gt;

&lt;p&gt;The fix is two anonymous volumes. An anonymous volume at &lt;code&gt;/app/node_modules&lt;/code&gt; tells Docker to manage that path separately — it won't be overwritten by the bind mount above it. The container's &lt;code&gt;node_modules&lt;/code&gt;, installed during &lt;code&gt;docker compose build&lt;/code&gt; and stored in a Docker-managed volume, stays intact. Same logic for &lt;code&gt;/app/.next&lt;/code&gt;: preserving the Next.js build cache across restarts.&lt;/p&gt;

&lt;p&gt;Without these two lines, you get a "cannot find module" error every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  WATCHPACK_POLLING
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;WATCHPACK_POLLING: 'true'&lt;/code&gt; is required on macOS and Windows because Docker Desktop's filesystem bridge doesn't reliably deliver native file-system events to the container. Next.js uses Watchpack for its file watcher; polling mode makes it check for changes on a timer instead of waiting for an event that may never arrive.&lt;/p&gt;

&lt;p&gt;Linux users running Docker Engine natively can usually leave this off — events propagate without polling. The trade-off is CPU: polling mode runs a constant low-level scan instead of sleeping between changes. Enable it when you need it; remove it when you're on Linux.&lt;/p&gt;

&lt;h3&gt;
  
  
  NEXT_PUBLIC_API_URL
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;NEXT_PUBLIC_API_URL: http://localhost:4000&lt;/code&gt; — this one looks wrong at first glance. The &lt;code&gt;api&lt;/code&gt; service is reachable at &lt;code&gt;http://api:4000&lt;/code&gt; inside the Docker network. So why &lt;code&gt;localhost&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variables are baked into the client-side JavaScript bundle, and that bundle runs in your browser — not inside Docker. Your browser doesn't know what &lt;code&gt;api&lt;/code&gt; means as a hostname; it only sees &lt;code&gt;localhost&lt;/code&gt;. So for any variable that the browser-side code uses, you need the host-mapped port, not the docker-network hostname.&lt;/p&gt;

&lt;p&gt;Server-side Next.js code (API routes, &lt;code&gt;getServerSideProps&lt;/code&gt;, Server Components) runs inside the container and &lt;em&gt;can&lt;/em&gt; use docker hostnames. So if you have a purely server-side database URL, &lt;code&gt;db:5432&lt;/code&gt; is fine. The rule: if it touches the browser, use the host port; if it's server-only, use the docker-network hostname.&lt;/p&gt;

&lt;h3&gt;
  
  
  depends_on
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;depends_on: api: condition: service_started&lt;/code&gt; ensures the api container has at least started before Next.js tries to boot. This is a loose dependency — "started" means the container process launched, not that the API is ready to accept requests. For local dev, that's usually fine; Next.js doesn't make API calls at startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service: &lt;code&gt;api&lt;/code&gt; (NestJS)
&lt;/h2&gt;

&lt;p&gt;The NestJS dev Dockerfile follows the same pattern:&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;# api/Dockerfile.dev&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20-alpine&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="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 4000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["npm", "run", "start:dev"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;start:dev&lt;/code&gt; in your &lt;code&gt;package.json&lt;/code&gt; runs &lt;code&gt;nest start --watch&lt;/code&gt;. NestJS recompiles TypeScript on each save and restarts the process — the same hot-reload loop as &lt;code&gt;next dev&lt;/code&gt;, just with TypeScript involved.&lt;/p&gt;

&lt;p&gt;The volumes are identical to the &lt;code&gt;web&lt;/code&gt; service: bind-mount source, anonymous volume for &lt;code&gt;node_modules&lt;/code&gt;. Same reasoning applies.&lt;/p&gt;

&lt;h3&gt;
  
  
  DATABASE_URL and docker-network hostnames
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;DATABASE_URL: postgres://dev:dev@db:5432/app&lt;/code&gt; — the hostname here is &lt;code&gt;db&lt;/code&gt;, the docker-compose service name. Inside the Docker network, services find each other by service name. This is server-side code only, so the docker hostname works fine.&lt;/p&gt;

&lt;p&gt;The mistake I see most often: using &lt;code&gt;localhost&lt;/code&gt; here. Inside the &lt;code&gt;api&lt;/code&gt; container, &lt;code&gt;localhost&lt;/code&gt; is the container itself, not the host machine and not the &lt;code&gt;db&lt;/code&gt; container. The connection will be refused. Use the service name.&lt;/p&gt;

&lt;h3&gt;
  
  
  depends_on with service_healthy
&lt;/h3&gt;

&lt;p&gt;The api service uses a stricter dependency than web:&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;depends_on&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;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;service_healthy&lt;/code&gt; waits for the &lt;code&gt;db&lt;/code&gt; service's healthcheck to pass before starting the api container. This is what prevents the connection-refused race condition.&lt;/p&gt;

&lt;p&gt;Without it: Docker starts &lt;code&gt;api&lt;/code&gt; shortly after &lt;code&gt;db&lt;/code&gt;. Postgres takes 3–5 seconds to initialize on first boot (creating the user, setting up the database). Your API tries to connect during those seconds, gets refused, and crashes. If it doesn't auto-restart, you have to &lt;code&gt;docker compose restart api&lt;/code&gt; manually every time you wipe the database volume.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;service_healthy&lt;/code&gt;: Docker holds the &lt;code&gt;api&lt;/code&gt; container until Postgres says it's ready. The healthcheck (defined on the &lt;code&gt;db&lt;/code&gt; service) handles the timing.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;command: npm run start:dev&lt;/code&gt; is set at the compose level, not in the Dockerfile. This is intentional — it lets you override the default command without rebuilding the image. If you want to run a migration before the dev server starts, you can override &lt;code&gt;command&lt;/code&gt; here without touching the Dockerfile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service: &lt;code&gt;db&lt;/code&gt; (Postgres)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;postgres:16-alpine&lt;/code&gt; — small image, fast pull, stable. Alpine here is straightforward: Postgres ships its own binaries without the glibc complications that affect some Node packages.&lt;/p&gt;

&lt;p&gt;The three environment variables (&lt;code&gt;POSTGRES_USER&lt;/code&gt;, &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;, &lt;code&gt;POSTGRES_DB&lt;/code&gt;) auto-create the user and database on first container start. &lt;code&gt;dev/dev/app&lt;/code&gt; is fine for local. Don't waste time on secure local credentials — that's what environment files for production are for.&lt;/p&gt;

&lt;p&gt;The named volume &lt;code&gt;db_data&lt;/code&gt; persists your data across &lt;code&gt;docker compose down&lt;/code&gt;. When you want a clean slate — after a schema change you can't migrate forward from, or at the start of a testing session — use &lt;code&gt;docker compose -f docker-compose.dev.yml down -v&lt;/code&gt;. The &lt;code&gt;-v&lt;/code&gt; flag removes named volumes too. Without it, your data survives restarts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ports: ['5432:5432']&lt;/code&gt; exposes Postgres to your host machine. This is optional from the stack's perspective — the &lt;code&gt;api&lt;/code&gt; container connects over the Docker network and doesn't need the host port. The reason to keep it: direct access from GUI clients like TablePlus, Postico, or DBeaver, and the ability to run &lt;code&gt;psql postgres://dev:dev@localhost:5432/app&lt;/code&gt; from your terminal. If you don't use those, remove the line.&lt;/p&gt;

&lt;h3&gt;
  
  
  The healthcheck
&lt;/h3&gt;

&lt;p&gt;The healthcheck is what makes the whole dependency chain work:&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;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&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;CMD-SHELL'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dev&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pg_isready&lt;/code&gt; is a Postgres CLI utility that returns exit code 0 when the server is accepting connections on the specified user and database. Docker's healthcheck system runs this command every 5 seconds, marks the container healthy when it passes, and marks it unhealthy after 5 consecutive failures.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;api&lt;/code&gt; service's &lt;code&gt;depends_on: db: condition: service_healthy&lt;/code&gt; watches this status. As soon as &lt;code&gt;pg_isready&lt;/code&gt; returns 0, Docker releases the api container to start. Without this healthcheck, &lt;code&gt;service_healthy&lt;/code&gt; would block forever because Docker wouldn't know what "healthy" means for the &lt;code&gt;db&lt;/code&gt; service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional services
&lt;/h2&gt;

&lt;p&gt;Add only what your app actually uses. Two common additions:&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;redis&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;redis:7-alpine&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;6379:6379'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;mail&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;maildev/maildev&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;1080:1080'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1025:1025'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# web UI : smtp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis is useful for session storage, cache, or BullMQ queues. The &lt;code&gt;redis:7-alpine&lt;/code&gt; image is tiny (~30 MB) and needs no configuration for local dev.&lt;/p&gt;

&lt;p&gt;Maildev (or Mailhog — either works) catches outgoing emails without delivering them. Your app sends to &lt;code&gt;mail:1025&lt;/code&gt; over the Docker network using SMTP; you read the messages at &lt;code&gt;localhost:1080&lt;/code&gt; in your browser. Invaluable when working on email-sending flows like password reset or welcome sequences — no risk of accidentally spamming real inboxes.&lt;/p&gt;

&lt;p&gt;Keep optional services optional. Every container you add to the stack consumes RAM and adds a few seconds to &lt;code&gt;docker compose up&lt;/code&gt;. If Redis isn't needed for what you're working on today, comment it out.&lt;/p&gt;

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

&lt;p&gt;The five bugs that account for most local-dev compose failures with this stack:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Hot reload doesn't fire on Mac or Windows.&lt;/strong&gt; Docker Desktop's filesystem bridge doesn't reliably propagate native file-system events into containers. Fix: set &lt;code&gt;WATCHPACK_POLLING: 'true'&lt;/code&gt; in the &lt;code&gt;web&lt;/code&gt; service environment. For NestJS with nodemon or Chokidar, add &lt;code&gt;CHOKIDAR_USEPOLLING: 'true'&lt;/code&gt; to the &lt;code&gt;api&lt;/code&gt; service environment. Polling costs CPU; only enable it where you need it. Linux users with Docker Engine native usually don't need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;node_modules&lt;/code&gt; empty or missing on the host after &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/strong&gt; The bind mount &lt;code&gt;./api:/app&lt;/code&gt; overwrites the container's &lt;code&gt;/app&lt;/code&gt; with your host directory, including its (empty or macOS-compiled) &lt;code&gt;node_modules&lt;/code&gt;. Fix: add the anonymous volume &lt;code&gt;/app/node_modules&lt;/code&gt; below the bind mount. The anonymous volume takes precedence for that specific path; the container's installed modules survive. Same fix for &lt;code&gt;/app/.next&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "Cannot find module" for native dependencies like &lt;code&gt;bcrypt&lt;/code&gt; or &lt;code&gt;sharp&lt;/code&gt;.&lt;/strong&gt; Native packages are compiled for the platform where &lt;code&gt;npm install&lt;/code&gt; runs. If you installed them on macOS, the binaries are macOS binaries and won't load in the Linux container. Fix: the anonymous volume pattern above means &lt;code&gt;npm ci&lt;/code&gt; runs inside the container at build time, producing Linux binaries. If you still see the error, run &lt;code&gt;docker compose -f docker-compose.dev.yml exec api npm rebuild bcrypt&lt;/code&gt; once after first up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. API container crashes with "connection refused" on first start.&lt;/strong&gt; The API started before Postgres finished initializing. Fix: add the healthcheck block to the &lt;code&gt;db&lt;/code&gt; service (exactly as shown above) and set &lt;code&gt;depends_on: db: condition: service_healthy&lt;/code&gt; on the &lt;code&gt;api&lt;/code&gt; service. The API container won't start until &lt;code&gt;pg_isready&lt;/code&gt; returns 0. Without this, you'll restart the api container by hand every time you wipe the database volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Port already in use.&lt;/strong&gt; &lt;code&gt;Error starting userland proxy: listen tcp4 0.0.0.0:3000: bind: address already in use&lt;/code&gt; means something else on your machine owns that port. Fix: &lt;code&gt;lsof -i :3000&lt;/code&gt; to find the offending process. Kill it, or change the host-side port mapping in the compose file (&lt;code&gt;'3001:3000'&lt;/code&gt; runs the container on 3000 but exposes it on 3001 on your host). Ports 4000 and 5432 are equally likely culprits.&lt;/p&gt;

&lt;p&gt;Each one of these is a 5-minute fix once you know the name. Combined, they're 90% of the local-dev compose failures I've debugged on real teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The everyday workflow
&lt;/h2&gt;

&lt;p&gt;How I actually run this stack day to day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start everything in the background&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.dev.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Tail logs for the service you're working on&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.dev.yml logs &lt;span class="nt"&gt;-f&lt;/span&gt; api

&lt;span class="c"&gt;# Run a one-off command inside a container&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.dev.yml &lt;span class="nb"&gt;exec &lt;/span&gt;api npm run db:migrate

&lt;span class="c"&gt;# After changing package.json — rebuild just that service&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.dev.yml build api
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.dev.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; api

&lt;span class="c"&gt;# Wipe the database for a clean start&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.dev.yml down &lt;span class="nt"&gt;-v&lt;/span&gt;

&lt;span class="c"&gt;# Connect to Postgres from your host terminal&lt;/span&gt;
psql postgres://dev:dev@localhost:5432/app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-f docker-compose.dev.yml&lt;/code&gt; flag is the main ergonomic cost. Worth adding an alias to your shell rc file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# In ~/.zshrc or ~/.bashrc&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;dc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'docker compose -f docker-compose.dev.yml'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the commands above become &lt;code&gt;dc up -d&lt;/code&gt;, &lt;code&gt;dc logs -f api&lt;/code&gt;, &lt;code&gt;dc exec api npm run db:migrate&lt;/code&gt;. Saves a significant amount of typing over a few months of daily use.&lt;/p&gt;

&lt;p&gt;One more: if you're touching a section of the app that touches the database schema, the workflow is &lt;code&gt;dc down -v&lt;/code&gt; (wipe), &lt;code&gt;dc up -d&lt;/code&gt; (fresh start), &lt;code&gt;dc exec api npm run db:migrate&lt;/code&gt; (apply migrations), then develop. Faster than trying to hand-migrate a dirty local database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full setup is in the starter
&lt;/h2&gt;

&lt;p&gt;This compose file is part of a production-grade Next.js + NestJS starter I'm building. The starter ships everything you'd need to go from &lt;code&gt;git clone&lt;/code&gt; to a running local stack in under five minutes, then deploy to production without rearchitecting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The production &lt;code&gt;Dockerfile&lt;/code&gt; (from &lt;a href="https://mahmoud-mokaddem.com/posts/dockerizing-nextjs-for-production" rel="noopener noreferrer"&gt;the previous post&lt;/a&gt;) and a &lt;code&gt;docker-compose.yml&lt;/code&gt; for prod-like local runs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-compose.dev.yml&lt;/code&gt; (this post, pre-wired)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Dockerfile.dev&lt;/code&gt; for both &lt;code&gt;web&lt;/code&gt; and &lt;code&gt;api&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A working migration flow with Prisma — &lt;code&gt;db:migrate&lt;/code&gt; and &lt;code&gt;db:seed&lt;/code&gt; commands that run inside the container&lt;/li&gt;
&lt;li&gt;Healthchecks pre-wired on every service that needs one&lt;/li&gt;
&lt;li&gt;GitHub Actions pipeline: lint → test → build image → push to registry → deploy&lt;/li&gt;
&lt;li&gt;JWT auth with refresh tokens — the hardest part of most apps to get right from scratch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It'll be free, and email subscribers get it the day it ships. Subscribe at &lt;a href="https://mahmoud-mokaddem.com" rel="noopener noreferrer"&gt;mahmoud-mokaddem.com&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;If your local dev compose has a gotcha I didn't cover — a platform-specific failure, a dependency manager edge case, a Docker Desktop version that changed something — find me on &lt;a href="https://x.com/mahmoudmkdm" rel="noopener noreferrer"&gt;X&lt;/a&gt; or &lt;a href="https://linkedin.com/in/mahmoud-mokaddem" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;. I'll add it to the post.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>nestjs</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <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://dev.to/mahmoudmkdm/dockerizing-nextjs-for-production-18b0</link>
      <guid>https://dev.to/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>
