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 --from=deps /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 --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /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 up

No 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 cache

The 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=true

Polling 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.ts

sync 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 watch

On 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=max

The 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 build

Move 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*.yml

Faster 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 docker to podman works 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!