After learning how to write a Compose file in Part 6, the next question is how one codebase can run under different rules. Node.js makes the dev/prod contrast obvious, so this article splits one sample app into a production container and a development container.
In this post, assume the production example is a Node server that listens on port 3000, while the development example uses a dev server such as Vite on port 5173. If your project runs the same process in both environments, the host/container port pairs can stay the same too.
How this post flows
- Core rules for designing a production container for a Node app
- Intentional openings in a development container
- A three-point checklist that compares commands, ports, and volumes
- Mini Lab: swap between the two containers
- The one sentence beginners must remember
Reading card
- Estimated time: 18 minutes
- Prereqs: you have run
npm run devand basic Compose commands- After reading: you can operate dev/prod containers from a single codebase.
Production container: ship only the built artifact
Stability is the main virtue in production. Think in terms of two files. The Dockerfile assumes you have a lockfile such as package-lock.json, because npm ci depends on it for reproducible installs.
# Dockerfile
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
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]
The key detail is that the runtime stage installs only production dependencies and starts the built server directly.
# docker-compose.prod.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
target: runner
ports:
- "8080:3000"
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 3s
retries: 3
- The multi-stage image only includes the built
dist/output. npm ci --omit=devinstalls only production dependencies, so the final image stays smaller and more predictable.- Only the necessary port mapping (
8080:3000) stays open. - Policies like
restartandhealthcheckprepare automatic recovery.
Using a Node-based healthcheck avoids the common beginner mistake of calling curl in an Alpine image that does not have it installed by default.
The mantra is simple: the tougher the production container, the better. Prevent live edits or surprise npm install runs; immutability is the safety feature.
Development container: favor quick edits
Development values instant updates, so keep three things deliberately open.
volumes:
node_modules:
services:
app-dev:
image: node:20-alpine
working_dir: /app
command: sh -c "npm ci && npm run dev"
volumes:
- .:/app
- node_modules:/app/node_modules
ports:
- "5173:5173"
environment:
- NODE_ENV=development
profiles: ["dev"]
- Bind mount the source folder and keep dependencies inside the container with a named volume at
/app/node_modules. - Override
commandto run the dev server with hot reloading. - Wrap the service in a
devprofile so defaultdocker compose upruns do not start it.
Here npm ci is still a teaching-friendly shortcut, not the only option. For faster repeated starts, many teams build a separate dev image or install dependencies once and reuse the named volume.
Never bring this container to production. If dependency installation runs during every boot and code mutates live, you cannot reproduce failures. Separate profiles or separate Compose files are mandatory.
Three-point checklist: command · port · volume
| Item | Production container | Development container |
|---|---|---|
| Command | Use the Dockerfile CMD and never override it |
Override with command to run the dev server |
| Ports | Expose the minimum mapping needed (e.g., 8080:3000) |
Expose the dev server port you actually use (e.g., 5173:5173) |
| Volumes | Read-only assets, or none | Source bind mount plus named volume for dependencies |
Scan this table to avoid mixing them up on a live server. If your development container also runs the production server process, keep the same internal port and change only the workflow around it.
Mini Lab: switch between the two containers
- Split the snippets above into
compose.prod.ymlandcompose.dev.ymlfiles. - Run
docker compose -f compose.prod.yml up -d, then confirm withcurl localhost:8080. - Run
docker compose -f compose.dev.yml --profile dev up -dand compare ports withdocker compose ps. - Edit a line of code and refresh the browser to see the dev container update instantly.
- Stop only the dev container via
docker compose -f compose.dev.yml --profile dev down.
Repeat the loop until "a production container does not change unless the image changes" becomes second nature.
One sentence beginners must remember
The summary is short:
- Production containers should not change.
- Development containers must change quickly.
If you can explain that sentence, most dev/prod split decisions fall into place. Part 8 explores how those containers communicate through ports and networks, plus how the host accesses them.
💬 댓글
이 글에 대한 의견을 남겨주세요