Docker for Frontend Developers
13. March, 2026 • 13 min read • Develop
The frontend grew a database
There was a time when handing a frontend project to someone meant sending them a zip file and telling them which version of Node to install. Then the frontend grew a database, a cache, a queue and four environment variables that are different on everybody's laptop, and "works on my machine" stopped being a joke and started being a Tuesday.
This site has shipped in a container since 2020, when I wrote up putting it on Divio Cloud. That Dockerfile was one stage, installed the Gatsby CLI globally and copied everything in one go. It worked. It was also about a gigabyte, and I didn’t think about it again for three years.
What changed isn’t Docker. It’s that a Next.js app is now a server, and servers have neighbours. This post covers containerising one properly: the production image, the local stack, the file-watching mess on macOS, and the case for not bothering.
The four pieces
Four concepts and then we can move on.
An image is a read-only filesystem plus the metadata to start a process in it. A container is one running instance of an image. Nothing more mysterious than a class and an object.
A Dockerfile is the recipe for an image. Every instruction produces a layer, and layers are cached by content, which is the single fact that determines whether your builds take ten seconds or ten minutes.
Compose is the thing that starts several containers together with a network between them, described in a compose.yml. For local development it’s the piece you’ll actually live in.
One choice worth making early: base your images on node:22-alpine rather than plain node:22. Alpine is a minimal Linux distribution built around musl instead of glibc, and it drops the image from roughly 1.1 GB to roughly 150 MB before you’ve added a single file of your own.
Building the image
Standalone output
Before the Dockerfile, tell Next.js to produce a self-contained build:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;With standalone, Next.js runs @vercel/nft over your build to trace which files are genuinely reachable at runtime, then writes them into .next/standalone along with a small server.js. The point is that you no longer need node_modules in the production image. Only the modules your code actually imports come along.
Two directories are left out of the trace deliberately, because they’re normally served from a CDN: public/ and .next/static/. If you forget to copy them the app boots fine and then serves a page with no CSS and no images, which is a confusing five minutes.
The production Dockerfile
Three stages: fetch dependencies, build, then assemble a runtime image that contains neither the dependencies nor the build tools.
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Build the application
FROM node:22-alpine AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Production runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]A few of those lines are load-bearing and not obvious:
libc6-compat is the glibc compatibility shim. Some native packages, sharp in particular (which next/image uses for image optimisation), ship binaries built against glibc and fall over on musl without it. This is the standard Alpine tax.
npm ci rather than npm install. It installs strictly from the lockfile and wipes node_modules first, so the build can’t quietly pick up a floating minor version and produce something that doesn’t match what you tested.
HOSTNAME="0.0.0.0" is the one that catches people. Without it the server binds to loopback inside the container, the port mapping looks right, and nothing answers. I have lost time to this more than once.
The nextjs user exists because a process running as root inside a container is one escape away from root on the host. It costs four lines.
A development image
Development wants the opposite of all that. Keep it in the same Dockerfile as an extra stage so Compose can target it:
FROM node:22-alpine AS dev
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]No standalone output, no pruning, devDependencies included. You want the dev server, not a small image.
The local stack
This is where Docker earns its place for frontend work. One file describes the app, the database and the cache, and they come up together on a network where they can find each other by name:
services:
app:
build:
context: .
dockerfile: Dockerfile
target: dev
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
- REDIS_URL=redis://redis:6379
- WATCHPACK_POLLING=true
volumes:
- .:/app
- /app/node_modules
- /app/.next
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres-data:
redis-data:Notice there’s no version: key at the top. Compose has ignored it for years and now warns about it, so if you’re copying an old file around (mine on this site still carries a version: '2.3' from 2020) you can delete that line.
The DATABASE_URL points at db:5432, not localhost. Inside the Compose network every service is reachable by its service name, and this trips up everyone once, usually while wondering why Prisma can’t connect. If you’re wiring up the database side, I covered that in using Prisma with Next.js.
condition: service_healthy is the part I’d argue is mandatory rather than nice to have. Plain depends_on only waits for the container to start, and Postgres takes a couple of seconds after starting before it accepts connections. Without the health check your app boots into a connection refused error roughly one time in three, which is exactly the kind of intermittent failure that eats an afternoon.
The named volumes keep your data across restarts. Leave them out and every docker compose down wipes the database, which is either a disaster or a feature depending on the day.
Then the whole thing is:
docker compose upNo installing Postgres, no matching Node versions, no page of README instructions that went stale in November.
Hot reload, and why macOS makes it hurt
Getting file changes to reach the dev server inside a container is the part of this that genuinely annoys me. On Linux it mostly just works. On macOS, Docker runs inside a virtual machine, your source lives on the host, and filesystem events have to cross that boundary. They often don’t.
The three volumes
volumes:
- .:/app # source code from the host
- /app/node_modules # keep the container's copy
- /app/.next # keep the container's build cacheThe first line is the useful one. The other two are anonymous volumes that exist purely to stop the host from shadowing directories that must stay Linux-native. Your macOS node_modules contains a sharp built for Darwin on arm64; mount that over the container’s and the app dies on startup with an error about missing bindings.
Polling
Next.js watches files through Watchpack, which prefers native filesystem events. When those don’t arrive, you edit a file and nothing happens at all, with no error to tell you why. The fix is to stop waiting for events and ask instead:
environment:
- WATCHPACK_POLLING=truePolling costs some CPU and adds a small delay, usually a few hundred milliseconds. That’s fine. Silent non-reloading is not fine.
Compose Watch
Since Compose v2.22 there’s a better answer than bind mounts, which is to let Compose push changes in rather than having the container watch for them:
services:
app:
build:
context: .
target: dev
ports:
- "3000:3000"
develop:
watch:
- action: sync
path: ./src
target: /app/src
ignore:
- node_modules/
- action: rebuild
path: package.json
- action: sync+restart
path: next.config.ts
target: /app/next.config.tssync copies changed files into the container and lets Next.js handle the reload. rebuild rebuilds the image, which is what you want when package.json changes. sync+restart copies the file and bounces the container, for config that’s only read at boot.
docker compose watchOn macOS this is noticeably quicker than a bind mount, because it’s a targeted file copy instead of a shared filesystem doing translation on every stat call.
How small it gets
Multi-stage builds are the whole game for image size. Each FROM starts fresh and only the final stage ships, so everything you needed to build the app is thrown away.
| Stage | Purpose | What survives |
|---|---|---|
deps |
Install dependencies | node_modules |
builder |
Compile the app | .next output |
runner |
Serve in production | Standalone server, static and public files |
And the base image choice, for context:
| Base image | Approximate size |
|---|---|
node:22 (Debian) |
1.1 GB |
node:22-slim |
220 MB |
node:22-alpine |
150 MB |
Alpine plus standalone output plus three stages usually lands a Next.js app somewhere around 150 to 200 MB, against well over a gigabyte for the copy-everything approach. Worth doing, though be honest about why: it’s mostly about how fast the image pulls on deploy, not about disk space, which is free.
CI with GitHub Actions
The same Dockerfile builds in CI unchanged, which is most of the appeal:
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxThe two cache lines are what separate a two-minute build from a nine-minute one. Runners are ephemeral, so without them every push reinstalls every dependency from scratch. With type=gha the layers go into GitHub’s cache, and a commit that only touches source code reuses the deps layer.
Build-time values, the NEXT_PUBLIC_* sort that get baked into the client bundle, have to be passed in as build arguments, because they’re needed while the image is being built rather than when it runs:
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
NEXT_PUBLIC_API_URL=${{ vars.API_URL }}Anything secret stays out of build args. They’re visible in the image history.
Things worth getting right
Instruction order
Docker caches layers in sequence, and one invalidated layer invalidates everything below it. So put what rarely changes first:
# 1. Base image (rarely changes)
FROM node:22-alpine
WORKDIR /app
# 2. Dependencies (change when package.json changes)
COPY package.json package-lock.json ./
RUN npm ci
# 3. Source code (changes constantly)
COPY . .
RUN npm run buildMove COPY . . above npm ci and every typo you fix triggers a full dependency reinstall. This is the most common Dockerfile mistake I see, and it’s the one with the largest daily cost.
.dockerignore
Everything in the build context gets shipped to the daemon before the build starts. Without a .dockerignore that includes your node_modules, your entire git history and any .env file lying around:
node_modules
.next
.git
.env*
*.md
coverage
.vscode
.DS_Store
docker-compose*.ymlFaster builds, and, more importantly, secrets that don’t accidentally end up in a layer that someone can docker history their way into later.
Security
Run as a non-root user, as in the Dockerfile above. Pin base images to something specific: node:22.14.0-alpine3.21 rather than node:22-alpine, which quietly moves under you. Keep credentials in runtime environment variables and never in the image. And put a scanner in the pipeline, either docker scout cves <image> or Trivy, because your base image will pick up CVEs whether or not you touched it.
Setting NEXT_TELEMETRY_DISABLED=1 is also worth doing, if only so your CI containers stop phoning home on every build.
Docker Desktop and the alternatives
Docker Desktop is the default and it’s fine, but it needs a paid licence for commercial use once a company passes 250 employees or 10 million dollars in revenue, and on macOS it’s a heavy neighbour.
- OrbStack is what I’d point macOS users at first. It starts in a couple of seconds, sits well under a gigabyte of memory, and behaves like a native app. Free for personal use.
- Colima runs from the terminal with no GUI at all, idles around 400 MB, and is fully open source.
- Podman is daemonless and rootless by default, and CLI-compatible enough that aliasing
dockertopodmanworks for most everyday commands.
All of them run standard Dockerfiles and Compose files, so this isn’t a decision you have to get right first time.
When I’d skip it
Docker is not free, and pretending otherwise is how teams end up with a slow, resented development setup.
Reach for it when your app has neighbours: a database, a cache, a queue, a worker. Reach for it when your CI builds images anyway, so local and production share one definition. Reach for it when new people join often enough that onboarding time is a real cost.
Skip it for a static site or a pure SPA that talks to an API somebody else runs. Skip it when you’re one person on one machine and the setup is five minutes of npm install.
What I actually do most of the time is neither: Postgres and Redis in Compose, the Next.js dev server running natively on the host, pointed at localhost:5432. The services are the part that’s annoying to install and easy to containerise. The dev server is the part that’s trivial to run and painful to containerise. Splitting them that way gets you the consistency without ever thinking about Watchpack again.
For production, of course, everything goes in the image. That’s the one place the container is unambiguously the right answer.
Where this leaves my own setup
The Dockerfile for this site is still a two-stage build that produces static files and hands them to nginx, which is about as simple as it gets and needs none of the standalone machinery above. I’ve been meaning to pin the base image properly for a while now, having just written a whole section telling you to do exactly that 🙃
‘Till next time!