Docker for Frontend Developers

13. March, 2026 12 min read Develop

Containerizing the Frontend

Frontend development has grown far beyond editing HTML files and refreshing a browser. A modern Next.js application depends on databases, caches, background workers, and environment variables that differ between teammates' machines. Docker eliminates the "works on my machine" problem by packaging your entire application stack into portable, reproducible containers.

Docker isn’t just for backend engineers anymore. With full-stack frameworks like Next.js blurring the line between frontend and backend, understanding containers has become essential for frontend developers. In this post, we’ll set up Docker for a Next.js project — from development with hot reload to optimized production builds — and explore the tools and patterns that make containerized frontend development practical.

Docker Fundamentals

Before diving into Next.js specifics, let’s establish the core concepts. Docker works with four building blocks:

  • Image: A read-only template containing your application code, runtime, libraries, and dependencies. Think of it as a snapshot — a blueprint for running your application.
  • Container: A running instance of an image. Containers are isolated, ephemeral processes. You can run multiple containers from the same image simultaneously.
  • Dockerfile: A text file with instructions to build an image. Each instruction creates a cacheable layer, which Docker reuses when nothing has changed.
  • Docker Compose: A tool for defining and running multi-container applications through a compose.yml file. Perfect for local development with databases and services.

For frontend projects, you’ll typically base images on node:22-alpine (~135 MB) rather than the full node:22 image (~1.1 GB). Alpine Linux is a minimal distribution that includes just enough to run Node.js, keeping your images small and your builds fast.

Dockerizing a Next.js Application

Standalone Output

The first step is configuring Next.js for containerized deployment. Enable the standalone output mode in your Next.js config:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'standalone',
};

export default nextConfig;

The standalone option uses @vercel/nft to trace only the files needed at runtime, producing a self-contained .next/standalone folder with a minimal server.js. This eliminates the need to install node_modules in the production image — the standalone output includes only the dependencies your application actually imports.

The public/ and .next/static/ folders must be copied manually since they’re typically CDN-served and aren’t included in the trace. We’ll handle that in the Dockerfile.

Production Dockerfile

A multi-stage Dockerfile separates concerns — install dependencies, build the application, and create a minimal runtime image:

# 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"]

Let’s break down the key decisions:

  • libc6-compat is needed on Alpine for some native Node.js packages like sharp (used by next/image for image optimization).
  • npm ci (not npm install) ensures reproducible installs from the lockfile. It’s faster and removes node_modules before installing, preventing ghost dependencies.
  • HOSTNAME="0.0.0.0" is required so the server listens on all network interfaces inside the container. Without this, the server binds to localhost and is unreachable from outside.
  • Non-root user (nextjs) runs the process for security. Never run production containers as root.
  • Three stages means the final image contains only the standalone server, static assets, and public files — no source code, no node_modules, no build tools.

This pattern typically reduces image size from over 1 GB to under 200 MB.

Development Dockerfile

For development, you need a simpler setup that supports hot reload:

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"]

The development image doesn’t need multi-stage builds or standalone output. It installs all dependencies (including devDependencies) and runs the dev server directly.

Docker Compose for Local Development

The real power of Docker for frontend development is Docker Compose. With a single file, you can define your entire local stack — the Next.js app, a PostgreSQL database, Redis for caching, and any other services your application needs.

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:

Several things worth noting:

  • Service names as hostnames: Within the Compose network, services communicate by name. The db service is reachable at db:5432, redis at redis:6379. No need for localhost.
  • Health checks with depends_on: The condition: service_healthy syntax ensures the app container doesn’t start until PostgreSQL and Redis are actually ready to accept connections — not just running.
  • Named volumes (postgres-data, redis-data) persist data across container restarts. Without them, you’d lose your database every time you stop the containers.
  • target: dev references the dev stage in the Dockerfile, so Docker builds only what’s needed for development.

New developers joining the project run a single command:

docker compose up

No installing PostgreSQL, no configuring Redis, no matching Node.js versions. The entire stack starts in seconds with the exact same configuration everyone else uses.

Hot Reload in Docker

Getting hot module replacement (HMR) working inside Docker requires some attention. The challenge is that filesystem events from bind mounts may not propagate correctly — especially on macOS, where Docker runs inside a lightweight VM.

Volume Mounts

Three volumes are critical for the development workflow:

volumes:
  - .:/app              # Sync source code into container
  - /app/node_modules   # Prevent host overwriting container's node_modules
  - /app/.next          # Prevent host overwriting container's build cache

The first mount syncs your source code into the container so changes are reflected immediately. The second and third are anonymous volumes that prevent the host’s node_modules and .next directories from overwriting the container’s versions. This is important because dependencies may include platform-specific binaries (like sharp compiled for Linux) that differ from your macOS host.

File Watching

Next.js uses Watchpack for file watching. Inside Docker, native filesystem events may not work reliably through bind mounts. Setting WATCHPACK_POLLING=true enables polling-based file watching:

environment:
  - WATCHPACK_POLLING=true

Polling is slightly slower than native filesystem events, but it works reliably across all Docker setups. The latency difference is typically under 500ms — barely noticeable during development.

Docker Compose Watch

Since Docker Compose v2.22, there’s a built-in alternative to bind mounts called Compose Watch. It provides intelligent file synchronization with three action types:

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

The three actions serve different purposes:

  • sync: File changes are mirrored into the container instantly. Ideal for source code where Next.js handles the HMR.
  • rebuild: Triggers a full image rebuild. Use this for dependency changes like package.json or package-lock.json.
  • sync+restart: Syncs the file then restarts the container. Perfect for configuration files like next.config.ts that require a server restart.

Start the watch mode with:

docker compose watch

Compose Watch is often faster than bind mounts on macOS because it uses optimized file sync rather than the VM’s shared filesystem.

Multi-Stage Builds Deep Dive

Multi-stage builds are the single most impactful optimization for Docker images. Each FROM instruction starts a new stage, and only the final stage becomes the output image. Everything else — build tools, dev dependencies, source code — is discarded.

The three-stage pattern for Next.js serves distinct purposes:

Stage Purpose What It Contains
deps Install dependencies node_modules only
builder Compile the application Built .next output
runner Run in production Standalone server + static assets

To put the image size reduction in perspective:

Base Image Size
node:22 (Debian) ~1.1 GB
node:22-slim ~200 MB
node:22-alpine ~135 MB

With standalone output + Alpine + multi-stage builds, a typical Next.js production image lands between 150-200 MB. That’s a 5-7x reduction from the naive approach of copying everything into a Debian-based image.

CI/CD with GitHub Actions

Docker and CI/CD are natural companions. The same Dockerfile that builds your production image locally works identically in GitHub Actions:

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 key to fast CI builds is layer caching. The cache-from: type=gha and cache-to: type=gha,mode=max options use GitHub’s native cache API to store and restore Docker build layers. With caching enabled, rebuilds that only change source code skip the dependency installation step entirely — cutting build times from minutes to seconds.

Docker Buildx extends the standard build command with features like multi-platform builds (producing images for both linux/amd64 and linux/arm64), remote cache backends, and parallel stage execution. It’s the recommended builder for CI/CD workflows.

For build-time environment variables like public API URLs, pass them as build arguments:

- 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 }}

Best Practices

Layer Caching

Order your Dockerfile instructions from least to most frequently changed. Docker caches each layer and reuses it when nothing above it has changed:

# 1. Base image (rarely changes)
FROM node:22-alpine
WORKDIR /app

# 2. Dependencies (changes when package.json changes)
COPY package.json package-lock.json ./
RUN npm ci

# 3. Source code (changes frequently)
COPY . .
RUN npm run build

If you place COPY . . before npm ci, every source code change invalidates the dependency cache, forcing a full reinstall on every build.

.dockerignore

A .dockerignore file controls what gets sent to the Docker daemon as build context. Without it, Docker sends your entire project directory — including node_modules, .git, .env files, and everything else:

node_modules
.next
.git
.env*
*.md
coverage
.vscode
.DS_Store
docker-compose*.yml

This reduces build context size, speeds up COPY . ., and prevents secrets from being accidentally embedded in your image.

Security

Security practices for Docker images should be non-negotiable:

  1. Run as non-root: Always create and switch to a dedicated user in the final stage. Container escapes as root give attackers host-level access.
  2. Pin base image versions: Use node:22.6.0-alpine3.21 for reproducibility, not just node:22-alpine which floats to the latest patch.
  3. Never embed secrets: Use runtime environment variables for API keys and database credentials. Never COPY .env into an image — the .dockerignore should prevent this.
  4. Scan for vulnerabilities: Run docker scout cves <image> or integrate Trivy in your CI pipeline to catch known CVEs in your base image and dependencies.
  5. Disable telemetry: Set NEXT_TELEMETRY_DISABLED=1 to prevent Next.js from sending telemetry data from your containers.

Docker Desktop Alternatives

Docker Desktop is the default choice, but it’s not the only option — and it requires a paid license for commercial use in companies with more than 250 employees or $10 million in revenue.

  • OrbStack: The fastest option on macOS. Starts 10x faster than Docker Desktop, uses under 1 GB of memory, and provides native macOS integration with a clean UI. Free for personal use.
  • Colima: A terminal-based, open-source alternative with minimal resource usage (~400 MB RAM idle). Fully Docker-compatible and great for developers who prefer CLI tools.
  • Podman: Daemonless and rootless by default, making it inherently more secure. 100% Docker CLI compatible — you can alias docker to podman and most commands work unchanged.

All three work with Docker Compose and the standard Docker CLI, so your Dockerfiles and compose files remain portable regardless of which tool you choose.

When to Dockerize

Docker isn’t always the right choice for frontend development. Here’s when it makes the most sense:

Use Docker when:

  • Your app depends on databases, caches, or other services
  • Team environment consistency matters more than raw speed
  • Your CI/CD pipeline builds Docker images
  • You’re running a full-stack Next.js application with server-side dependencies

Consider native development when:

  • You’re building a pure frontend SPA with no backend services
  • You’re a solo developer on a consistent setup
  • Hot reload speed is your top priority
  • You’re in early prototyping and want minimal overhead

The sweet spot for most teams is using Docker Compose for services (databases, Redis, message queues) while running the Next.js dev server natively. This gives you the best of both worlds — consistent infrastructure without the file-watching complexity.

Conclusion

Docker transforms frontend development from a “check the README and hope for the best” onboarding experience into a single docker compose up command that works identically for everyone. Multi-stage builds ensure your production images are small and secure. Compose Watch makes the development experience almost as smooth as running natively. And CI/CD integration means the same Dockerfile that runs on your laptop builds your production deployments.

The frontend has become full-stack, and full-stack needs infrastructure. Docker provides that infrastructure in a way that’s portable, reproducible, and version-controlled alongside your code.

‘Till next time!