Containerizing Your First App with Docker Compose
This covers writing a lean multi-stage Dockerfile and a docker-compose.yml that wire up your app and its database, without the bloated image and broken build cache that a naive Dockerfile produces.
Multi-stage builds: separate what you build from what you run
The container that compiles your app doesn't need to be the same container that runs it. Give the builder stage everything it wants: full node_modules, dev dependencies, build caches. Then copy only the compiled output into a clean runtime image.
# Stage 1: build
FROM node:24.21.0-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:24.21.0-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]Only --from=builder /app/dist and the production dependencies make it into the final image. In my experience that change alone usually takes a Node service from well over 1GB down to a few hundred MB, and most of what's left is the slim base image. This is the skeleton. The complete version, with a health check and a non-root user, is at the end of the post.
Pin your base image. FROM node:24-slim looks harmless, but that tag moves, and a rebuild six months from now can pull a different minor version that behaves differently. node:24.21.0-slim pins the Node version (24 is the current LTS line; 20 is end-of-life). Even an exact tag gets re-pushed for OS security patches, so for fully reproducible builds pin the digest too (node:24.21.0-slim@sha256:...) and let Renovate or Dependabot open the PRs that bump it.
Order your instructions for the cache, not for readability
Docker caches each layer and only invalidates it (and everything after it) when its inputs change. The most common mistake is copying the entire project before installing dependencies. That forces a full npm install (or pip install, or bundle install) on every single source-code edit.
Copy only the dependency manifest first, install, and then copy the rest of the source:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Now editing src/routes/users.ts invalidates the COPY . . layer and everything below it, while the npm ci layer above it stays cached. On a CI runner with a warm cache, the build then usually finishes in seconds instead of reinstalling everything for a minute or more.
A .dockerignore keeps your build context honest
The "build context" is the directory you pass to docker build (usually .). BuildKit, the default builder, only transfers the files your Dockerfile needs and reuses what it already sent, so it doesn't upload everything blindly like the legacy builder did. You still want a .dockerignore. Excluded files are invisible to the build, so a broad COPY . . can't pick up your local node_modules, .git or a .env full of real credentials. It also means COPY . . gets invalidated less often by files that don't affect the image (every commit touches .git). And less data goes to the builder, which you'll notice with remote builders and in CI.
node_modules
npm-debug.log
dist
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
.dockerignore
README.md
.vscode
coverageWithout it, that COPY . . can bake a .env file with real credentials into an image layer. Layers are cached and reused, so deleting the file in a later RUN step doesn't remove it from the image. It's still in the earlier layer, and anyone who can pull the image can extract it.
Wiring it up with Docker Compose
A single container is rarely the whole story. Most apps need a database next to them, at least for local development. Compose lets you describe the whole stack, including a named volume so your Postgres data survives a docker compose down. (You'll still see a top-level version: key in older examples. It's obsolete; current Compose ignores it and prints a warning.)
services:
app:
build:
context: .
target: runtime
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:app@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
healthcheck:
# slim images have no curl; Node 18+ ships fetch, so use node itself
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
db:
image: postgres:16.4-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7.4-alpine
volumes:
pgdata:Bring it up with the Compose v2 plugin, docker compose with a space (the old docker-compose binary is retired):
docker compose up -d --build
docker compose ps # the STATUS column shows (healthy) once /healthz answersWithout condition: service_healthy, depends_on only waits for the container to start, not for Postgres to actually accept connections. Your app will crash-loop on its first few connection attempts against a database that isn't ready yet.
HEALTHCHECK, non-root users, and keeping the image lean
Two habits separate a container that survives production from one that just happens to work on your laptop.
First, a HEALTHCHECK in the Dockerfile lets docker ps and Compose check that the app is serving traffic, since a process can keep running long after it stops responding. The -slim images don't include curl, so a curl -f check would report unhealthy forever. Use the Node runtime that's already there:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]127.0.0.1 rather than localhost avoids a false failure when localhost resolves to IPv6 first and the app only listens on IPv4. Kubernetes ignores Dockerfile HEALTHCHECKs and uses its own probes, so there you'd point a readinessProbe at the same /healthz.
Second, run as a non-root user. The official node images ship a node user for exactly this reason:
USER nodeRunning as root inside a container isn't a theoretical risk. A container escape combined with a root process inside the container hands an attacker root on whatever they escape to. It costs one line to avoid, and every base-image security scanner will flag its absence.
Putting it all together, this is the complete Dockerfile:
# Stage 1: build
FROM node:24.21.0-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:24.21.0-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# Drop root before the app starts; files copied above stay root-owned and read-only to the app
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "dist/server.js"]Finally, check what you shipped. docker images shows the size, and docker history <image> shows which layer put it there. Run it before an image goes near a registry. It's the fastest way to catch a stray apt-get or a forgotten cache directory before it turns into hundreds of MB of dead weight.
The gap between a naive Dockerfile and one built for production comes down to a handful of habits: multi-stage builds, cache-aware instruction ordering, a .dockerignore, a HEALTHCHECK, and a non-root user.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out