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:20.17.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:20.17.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 makes it into the final image, and that one change routinely takes a Node or Python service from 1-1.5GB down to 150-250MB.
Pin your base image tag. FROM node:20-slim looks harmless, but that tag moves; a rebuild six months from now can pull a different minor version and quietly change behavior on you. node:20.17.0-slim is reproducible; node:20-slim is a moving target.
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, but the npm ci layer above it stays cached. On a CI runner, that's the difference between a 90-second build and a 4-second one.
A .dockerignore keeps your build context honest
Every docker build sends your entire project directory to the Docker daemon as the "build context" before it even looks at your Dockerfile. Without a .dockerignore, that context includes node_modules, .git, .env files with real credentials in them, and your local dist folder, all uploaded on every build.
node_modules
npm-debug.log
dist
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
.dockerignore
README.md
.vscode
coverageWithout it, a .env file with real credentials can get baked 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 history; it's inspectable by anyone with docker history and a registry pull.
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.
version: "3.9"
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:
test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"]
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:Without 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 itself means docker ps and your orchestrator both confirm the app is actually serving traffic, since a process can stay running long after it stops responding:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:3000/healthz || exit 1Second, 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.
Finally, check what you actually shipped. docker images shows you the size, but docker history <image> shows you 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 becomes 400MB 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