Development vs Production Images
Development and production Docker images solve different problems. A development image should make editing, testing, debugging, and reloading fast, while a production image should be small, predictable, secure, and focused on running the application.
Confusing the two leads to slow local workflows or risky deployments. The best pattern is usually one Dockerfile with separate build targets, plus Docker Compose for the local development experience.
Overview: How it works
A Docker image is a read-only stack of filesystem layers plus metadata such as CMD, ENV, USER, and exposed ports. A container is an instance of that image with a thin writable layer and a running process. Development and production images use the same image mechanics, but they choose different files, tools, users, commands, and runtime assumptions.
A development image normally contains convenience tools. It may include source code, test dependencies, a package manager, a watcher such as npm run dev, a debugger port, and enough shell utilities to inspect problems. Local source is often attached with a bind mount, so editing a file on the host immediately changes what the container sees. This makes the container feel like a repeatable development machine.
A production image should contain only what is needed to run the built application. It should not include test frameworks, compilers, source-only files, local editor state, package manager caches, or debug-only tools. It should run as a non-root user when practical, use pinned base image tags such as node:20-alpine, and start the real server process directly.
Multi-stage Dockerfiles make this manageable. Each FROM starts a separate stage. You can name stages such as development, build, and production, then choose one with docker build --target. Docker caches each layer independently, so a good Dockerfile copies dependency manifests before source files. Changing src/server.js should not force Docker to reinstall every dependency unless package.json changed.
The registry stores image manifests, configuration, and layer blobs. When you push a production image, you are publishing the exact final image layers that other machines will pull and run. Development images are usually local or CI-only; production images are the ones you tag, scan, promote, roll back, and deploy.
Syntax
FROM pinned-runtime:version AS base
WORKDIR /app
COPY dependency-files ./
RUN install-dependencies
FROM base AS development
CMD ["dev-command"]
FROM base AS build
COPY source-files ./
RUN build-command
FROM pinned-runtime:version AS production
WORKDIR /app
COPY --from=build /app/runtime-artifacts ./
USER app
EXPOSE container-port
CMD ["start-command"]
| Part | Purpose |
|---|---|
AS development |
Names a target intended for local work, live reload, tests, and debugging. |
AS build |
Contains build tools and source files needed to compile or bundle the application. |
AS production |
Creates the final runtime image with only production dependencies and built output. |
docker build --target NAME |
Builds one named stage instead of the final stage. |
docker compose up |
Starts the local development stack, commonly with bind mounts and dev commands. |
EXPOSE |
Documents a container port only. It does not publish the port to the host; use -p or Compose ports:. |
Examples
Example 1: One Dockerfile with development and production targets
This Node Dockerfile uses pinned images and separates dependency installation, development, build, and production runtime concerns.
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS development
ENV NODE_ENV=development
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
FROM base AS build
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S app && adduser -S -G app app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]
Output:
[+] Building 11.2s (15/15) FINISHED
=> [base 4/4] RUN npm ci
=> [build 2/2] RUN npm run build
=> [production 6/6] COPY --from=build /app/dist ./dist
=> exporting to image
=> naming to docker.io/library/node-web:1.0
The development target keeps dev dependencies and runs the development server. The production target starts fresh, installs only production dependencies, copies only built files from build, and runs as the app user. Both stages share the cached dependency layer from base.
Example 2: Build and run the production image
docker build --target production -t node-web:1.0 .
docker run --rm -p 3000:3000 node-web:1.0
Output:
[+] Building 2.4s (15/15) FINISHED
Server listening on port 3000
The build command exports only the production target as node-web:1.0. The run command publishes host port 3000 to container port 3000. The Dockerfile’s EXPOSE 3000 line is useful metadata, but without -p 3000:3000 the service would not be reachable from the host.
Example 3: Use Compose for the development image
services:
web:
build:
context: .
target: development
image: node-web:dev
ports:
- "5173:5173"
volumes:
- .:/app
- /app/node_modules
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
Output:
docker compose up
[+] Running 1/1
✔ Container node-web-web-1 Started
web-1 | VITE ready in 420 ms
web-1 | Local: http://localhost:5173/
Compose builds the development target, publishes the dev-server port, and bind-mounts the project directory into the container. The anonymous volume at /app/node_modules prevents the host bind mount from hiding the container’s installed dependencies. This pattern is common for Node projects because host and container dependency trees can differ.
Example 4: Keep the build context clean
node_modules
dist
coverage
.git
.env
*.log
Output:
[+] Building 0.8s (8/8) FINISHED
=> transferring context: 32.14kB
This .dockerignore keeps local dependencies, generated output, Git history, environment files, and logs out of the build context. Docker can only COPY files that are sent in the context, so ignoring unwanted files improves speed and reduces the chance that local secrets or artifacts land in an image layer.
How it works step by step
- The Docker client sends the build context to BuildKit after applying
.dockerignore. - BuildKit reads the Dockerfile and resolves the requested target. If you run
docker build --target development, Docker builds only the stages needed for that target. - Docker checks the cache for each instruction. The
COPY package.json package-lock.json ./layer changes only when dependency manifests change, sonpm cican usually be reused during normal source edits. - For the development target, Docker copies source into an image, but Compose often replaces that source at runtime with a bind mount from the host.
- For the production target, Docker runs the build stage, starts a clean runtime stage, and copies only
/app/distfrom the build stage. - When a container starts, Docker mounts the image’s read-only layers, adds a writable layer, applies metadata such as
USER,ENV, andCMD, then starts the configured process.
The important distinction is that a development container is optimized for feedback loops, while a production container is optimized for repeatable deployment. Both can come from the same Dockerfile because targets let you export different stage endpoints.
Common Mistakes
Shipping the development image to production
docker build --target development -t node-web:prod .
This creates an image that likely includes dev dependencies, source files, file watchers, and a development server. It may listen on the wrong port and run with debugging behavior enabled. Build the production target instead:
docker build --target production -t node-web:1.0 .
Using latest for the runtime image
FROM node:latest AS production
WORKDIR /app
COPY . .
CMD ["node", "server.js"]
latest is a moving target. A rebuild tomorrow may pull a different Node version or operating system package set. Pin a specific tag such as node:20-alpine, and consider digest pinning in high-control production pipelines.
Invalidating the dependency cache
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
Here, changing any source file invalidates COPY . ., which forces npm ci to run again. Copy dependency manifests first, install dependencies, then copy the rest of the source.
Baking secrets into either image
FROM node:20-alpine
ENV NPM_TOKEN="<YOUR_NPM_TOKEN>"
RUN npm config set //registry.npmjs.org/:_authToken "$NPM_TOKEN"
This is unsafe even in a temporary build stage. Secrets can remain in layer metadata, cache records, logs, or exported intermediate images. A later RUN rm does not remove data from an earlier layer. Use BuildKit secret mounts, Docker secrets, or your orchestrator’s secret store instead.
Assuming EXPOSE publishes the app
docker run --rm node-web:1.0
This starts the container but publishes no host port. Use docker run -p 3000:3000 node-web:1.0 or Compose ports: to make the service reachable from your machine.
Best Practices
- Use one multi-stage Dockerfile when development and production share dependencies, build steps, or base assumptions.
- Name targets clearly:
development,test,build, andproductionare easier to understand than numbered stages. - Use Docker Compose for local development bind mounts, ports, environment files, and companion services.
- Use pinned base image tags. Avoid
latestfor production because it makes rebuilds less predictable. - Install dependencies before copying the full source tree so Docker can reuse expensive dependency layers.
- Keep production images small by copying built artifacts from a build stage instead of shipping the whole project.
- Run production containers as a non-root user when the application supports it.
- Keep secrets out of
ARG,ENV, copied files, and committed Compose files. - Remember that bind mounts are great for local source code, while named volumes are better for persistent service data.
- Publish ports explicitly with
-por Composeports:;EXPOSEis documentation and image metadata only.
Practice Exercises
- Take an existing Node Dockerfile and split it into
development,build, andproductiontargets. Expected end state:docker build --target productionships no source files except the built output. - Create a Compose file for local development that builds the
developmenttarget, bind-mounts the project directory, and publishes the dev-server port. Hint: protect container dependencies with a volume at the dependency directory. - Review a production Dockerfile for accidental development behavior. Look for
latest, root users, dev commands, missing.dockerignore, broadCOPY . .before dependency installation, and secrets inENV.
Summary
- Development images optimize for fast editing, debugging, tests, and live reload.
- Production images optimize for small size, reproducibility, startup behavior, and reduced attack surface.
- Multi-stage Dockerfiles let one file produce both workflows using named targets.
- Compose is the standard local tool for development bind mounts, ports, and multi-service stacks.
- Layer cache order matters: copy dependency manifests before source files.
- Never rely on
EXPOSEto publish a port, and never bake secrets into image layers.
