Docker for web apps without the overkill
A practical guide to dockerizing a Next.js or Node app: one tidy multi-stage Dockerfile, a sane compose file for local dev, and the traps that bloat images.

Most Docker tutorials for web apps are either three lines that fall over in production, or a forty-file Kubernetes saga you do not need. The truth for a typical Next.js or Node app sits in the boring middle: one good Dockerfile, one compose file for local dev, and a handful of habits that keep your images small and your builds fast. This is that middle.
Do you even need Docker
Honest answer first: a lot of web apps ship fine without it. If you deploy a Next.js app to Vercel or a Node API to Render, the platform builds and runs it for you, and adding a Dockerfile just gives you one more thing to maintain.
Docker earns its keep when you have moving parts that need to agree on versions. A Node service plus Postgres plus Redis, and three developers on three different laptops. Or a self-hosted deploy where you control the box, the sort of cloud and DevOps work where containers genuinely pay off. Or a CI pipeline that needs the same environment every time. If your app is "one runtime, one managed database, deploy to a PaaS," you can probably skip this and come back when it hurts.
For everything else, the goal is a single image that runs the same on your laptop, in CI, and on the server. Let us build one.
A multi-stage Dockerfile that stays small
The single biggest mistake is shipping your whole build toolchain in the final image. You do not need node_modules with dev dependencies, the TypeScript compiler, or your source .ts files running in production. Multi-stage builds fix this: you build in one stage and copy only the output into a clean final stage.
Here is a Dockerfile for a Next.js app using standalone output. The same shape works for a plain Node API with minor changes.
# syntax=docker/dockerfile:1
# 1. Install dependencies only when lockfile changes
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 2. Build the app
FROM node:22-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# 3. Final runtime image: tiny, no build tools
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Run as a non-root user
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs nextjs
# Next.js standalone output bundles only what it needs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]For that to work, set output: "standalone" in your next.config.js. It tells Next.js to trace exactly which files the server needs and emit them into .next/standalone, so you copy a few megabytes instead of a few hundred.
The three stages each pull their weight. deps exists so the dependency install only re-runs when package-lock.json changes, not every time you touch a source file. builder compiles. runner starts from a clean base and receives only the artifacts. Your final image carries no dev dependencies and no source.
Why slim and not alpine
You will see node:22-alpine everywhere because it is smaller. It is also built on musl libc instead of glibc, which occasionally breaks native modules (things like sharp, bcrypt, or some Prisma binaries) in ways that cost you an afternoon. node:22-slim is Debian-based, a bit larger, and far less likely to surprise you. Start with slim. Switch to alpine later only if image size genuinely matters and your dependencies are happy on it.
A .dockerignore is not optional
Without one, COPY . . drags your local node_modules, your .next cache, your .git history, and your .env into the build context. That makes builds slow and can leak secrets into image layers.
node_modules
.next
.git
.env
.env.*
npm-debug.log
Dockerfile
.dockerignore
README.mdWrite this before your first build, not after you notice the image is 1.2 GB.
Compose for local development
The Dockerfile above is for production. Local development wants something different: live reloading, your real database, and no rebuild every time you save a file. That is what compose.yaml is for.
services:
app:
build:
context: .
target: deps
command: npm run dev
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
DATABASE_URL: postgres://app:app@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:Two details matter here. The target: deps line reuses the dependency stage from your Dockerfile, so dev and prod install from the same lockfile. And the pair of volumes (.:/app plus /app/node_modules) mounts your source for live editing while keeping the container's own node_modules. Without that second anonymous volume, your host's node_modules would shadow the container's, and anything built for the container architecture breaks.
The healthcheck plus condition: service_healthy means your app waits for Postgres to actually accept connections, not just for the container to start. This kills the classic "connection refused on first boot" race.
Run docker compose up, and you have your app and a real Postgres talking to each other. Your data survives restarts because it lives in the pgdata named volume.
Migrations and seed data
Keep schema changes out of the Dockerfile. Run them as a step, either a one-off command or your app's migrate-on-boot logic.
-- migrations/001_init.sql
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);Apply it with docker compose exec db psql -U app -d app -f /migrations/001_init.sql after mounting the folder, or let your migration tool (Prisma, Drizzle, node-pg-migrate) handle it. The point is that the database image stays generic and your schema stays in version control.
The habits that keep it boring
A few things will save you repeated pain.
Pin your base image to a specific Node major (node:22-slim), not node:latest. Latest moves under you and breaks reproducibility.
Order your Dockerfile from least to most frequently changed. Copy package.json and install before copying source. Docker caches layers top to bottom and throws away everything after the first change, so a tweak to a component should not reinstall every dependency.
Never bake secrets into the image. Environment variables passed at runtime stay out of the layers; a COPY .env lives in the image forever and travels with it. Use runtime env vars or your platform's secret store.
Add a HEALTHCHECK or a /healthz route so your orchestrator knows when the app is actually ready, not merely running.
The takeaway
You do not need a platform team to run Docker well for a web app. One multi-stage Dockerfile that ships only build output, a .dockerignore so your context stays clean, and a compose file with a healthchecked database covers the vast majority of real projects. Start there, keep it small, and add complexity only when a specific problem forces your hand.
If you want a second set of eyes on a Dockerfile that has quietly grown to a gigabyte, that is the kind of cleanup we enjoy.

