Tools: Multi-Stage Docker Builds for Fullstack React + Node Apps

Tools: Multi-Stage Docker Builds for Fullstack React + Node Apps

The Problem with Single-Stage Builds

The Multi-Stage Approach

Serving the Frontend from Fastify

Docker Compose with Traefik

The .dockerignore File

Prisma in Docker: Common Pitfalls

Size Comparison

Deploy Script

Wrapping Up I used to ship fullstack apps as 1.2GB Docker images. Node modules, build tools, source maps, dev dependencies -- all crammed into one layer. It worked, but pulling that image on a $5 VPS with 1GB RAM was painful. Multi-stage builds cut that to ~180MB. Here's the exact setup I use for Vite + Fastify apps with Prisma, including the Traefik reverse proxy config for automatic SSL. A naive Dockerfile looks like this: This image includes everything: TypeScript compiler, Vite, all dev dependencies, source files, node_modules with 400+ packages you only need at build time. The result is 1GB+ and slow to deploy. The idea is simple: use one stage to build, another to run. The build stage has all the tools. The production stage copies only the compiled output. Here's the complete Dockerfile for a Vite frontend + Fastify backend: Three stages, each with a clear purpose: The final image doesn't contain TypeScript, Vite, esbuild, or any dev dependencies. Just the compiled JavaScript, production node_modules, and the Prisma client. Since both frontend and backend are in one container, Fastify serves the Vite build output as static files: API routes go under /api/*, everything else falls through to the React SPA. One process, one port, no nginx sidecar needed. For production, I use Traefik as a reverse proxy. It handles SSL certificates from Let's Encrypt automatically -- no certbot cron jobs, no manual renewal. Here's the docker-compose.yml: The key Traefik labels on the app service: One important detail: your DNS must point directly to the server (A record, not proxied through Cloudflare). Traefik needs to respond to the ACME HTTP challenge on port 80. This is easy to overlook but matters for build speed and image size: Without this, Docker copies your local node_modules (which might be 500MB+) into the build context before the COPY . . step. The build stage installs its own clean dependencies, so your local ones are just wasted transfer time. Two things that will bite you with Prisma in multi-stage builds: 1. Generate in the right stage. prisma generate creates a platform-specific engine binary. If you generate in the deps stage and copy to production, the binary targets match (both Alpine). If you generate locally on macOS and copy to the container, it won't work. 2. Set the binary target explicitly in your schema as a safety net: The linux-musl target covers Alpine. The native target keeps local development working. Here's what the multi-stage build achieves on a real project with Prisma, 15 API routes, and a React dashboard: That's a 7x reduction. Deploys go from 45 seconds to about 8 seconds on a typical VPS. I deploy with a simple rsync + rebuild: Rsync transfers only changed files. Docker layer caching means unchanged stages aren't rebuilt. A typical deploy after a small code change takes under 30 seconds. The multi-stage pattern works for any Node.js fullstack app, not just this specific stack. The principles are always the same: Combined with Traefik for automatic SSL and Docker Compose for orchestration, you get a production setup that takes about 20 minutes to configure and costs $5/month on any VPS provider. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

FROM node:22-alpine WORKDIR /app COPY . . RUN npm install RUN npm run build EXPOSE 3000 CMD ["node", "dist/server.js"] FROM node:22-alpine WORKDIR /app COPY . . RUN npm install RUN npm run build EXPOSE 3000 CMD ["node", "dist/server.js"] FROM node:22-alpine WORKDIR /app COPY . . RUN npm install RUN npm run build EXPOSE 3000 CMD ["node", "dist/server.js"] # ---- Stage 1: Install all dependencies ---- FROM node:22-alpine AS deps WORKDIR /app # Enable pnpm via corepack RUN corepack enable && corepack prepare pnpm@latest --activate COPY package.json pnpm-lock.yaml ./ COPY prisma ./prisma/ RUN pnpm install --frozen-lockfile RUN pnpm prisma generate # ---- Stage 2: Build frontend and backend ---- FROM node:22-alpine AS builder WORKDIR /app RUN corepack enable && corepack prepare pnpm@latest --activate COPY --from=deps /app/node_modules ./node_modules COPY . . # Build Vite frontend (outputs to client/dist/) RUN pnpm run build:client # Build Fastify backend with esbuild (outputs to dist/) RUN pnpm run build:server # ---- Stage 3: Production image ---- FROM node:22-alpine AS production WORKDIR /app RUN corepack enable && corepack prepare pnpm@latest --activate # Only copy production dependencies COPY package.json pnpm-lock.yaml ./ COPY prisma ./prisma/ RUN pnpm install --frozen-lockfile --prod RUN pnpm prisma generate # Copy built artifacts COPY --from=builder /app/dist ./dist COPY --from=builder /app/client/dist ./client/dist # Non-root user RUN addgroup -g 1001 appgroup && \ adduser -u 1001 -G appgroup -s /bin/sh -D appuser USER appuser EXPOSE 3000 CMD ["node", "dist/server.js"] # ---- Stage 1: Install all dependencies ---- FROM node:22-alpine AS deps WORKDIR /app # Enable pnpm via corepack RUN corepack enable && corepack prepare pnpm@latest --activate COPY package.json pnpm-lock.yaml ./ COPY prisma ./prisma/ RUN pnpm install --frozen-lockfile RUN pnpm prisma generate # ---- Stage 2: Build frontend and backend ---- FROM node:22-alpine AS builder WORKDIR /app RUN corepack enable && corepack prepare pnpm@latest --activate COPY --from=deps /app/node_modules ./node_modules COPY . . # Build Vite frontend (outputs to client/dist/) RUN pnpm run build:client # Build Fastify backend with esbuild (outputs to dist/) RUN pnpm run build:server # ---- Stage 3: Production image ---- FROM node:22-alpine AS production WORKDIR /app RUN corepack enable && corepack prepare pnpm@latest --activate # Only copy production dependencies COPY package.json pnpm-lock.yaml ./ COPY prisma ./prisma/ RUN pnpm install --frozen-lockfile --prod RUN pnpm prisma generate # Copy built artifacts COPY --from=builder /app/dist ./dist COPY --from=builder /app/client/dist ./client/dist # Non-root user RUN addgroup -g 1001 appgroup && \ adduser -u 1001 -G appgroup -s /bin/sh -D appuser USER appuser EXPOSE 3000 CMD ["node", "dist/server.js"] # ---- Stage 1: Install all dependencies ---- FROM node:22-alpine AS deps WORKDIR /app # Enable pnpm via corepack RUN corepack enable && corepack prepare pnpm@latest --activate COPY package.json pnpm-lock.yaml ./ COPY prisma ./prisma/ RUN pnpm install --frozen-lockfile RUN pnpm prisma generate # ---- Stage 2: Build frontend and backend ---- FROM node:22-alpine AS builder WORKDIR /app RUN corepack enable && corepack prepare pnpm@latest --activate COPY --from=deps /app/node_modules ./node_modules COPY . . # Build Vite frontend (outputs to client/dist/) RUN pnpm run build:client # Build Fastify backend with esbuild (outputs to dist/) RUN pnpm run build:server # ---- Stage 3: Production image ---- FROM node:22-alpine AS production WORKDIR /app RUN corepack enable && corepack prepare pnpm@latest --activate # Only copy production dependencies COPY package.json pnpm-lock.yaml ./ COPY prisma ./prisma/ RUN pnpm install --frozen-lockfile --prod RUN pnpm prisma generate # Copy built artifacts COPY --from=builder /app/dist ./dist COPY --from=builder /app/client/dist ./client/dist # Non-root user RUN addgroup -g 1001 appgroup && \ adduser -u 1001 -G appgroup -s /bin/sh -D appuser USER appuser EXPOSE 3000 CMD ["node", "dist/server.js"] import fastifyStatic from "@fastify/static"; import { join } from "path"; fastify.register(fastifyStatic, { root: join(__dirname, "../client/dist"), prefix: "/", wildcard: false, }); // SPA fallback -- serve index.html for all non-API routes fastify.setNotFoundHandler(async (request, reply) => { if (request.url.startsWith("/api/")) { return reply.status(404).send({ error: "Not found" }); } return reply.sendFile("index.html"); }); import fastifyStatic from "@fastify/static"; import { join } from "path"; fastify.register(fastifyStatic, { root: join(__dirname, "../client/dist"), prefix: "/", wildcard: false, }); // SPA fallback -- serve index.html for all non-API routes fastify.setNotFoundHandler(async (request, reply) => { if (request.url.startsWith("/api/")) { return reply.status(404).send({ error: "Not found" }); } return reply.sendFile("index.html"); }); import fastifyStatic from "@fastify/static"; import { join } from "path"; fastify.register(fastifyStatic, { root: join(__dirname, "../client/dist"), prefix: "/", wildcard: false, }); // SPA fallback -- serve index.html for all non-API routes fastify.setNotFoundHandler(async (request, reply) => { if (request.url.startsWith("/api/")) { return reply.status(404).send({ error: "Not found" }); } return reply.sendFile("index.html"); }); services: traefik: image: traefik:v3 command: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - "[email protected]" - "--certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json" - "--entrypoints.web.http.redirections.entrypoint.to=websecure" ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - traefik-certs:/certs app: build: context: . dockerfile: Dockerfile labels: - "traefik.enable=true" - "traefik.http.routers.app.rule=Host(`app.example.com`)" - "traefik.http.routers.app.entrypoints=websecure" - "traefik.http.routers.app.tls.certresolver=letsencrypt" - "traefik.http.services.app.loadbalancer.server.port=3000" environment: DATABASE_URL: "postgresql://postgres:secret@db:5432/myapp" NODE_ENV: production depends_on: db: condition: service_healthy db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: secret POSTGRES_DB: myapp volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5 volumes: pgdata: traefik-certs: services: traefik: image: traefik:v3 command: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - "[email protected]" - "--certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json" - "--entrypoints.web.http.redirections.entrypoint.to=websecure" ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - traefik-certs:/certs app: build: context: . dockerfile: Dockerfile labels: - "traefik.enable=true" - "traefik.http.routers.app.rule=Host(`app.example.com`)" - "traefik.http.routers.app.entrypoints=websecure" - "traefik.http.routers.app.tls.certresolver=letsencrypt" - "traefik.http.services.app.loadbalancer.server.port=3000" environment: DATABASE_URL: "postgresql://postgres:secret@db:5432/myapp" NODE_ENV: production depends_on: db: condition: service_healthy db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: secret POSTGRES_DB: myapp volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5 volumes: pgdata: traefik-certs: services: traefik: image: traefik:v3 command: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - "[email protected]" - "--certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json" - "--entrypoints.web.http.redirections.entrypoint.to=websecure" ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - traefik-certs:/certs app: build: context: . dockerfile: Dockerfile labels: - "traefik.enable=true" - "traefik.http.routers.app.rule=Host(`app.example.com`)" - "traefik.http.routers.app.entrypoints=websecure" - "traefik.http.routers.app.tls.certresolver=letsencrypt" - "traefik.http.services.app.loadbalancer.server.port=3000" environment: DATABASE_URL: "postgresql://postgres:secret@db:5432/myapp" NODE_ENV: production depends_on: db: condition: service_healthy db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: secret POSTGRES_DB: myapp volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5 volumes: pgdata: traefik-certs: node_modules dist client/dist .git *.md .env* .vscode node_modules dist client/dist .git *.md .env* .vscode node_modules dist client/dist .git *.md .env* .vscode generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } #!/bin/bash set -euo pipefail SERVER="user@your-vps-ip" APP_DIR="/opt/apps/myapp" echo "Syncing files..." rsync -az --delete \ --exclude node_modules \ --exclude .git \ --exclude .env \ ./ "$SERVER:$APP_DIR/" echo "Building and starting..." ssh "$SERVER" "cd $APP_DIR && docker compose up -d --build" echo "Done. Checking health..." sleep 5 curl -sf "https://app.example.com/api/health" && echo " OK" || echo " FAILED" #!/bin/bash set -euo pipefail SERVER="user@your-vps-ip" APP_DIR="/opt/apps/myapp" echo "Syncing files..." rsync -az --delete \ --exclude node_modules \ --exclude .git \ --exclude .env \ ./ "$SERVER:$APP_DIR/" echo "Building and starting..." ssh "$SERVER" "cd $APP_DIR && docker compose up -d --build" echo "Done. Checking health..." sleep 5 curl -sf "https://app.example.com/api/health" && echo " OK" || echo " FAILED" #!/bin/bash set -euo pipefail SERVER="user@your-vps-ip" APP_DIR="/opt/apps/myapp" echo "Syncing files..." rsync -az --delete \ --exclude node_modules \ --exclude .git \ --exclude .env \ ./ "$SERVER:$APP_DIR/" echo "Building and starting..." ssh "$SERVER" "cd $APP_DIR && docker compose up -d --build" echo "Done. Checking health..." sleep 5 curl -sf "https://app.example.com/api/health" && echo " OK" || echo " FAILED" - deps -- installs all dependencies and generates the Prisma client - builder -- compiles TypeScript and bundles the frontend - production -- copies only what's needed to run - Host() rule routes traffic for your domain - certresolver=letsencrypt triggers automatic certificate provisioning - The HTTP-to-HTTPS redirect is on the Traefik entrypoint level - Separate install, build, and run into distinct stages - Copy only artifacts into the final image (no source, no dev deps) - Run as non-root in production - Use .dockerignore to keep the build context small