Skip to main content

Docker Multi-Stage Build

This project uses a multi-stage Docker build to create an optimized production image. The build process is divided into three stages: frontend build, backend build, and final production assembly.

Why Multi-Stage Builds?

Multi-stage builds allow you to:
  • Keep the final image size small by excluding build tools and dependencies
  • Separate build-time dependencies from runtime dependencies
  • Improve security by reducing the attack surface
  • Organize the build process into logical stages

Complete Dockerfile

# =========================
# Stage 1: Build Frontend
# =========================
FROM node:18-alpine AS frontend-build

WORKDIR /app

# Install frontend dependencies
COPY client/package*.json ./client/
RUN cd client && npm install

# Build frontend
COPY client ./client
RUN cd client && npm run build


# =========================
# Stage 2: Build Backend
# =========================
FROM node:18-alpine AS backend-build

WORKDIR /app

# Install backend dependencies
COPY server/package*.json ./server/
RUN cd server && npm install

# Copy backend source
COPY server ./server


# =========================
# Stage 3: Production Image
# =========================
FROM node:18-alpine

WORKDIR /app

# Copy backend from backend-build stage
COPY --from=backend-build /app/server ./server

# Copy built frontend from frontend-build stage
COPY --from=frontend-build /app/client/build ./server/client/build

# Expose backend port
EXPOSE 5000

# Start backend server
CMD ["node", "server/index.js"]

Build Stages Explained

1

Stage 1: Frontend Build

The first stage builds the React frontend application.
FROM node:18-alpine AS frontend-build
WORKDIR /app

# Install frontend dependencies
COPY client/package*.json ./client/
RUN cd client && npm install

# Build frontend
COPY client ./client
RUN cd client && npm run build
What happens here:
  • Uses node:18-alpine as the base image (lightweight Alpine Linux)
  • Names this stage frontend-build for later reference
  • Copies package.json and package-lock.json first (Docker layer caching)
  • Installs all frontend dependencies with npm install
  • Copies the entire client source code
  • Runs npm run build to create optimized production assets in client/build
The frontend build creates static files (HTML, CSS, JS) that will be served by the Express backend.
2

Stage 2: Backend Build

The second stage prepares the Express backend.
FROM node:18-alpine AS backend-build
WORKDIR /app

# Install backend dependencies
COPY server/package*.json ./server/
RUN cd server && npm install

# Copy backend source
COPY server ./server
What happens here:
  • Uses a fresh node:18-alpine image for isolation
  • Names this stage backend-build
  • Copies backend package files and installs dependencies
  • Copies all backend source code (including index.js)
Each stage starts fresh, ensuring clean separation between frontend and backend builds.
3

Stage 3: Production Assembly

The final stage creates the minimal production image.
FROM node:18-alpine
WORKDIR /app

# Copy backend from backend-build stage
COPY --from=backend-build /app/server ./server

# Copy built frontend from frontend-build stage
COPY --from=frontend-build /app/client/build ./server/client/build

# Expose backend port
EXPOSE 5000

# Start backend server
CMD ["node", "server/index.js"]
What happens here:
  • Uses a final fresh node:18-alpine image (only this layer becomes the final image)
  • Copies the complete backend (code + dependencies) from backend-build stage
  • Copies only the built frontend files from frontend-build stage
  • Exposes port 5000 for the Express server
  • Sets the startup command to run the Node.js server
Only the contents of this final stage are included in the production image. Build tools, source files, and intermediate dependencies from previous stages are not included, keeping the image size minimal.

Building the Image

To build the Docker image locally:
docker build -t react-express-app .
Build process:
  1. Docker executes Stage 1 (frontend build)
  2. Docker executes Stage 2 (backend build)
  3. Docker assembles Stage 3 (production image)
  4. Final image is tagged as react-express-app

Running the Container

Once built, run the container:
docker run -d -p 5000:5000 --name react-express-app react-express-app
Parameters explained:
  • -d - Run in detached mode (background)
  • -p 5000:5000 - Map host port 5000 to container port 5000
  • --name react-express-app - Give the container a friendly name
  • react-express-app (last argument) - The image to run

Port Configuration

The application exposes port 5000, which is the Express server port. The React frontend is served as static files from the Express server, so only one port is needed.
EXPOSE 5000
This informs Docker that the container listens on port 5000 at runtime.

Application Startup

CMD ["node", "server/index.js"]
When the container starts, it executes node server/index.js, which:
  • Starts the Express server
  • Serves the React build files from server/client/build
  • Handles API requests on port 5000

Benefits of This Approach

Small Image Size

Only production files are included, no build tools or source code

Fast Builds

Docker layer caching speeds up rebuilds when dependencies don’t change

Security

Minimal attack surface with only necessary runtime files

Clean Separation

Frontend and backend builds are isolated and organized

Next Steps

Docker Compose

Learn how to orchestrate the container with Docker Compose

Jenkins Pipeline

Automate deployment with Jenkins CI/CD

Build docs developers (and LLMs) love