Skip to content
Docker for Web Developers: Containerizing Your Application the Right Way
Web Development10 min read

Docker for Web Developers: Containerizing Your Application the Right Way

Scult Team
10 min read

Docker's real value isn't the buzzword — it's ending "works on my machine" forever. Here's how to containerize a web app properly, and where teams get it wrong.

"It works on my machine" is the most expensive sentence in software development. A developer runs Node 20 locally, the staging server runs Node 18, production runs whatever the last person to touch the deploy script installed two years ago — and somewhere in those gaps, a dependency resolves differently, a file path behaves differently, or a system library is missing entirely. Docker's actual value proposition isn't that it's a trendy piece of infrastructure to have on a resume; it's that it packages an application together with its exact runtime, dependencies, and system libraries into one portable unit that behaves identically on a laptop, a CI runner, and a production server. That consistency is what eliminates an entire category of deployment bugs, not the container technology for its own sake.

What a container actually is

A container is not a lightweight virtual machine, even though it's often described that way. A VM virtualizes hardware and runs a full guest operating system on top of it; a container shares the host machine's kernel and isolates just the process, filesystem, and network namespace around your application. That's why containers start in milliseconds where VMs take minutes, and why a container image is typically tens of megabytes where a VM image is gigabytes. The tradeoff is that containers on Linux are genuinely native processes with isolation, while Docker on Mac and Windows runs a lightweight Linux VM under the hood to provide that same kernel — worth knowing when you're debugging performance differences between a Mac laptop and a Linux production server.

An image is the immutable, built artifact — your application code, runtime, and dependencies baked into layers. A container is a running instance of that image. You build an image once and can run any number of containers from it, each isolated from the others, which is exactly the property that makes horizontal scaling and reproducible environments straightforward.

Writing a Dockerfile that isn't wasteful

A Dockerfile is the recipe for building an image, and most first attempts make the same three mistakes: bloated images, slow rebuilds, and running as root.

Layer ordering determines rebuild speed. Docker caches each instruction in a Dockerfile as a layer, and reuses cached layers if nothing above them changed. Copying the entire application source before installing dependencies means every code change — even a one-line CSS fix — invalidates the dependency-install layer and forces a full npm install on every build. The fix is copying only the dependency manifest first:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

Now npm ci only re-runs when package.json or the lockfile actually changes, and everyday code changes rebuild in seconds instead of minutes.

Multi-stage builds keep production images small. A build step needs the full toolchain — TypeScript compiler, bundler, dev dependencies — none of which needs to exist in the image that actually runs in production. Multi-stage builds let you build in one stage and copy only the compiled output into a clean, minimal final stage:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

This routinely cuts image size by 60-80% compared to a naive single-stage build, which matters directly for deploy speed and registry storage costs, and indirectly for attack surface — fewer packages in the final image means fewer things that can have a vulnerability.

Base image choice matters more than people expect. node:20 (the default Debian-based image) is roughly 4x the size of node:20-alpine, which uses the minimal Alpine Linux base. Alpine is usually the right default for production unless a specific native dependency needs glibc compatibility that musl-based Alpine doesn't provide — a real but uncommon issue, mostly with certain native binary npm packages.

Never run the container process as root. The node:20-alpine image ships a non-root node user precisely so you don't have to create one — adding USER node before the CMD line means that if the application is ever compromised through a code vulnerability, the attacker's process doesn't have root privileges inside the container. It's a one-line change with a real security benefit.

docker-compose for local development

Real applications aren't single containers — there's the app, a database, maybe Redis for caching or a queue for background jobs. docker-compose.yml describes that whole stack declaratively so a new developer can clone the repo and run one command instead of following a setup document with fifteen manual steps:

services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/appdb
    depends_on:
      - db
  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_PASSWORD=pass
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:

docker compose up starts the whole stack, networked together, with the database's data persisted in a named volume across restarts. This is where the "works on my machine" problem actually dies — every developer, and CI, runs against the identical Postgres version with the identical configuration, instead of whatever database version happens to be installed locally.

Environment variables and secrets

Configuration that differs between environments — database URLs, API keys, feature flags — belongs in environment variables injected at runtime, never baked into the image. An image should be built once and promoted through environments (dev, staging, production) unchanged; if you're rebuilding the image per environment to bake in different config, you've lost the core guarantee that what you tested is what you're shipping. .env files are fine for local development but should never be copied into the image or committed to version control — a .dockerignore file (working like .gitignore) should explicitly exclude .env, node_modules, and .git from the build context so they can't leak into a layer by accident.

Secrets specifically — database passwords, API keys, signing certificates — need a step up from plain environment variables in production: a secrets manager (cloud-provider-native or a tool like HashiCorp Vault) injected at container start, rather than plaintext values sitting in a compose file or a CI configuration that gets logged somewhere.

Health checks and graceful shutdown

Two details separate a container that merely runs from one that's actually production-ready. A HEALTHCHECK instruction tells the orchestrator (Docker itself, or Kubernetes, or a cloud platform's container service) how to verify the app inside the container is actually serving traffic, not just that the process hasn't crashed:

HEALTHCHECK --interval=30s --timeout=3s CMD wget -q --spider http://localhost:3000/health || exit 1

Without it, a container that's technically running but deadlocked or stuck in an infinite loop keeps receiving traffic indefinitely. And graceful shutdown — handling SIGTERM to finish in-flight requests before the process exits — matters because orchestrators send SIGTERM before killing a container during a deploy or scale-down event; an app that ignores it drops requests mid-flight on every single deployment, which shows up as a small but real spike of failed requests every time you ship.

Networking and volumes: the two things that confuse people first

Containers on the same docker-compose network can reach each other by service name rather than an IP address — in the example above, the web service connects to db:5432, not to localhost:5432 or a hardcoded IP, because Compose sets up an internal DNS that resolves service names automatically. This trips up developers used to running everything on localhost locally; inside a container, localhost refers to the container itself, not the host machine or a sibling container, and forgetting that is the single most common "why can't my app reach the database" debugging session for anyone new to Docker.

Volumes are the other concept worth being precise about, because containers are meant to be disposable and their writable filesystem layer disappears when the container is removed — anything written inside a container that isn't in a volume is gone the moment that container is torn down. A named volume (like pgdata in the compose file above) persists data outside the container's lifecycle, which is why the database's actual data survives docker compose down and a subsequent docker compose up, while anything the application itself wrote to its own filesystem during that run does not. Bind mounts — mapping a local folder directly into the container — serve a different purpose in development: they let source code changes on your machine show up inside the running container instantly, without rebuilding the image, which is what makes hot-reloading workflows practical in a containerized dev environment.

Debugging a running container

New to Docker, the instinct when something behaves unexpectedly inside a container is to add more console.log or print statements and rebuild — which works, but is slow. docker exec -it <container> sh (or bash, if the image includes it) drops you into a live shell inside the running container, letting you poke around the actual filesystem, check environment variables, or run the application's own CLI tools exactly as they exist in that environment — often faster than reproducing the issue by rebuilding. docker logs <container> -f streams a container's stdout/stderr in real time, which is usually the first stop for diagnosing a crash loop, and docker inspect surfaces the full configuration Docker actually applied to a running container (its environment variables, mounted volumes, network settings) when something doesn't match what you expect from the Dockerfile or compose file alone.

Vulnerability scanning and image hygiene

An image is only as secure as its base layer and the packages it pulls in, and both drift over time even if your own application code never changes — a base image tagged node:20-alpine today is not the identical image it was six months ago, because the underlying OS packages receive security patches on their own schedule. Scanning tools (Docker Scout, Trivy, and similar) check an image's layers against known vulnerability databases and flag outdated packages before they ship, and running that scan as part of a CI pipeline — failing a build if a critical vulnerability is found in a dependency — catches this class of issue before it reaches production rather than during an incident response.

Pinning base image versions to a specific digest rather than a floating tag like latest is worth doing for production builds specifically because it makes builds reproducible — latest can silently point to a different, potentially breaking image tomorrow than it does today, which is the opposite of the consistency Docker is meant to provide in the first place.

When Docker earns its complexity

Docker is not free — it adds a layer of tooling, a new failure mode ("it works outside Docker but not inside"), and a learning curve for anyone unfamiliar with containers. For a static site or a single small app deployed to a platform that handles the runtime for you (many modern hosting platforms build directly from source without a Dockerfile at all), Docker may be pure overhead. It earns its keep once you have more than one service to coordinate, need identical environments across a team and CI, or are deploying to any orchestrator — Kubernetes, ECS, Cloud Run — that expects a container image as the unit of deployment in the first place.

We containerize projects for clients when the deployment target genuinely calls for it — multi-service applications, teams larger than one or two developers, or infrastructure that needs to scale horizontally — and skip it when a simpler managed deployment gets the same reliability with less operational surface area. The right call depends on the specific app and team, which is exactly the kind of architecture decision worth making deliberately at the start of a project rather than defaulting into either direction.

Want results like this?

Keep reading