I once saw a Node.js API Docker image at 1.2GB. The developer copied everything into the container, including dev node_modules, test files, and the entire TypeScript SDK. The application worked fine, but every deploy took minutes just to push the image.
Multi-stage builds fix this. The idea is straightforward: use one stage to compile and install dependencies, and another stage to copy only what the application needs to run. The result is 50 to 150MB images instead of 1GB.
This guide covers how to apply multi-stage builds to Node.js, Python, and Go projects, along with the most common mistakes I see in the wild.
Quick answer
Use FROM twice in your Dockerfile. The first stage (builder) installs dependencies and compiles the code. The second stage (runner) copies only the required artifacts from the builder. For Node.js, that means copying just dist/ and production node_modules. For Go, the compiled binary. For Python, the virtualenv with runtime dependencies.
Key takeaways
- Multi-stage builds separate construction from execution. The final image does not carry compilers, C headers, or testing tools.
- The order of
COPYinstructions determines Docker’s caching behavior. Copypackage.jsonbefore source code to reuse dependency layers. - Use alpine or distroless base images when possible. Fewer packages in the container means a smaller attack surface.
.dockerignoreis mandatory. Without it, you send localnode_modules,.git, and temporary files to the Docker daemon.- Test the final image with
docker runbefore pushing. It is the fastest way to catch permission or path issues.
When this tutorial applies
Use multi-stage builds for any application that needs a compilation or dependency installation step before running. This includes: Node.js APIs with TypeScript, Next.js applications with SSR, Python projects with compiled dependencies (psycopg2, numpy), Go binaries, and Java/Kotlin applications.
The pattern works well when the application runs as a single process inside the container and does not need debugging tools in production.
When not to use this approach
If the container needs compilation tools at runtime (for example, a service that compiles user-submitted code), multi-stage does not help because you need the toolchain in the final image. Local development containers (devcontainers) also do not need multi-stage, since the priority there is convenience, not image size.
For static applications (HTML/CSS/JS), you can use the build stage and then serve with Nginx in the final image. It works, but consider whether containerizing it makes sense or if a CDN is a better fit.
Before you start
- Docker 20.10+ installed locally
- A project with package.json, requirements.txt, or go.mod
- Basic familiarity with Docker commands (build, run)
1. Node.js with TypeScript: the most common case
Most Node.js projects in production use TypeScript. The build outputs JavaScript in dist/, and that is all the final image needs. Along with production node_modules, of course.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"] What is happening here: the builder stage installs all dependencies (including TypeScript and type definitions) and compiles the project. The runner stage installs only production dependencies (--omit=dev excludes TypeScript, Jest, ESLint) and copies the dist directory from the builder.
Typical final image size: 80 to 120MB, depending on how many production dependencies the project has.
The mistake most people make
The COPY package.json package-lock.json ./ line must come before COPY . .. This seems obvious, but I see Dockerfiles that do COPY . . first. The problem: when any file in the project changes, Docker invalidates the RUN npm ci cache. By separating the copies, Docker only reinstalls dependencies when package.json changes.
2. Python: compiling native dependencies
Python has an extra complication. Packages like psycopg2, Pillow, or numpy compile C extensions during installation. Those extensions need headers and compilers (gcc, python3-dev) that you do not want in the final image.
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] The trick here is creating the virtualenv (/opt/venv) in the builder and copying it entirely to the runner. The runner needs the system libraries that C extensions link against at runtime (like libpq5 for psycopg2), but does not need gcc or the headers.
Notice the difference: libpq-dev in the builder (to compile), libpq5 in the runner (to execute). If you forget the runtime lib, the import fails with a shared library error.
3. Go: the cleanest case
Go compiles to a static binary. The final image can contain just that binary and nothing else.
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/server
FROM alpine:3.19 AS runner
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /usr/local/bin/server
EXPOSE 8080
CMD ["server"] CGO_ENABLED=0 compiles a purely static binary, with no C dependencies. -ldflags="-s -w" strips debug information and reduces binary size by 20 to 30%.
The final image has Alpine (7MB) plus the binary. Usually ends up between 15 and 40MB total. If you want to go further, use scratch as the base instead of Alpine, but then you lose access to a shell and debugging tools (which can complicate troubleshooting).
4. Next.js: the tricky case
Next.js deserves attention because the build produces two types of artifact: static pages and server code. The .next/standalone directory is what you copy to production.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"] For this Dockerfile to work, add output: 'standalone' in next.config.js:
module.exports = {
output: 'standalone',
} Without output: 'standalone', Next.js does not generate the standalone directory and the build breaks. The standalone output includes a minimal Next.js server along with the required dependencies. Without it, you would need to copy the entire node_modules.
Essential configuration: .dockerignore
Without .dockerignore, Docker sends the entire project directory as build context. That includes local node_modules (which can be 500MB), .git (another few hundred MB), and temporary files. The build gets slow for no good reason.
node_modules
.next
dist
.git
.gitignore
.env
.env.*
*.md
coverage
.vscode
.idea
docker-compose*.yml
Dockerfile Build context with a properly configured .dockerignore usually stays between 5 and 50MB instead of several GB. The difference in build time is noticeable.
Verifying the result
After the build, it is worth inspecting the image before deploying:
Post-build checklist
- Run docker images to check the final image size
- Execute docker run -p 3000:3000 my-image and test the application locally
- Verify with docker inspect that there are no unnecessary layers
- Confirm sensitive variables are not hardcoded in the image
- Test the application health check responding on the correct port
Deploy on Guara Cloud
With the Dockerfile ready and .dockerignore configured, deploying on Guara Cloud is straightforward. The platform detects the Dockerfile in the repository root and runs the build automatically.
Deploy on Guara Cloud
- Push the project with the Dockerfile to GitHub
- Create a new service on Guara Cloud and connect the repository
- The platform detects the Dockerfile and starts the build
- Configure environment variables through the dashboard
- Watch the build in real-time logs (2 to 5 minutes on the first deploy)
Subsequent deploys are faster. Docker reuses layers that have not changed, so if you only modified source code (without touching package.json), only the final stages run again.
Common problems
- Problem The final image is over 500MB
- Solution Check if the runner stage uses a slim or alpine base image. Make sure you are copying only the required artifacts, not the entire builder directory.
- Problem npm ci fails with ENOENT in the runner stage
- Solution The package-lock.json must be in the repository. If you use npm install instead of npm ci, resolution may diverge between stages. Always use npm ci when a lockfile exists.
- Problem Permission error when running the application in the runner
- Solution Docker runs as root by default. If the builder created files as root and the runner uses a different user, add USER node before CMD or adjust permissions with COPY --chown=node:node.
- Problem Health check fails on deploy but works locally
- Solution Make sure the application listens on 0.0.0.0, not localhost or 127.0.0.1. In a container, localhost is the internal loopback. The platform reaches the container via its IP.
- Problem Build cache does not work, reinstalls everything every time
- Solution The order of COPY instructions matters. Copy package.json and lockfile before source code. If COPY . . comes before npm ci, any code change invalidates the dependency cache.
Size comparison
Real numbers from projects I tested:
| Stack | Without multi-stage | With multi-stage | Difference |
|---|---|---|---|
| Node.js + TypeScript | 1.1GB | 95MB | 91% smaller |
| Next.js (App Router) | 1.4GB | 180MB | 87% smaller |
| Python + FastAPI | 890MB | 210MB | 76% smaller |
| Go | 750MB | 18MB | 98% smaller |
The Go difference is massive because the final binary is completely self-contained. Node.js and Python always carry the runtime in the image, so there is a floor size that multi-stage cannot eliminate.
Does multi-stage build make the build slower?
The first build may be marginally slower because Docker processes two stages. But with active cache, subsequent builds are faster because dependency layers do not change. Image push time drops dramatically with the smaller size.
Can I use multi-stage with docker-compose?
Yes. In docker-compose.yml, specify the target of the stage you want to run. For development, use target: builder. For production, use target: runner or simply omit it (Docker uses the last stage).
What is the difference between alpine and slim?
Alpine uses musl libc (an alternative libc implementation) and weighs about 5MB. Slim is Debian-based but without docs, man pages, and unnecessary tools, coming in around 80MB. Alpine is smaller but can cause issues with packages that depend on glibc.
Do I need multi-stage if my project is plain JavaScript without TypeScript?
Still worth it. Even without compilation, multi-stage lets you separate dev node_modules (tests, linters) from production dependencies. The size difference comes from excluding hundreds of unnecessary packages in the final image.
How do I know if my image is secure for production?
Run docker scout cves my-image or trivy image my-image to check for known vulnerabilities. Smaller images tend to have fewer CVEs simply because they have fewer packages installed. Alpine and distroless lead in this regard.
Deploy your Docker image on Guara Cloud
Automatic builds from your Dockerfile, managed HTTPS, real-time logs, and billing in Brazilian Real. Infrastructure in São Paulo.