All writing
3 min read

Docker for Frontend Developers: What Finally Clicked

I put off containers for a year because every tutorial started with a Go service. Here is the mental model that made it obvious, plus the multi-stage build I actually use.

  • Docker
  • DevOps
  • Deployment
Docker for Frontend Developers: What Finally Clicked

For a long time Docker felt like something that belonged to the backend team. My deploy was a git push, then a build on a platform, and it worked. Containers looked like extra machinery for a problem I did not have.

What changed my mind was a specific annoyance: a staging environment where the Node version, the nginx config and the build flags had each drifted slightly from production, and nobody could say exactly how. Containers are, at their core, a fix for exactly that class of problem.

The mental model that made it click#

Three sentences:

  1. An image is a filesystem plus a start command. That is all. Not a virtual machine, not a process manager — a frozen directory and a thing to run.
  2. A container is that image, running. It has its own view of the filesystem and its own network namespace, and it shares the host kernel.
  3. A layer is a cached instruction. COPY package.json before COPY . is not a style choice; it is what makes rebuilds take four seconds instead of ninety.

Once I stopped thinking of it as "a VM thing" and started thinking of it as "a reproducible directory", the whole tool made sense.

The frontend-specific twist#

A frontend app has two lives and they are easy to confuse:

  • Build time — Node, the toolchain, TypeScript, bundling. Heavy, slow changing, only needed once.
  • Run time — static files and something to serve them. Tiny, needs to be secure, never needs Node.

Most bad frontend Dockerfiles merge those two into one image, which is how you end up shipping four hundred megabytes of node_modules to production.

The multi-stage build I actually use#

dockerfile
# ---- build -------------------------------------------------------------
FROM node:22-alpine AS build
WORKDIR /app

# Dependencies first: this layer only invalidates when the lockfile changes.
COPY package.json package-lock.json ./
RUN npm ci

# Then the source.
COPY . .
RUN npm run build

# ---- runtime -----------------------------------------------------------
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK CMD wget -qO- http://localhost/healthz || exit 1

The build stage ends up around 1.1GB. The image that ships is about 26MB, and none of the toolchain is in it.

The two lines that matter most#

npm ci instead of npm install. It installs exactly the lockfile, fails if the lockfile and package.json disagree, and never mutates the lockfile. In a build context that determinism is the entire point.

The COPY --from=build of only /app/dist. Everything the build stage needed is discarded. If you take one thing from this post, take this: do not COPY . into your runtime image.

Nginx for a single-page app#

The config that a SPA actually needs is short, and the SPA fallback is the part people forget — without it, a refresh on /blog/my-post is a 404.

nginx
server {
  listen 80;
  root /usr/share/nginx/html;

  # Hashed assets never change: cache them hard.
  location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
  }

  # The HTML shell must never be cached, or users get stale bundles.
  location = /index.html {
    add_header Cache-Control "no-cache";
  }

  location / {
    try_files $uri $uri/ /index.html;
  }
}

That cache split is worth more than any bundler optimisation I have made: hashed filenames cached for a year, and one tiny uncached HTML file that points at them.

What I would tell my past self#

  • Start with a single service and docker build. Do not begin with Compose.
  • Put node_modules and dist in .dockerignore. Copying them into the build context is the usual reason a build is slow.
  • Read your image size. If it is over 100MB for a static site, you have made the merge mistake.
  • Add the healthcheck early; every orchestrator you use later will want it.

The point is not containers. The point is that the thing you tested is the thing you shipped.

I am still early in this. But the first time a colleague ran docker compose up on a clean machine and got the exact stack I had locally, the year I spent avoiding this looked like a bad trade.