All posts

Streamlining Workflows with Multi-Stage Dockerfiles for Dev and Prod

Learn how to use multi-stage Docker builds to create a unified workflow that handles both local development with hot-reloading and slim, secure production images.

4 min read

Since I started building web applications in 2021, I have spent a significant amount of time addressing the classic problem of environment parity. It is a familiar story for many developers: a feature works perfectly on your local machine, but it fails the moment it hits the staging or production environment. Often, these failures stem from subtle differences in Node.js versions, Python dependencies, or OS-level libraries. Using Docker has become my standard way to eliminate these discrepancies, but the real magic happens when you use a single Dockerfile to manage both your local development and your production artifacts.

The Power of Multi-Stage Builds

When I first started with Docker, I used to maintain two separate files: a Dockerfile.dev for local work and a Dockerfile for production. This was a maintenance headache. Every time I added a system dependency, I had to remember to update both files. Multi-stage builds solved this by allowing me to define different stages within a single file. By using the AS keyword, I can label specific parts of the build process and target them independently depending on where the code is running.

The strategy involves creating a base stage that contains the common dependencies, a development stage that includes tools for debugging and hot-reloading, and finally a production stage that is as slim as possible. This approach ensures that the foundation of the environment remains identical across the entire lifecycle of the application.

Structuring the Dockerfile

A typical multi-stage Dockerfile for a TypeScript or Python application usually starts by defining a base image. This stage handles the installation of system-level packages that are required regardless of the environment. Here is an example of how I structure a Node.js project using this pattern:

FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./

FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

FROM base AS build
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/package-lock.json ./package-lock.json
RUN npm ci --only=production
CMD ["node", "dist/main.js"]

Focusing on Development

In the development stage shown above, I want an environment that supports rapid iteration. This means installing all devDependencies and ensuring that the application can watch for file changes. However, simply copying the code into the image isn't enough for local work because I don't want to rebuild the image every time I change a single line of code. This is where Docker Compose comes in. By using the target attribute in docker-compose.yaml, I can tell Docker to stop building at the development stage and then use volumes to mount my local source code directly into the container.

This setup gives me the best of both worlds. The container provides the consistent runtime environment (the specific version of Node and Linux), while the volume mount provides the instant feedback loop I need as a developer. If a teammate clones the repo, they don't need to worry if they have the right version of a specific library installed on their macOS or Windows machine; they just run the container.

Optimizing for Production

When it comes to the production stage, the goals shift entirely. Security and performance become the priorities. A production image should be as small as possible to reduce the attack surface and speed up deployment times in AWS or other cloud providers. In the example above, you'll notice that the production stage starts from a fresh base image. It does not contain the source code, the TypeScript compiler, or any development tools.

By using COPY --from=build, I only pull in the compiled assets. This is a critical distinction. The build stage might be several hundred megabytes because it contains all the build-time tools, but the final production image might only be a fraction of that size. This keeps my production deployments lean and limits the potential vulnerabilities that come with unnecessary packages.

Bridging the Gap with Docker Compose

The glue that holds this together for my daily workflow is Docker Compose. It allows me to define exactly how the development container should behave. For instance, I can set environment variables, link a PostgreSQL database container, and define the build target. A simplified service definition in my docker-compose.yaml often looks like this:

services:
  web:
    build:
      context: .
      target: development
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development

The target: development line is the most important part here. It tells Docker to ignore the subsequent production stages in the Dockerfile. Meanwhile, the /app/node_modules volume is a trick I use to ensure that the container's version of the installed packages is used, rather than overwriting them with whatever might be in my local folder.

Final Thoughts on Consistency

Transitioning to this unified Docker strategy has changed how I approach full stack development. It forces me to think about my environment dependencies early in the project. Instead of treating the production environment as a distant destination, I am effectively working in it from day one, just with a few extra tools attached. Whether I am working with a Svelte frontend or a Django backend, the principle remains the same: use Docker to define the environment once, and use multi-stage builds to tailor that environment for the specific needs of development and production. This discipline results in fewer "surprises" during deployment and a much more reliable local setup for everyone on the project.

Have a project in mind?

I build and run web apps end to end.

Get in touch