Why Docker Images Balloon to Gigabytes
A typical Dockerfile for a Rails application: take ruby:3.2, install Node.js for asset compilation, add build-essential for native gems, copy code, run bundle install and assets:precompile. Result — a 1.5-2 GB image containing GCC compiler, header files, Node.js with npm, bundler cache, native extension sources — none of which are needed at runtime.
Every extra megabyte means slower pulls from the registry, longer deploys, more disk space, wider attack surface. On CI with 50 deploys per day, the difference between a 2 GB and 150 MB image is hours of waiting and terabytes of traffic per month.
Multi-stage builds solve this: one Dockerfile, multiple stages (FROM). Each stage is a separate environment. The final image contains only what's needed to run. Compilers, build dependencies, source files stay in intermediate stages and never reach the production image.
The Multi-Stage Principle
Simplest Example
# Stage 1: build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
# Stage 2: runtime
FROM alpine:3.20
RUN apk --no-cache add ca-certificates
COPY --from=builder /server /server
EXPOSE 8080
CMD ["/server"]
What happens: the first FROM creates a stage with the Go compiler (~800 MB). The binary is compiled. The second FROM starts from clean Alpine (~7 MB). COPY --from=builder copies only the binary. Final image: ~15 MB instead of 800 MB.
Rails: From 1.8 GB to 120 MB
Multi-Stage Dockerfile
# Stage 1: base with runtime dependencies
FROM ruby:3.2-slim AS base
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y libpq5 curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV RAILS_ENV=production \
BUNDLE_DEPLOYMENT=1 \
BUNDLE_WITHOUT="development:test"
# Stage 2: gem compilation
FROM base AS gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
build-essential libpq-dev pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache
# Stage 3: asset compilation
FROM base AS assets
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install --no-install-recommends -y nodejs && \
npm install -g yarn && rm -rf /var/lib/apt/lists/*
COPY --from=gems /usr/local/bundle /usr/local/bundle
COPY Gemfile Gemfile.lock package.json yarn.lock ./
RUN yarn install --frozen-lockfile --production
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile && \
rm -rf node_modules tmp/cache vendor/javascript
# Stage 4: final production image
FROM base AS production
COPY --from=gems /usr/local/bundle /usr/local/bundle
COPY --from=assets /app /app
RUN bundle exec bootsnap precompile --gemfile app/ lib/
RUN useradd -m rails && chown -R rails:rails /app
USER rails
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
# Size: ~120 MB
From 1.8 GB to 120 MB — 15x smaller.
Node.js / TypeScript
Express + TypeScript
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
cp -R node_modules /prod_modules && \
npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]
# Size: ~80 MB (instead of ~400 MB)
The /prod_modules trick: in the deps stage, production dependencies are installed first and copied to a separate folder, then all dependencies (including dev) for building. Only production node_modules go into the final image.
Next.js with Standalone Output
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "server.js"]
# Size: ~120 MB (instead of ~1 GB)
Python / Django / FastAPI
FastAPI with Poetry
FROM python:3.12-slim AS builder
RUN pip install poetry==1.8.0
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false && \
poetry install --no-dev --no-interaction
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .
RUN useradd -m app
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Size: ~150 MB
Django
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
FROM python:3.12-slim AS production
RUN apt-get update && apt-get install --no-install-recommends -y libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
RUN python manage.py collectstatic --noinput
RUN useradd -m django
USER django
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
# Size: ~180 MB (instead of ~700 MB)
Key trick: pip install --prefix=/install installs packages to a separate directory, easy to COPY --from into the final image.
PHP / Laravel
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --prefer-dist --ignore-platform-reqs
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json vite.config.js ./
COPY resources/ ./resources/
RUN npm ci && npm run build
FROM php:8.4-fpm-alpine AS production
RUN apk add --no-cache libpq postgresql-dev \
&& docker-php-ext-install pdo_pgsql opcache \
&& apk del postgresql-dev
WORKDIR /app
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 9000
CMD ["php-fpm"]
# Size: ~95 MB
Go: Minimal Images
Static Binary + scratch
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-w -s" -o /server ./cmd/server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Size: ~8 MB
scratch is an empty image, 0 bytes. No shell, no libc, nothing. Just the binary and SSL certificates. Perfect for Go with CGO_ENABLED=0.
With Distroless
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Size: ~12 MB
Distroless — Google's image without shell, package manager, or utilities. More secure than Alpine, more convenient than scratch.
Layer Caching Optimization
COPY Order Matters
# Bad: changing ANY file invalidates bundle install cache
COPY . .
RUN bundle install
# Good: dependency files first, then code
COPY Gemfile Gemfile.lock ./
RUN bundle install # cached until Gemfile changes
COPY . . # code changes don't break bundle cache
.dockerignore Is Mandatory
.git
.github
node_modules
tmp
log
storage
.env*
coverage
spec
test
*.md
BuildKit Cache Mounts
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.bundle/cache \
bundle install
RUN --mount=type=cache,target=/root/.npm \
npm ci
Cache persists between builds but doesn't end up in the image. Re-running bundle install after adding one gem — seconds instead of minutes.
Security
Non-Root User
RUN useradd -m -s /bin/bash appuser
USER appuser
Never run applications as root in containers. Container escape exploits exist. Non-root user is an additional defense layer.
Base Image Comparison
| Base Image | Size | CVEs (typical) | Shell | Package Manager |
|---|---|---|---|---|
| ubuntu:24.04 | 78 MB | 50-100 | Yes | apt |
| python:3.12-slim | 45 MB | 20-40 | Yes | apt |
| python:3.12-alpine | 17 MB | 5-15 | Yes | apk |
| distroless | 3-15 MB | 0-5 | No | No |
| scratch | 0 MB | 0 | No | No |
Vulnerability Scanning
# Trivy
docker run --rm aquasec/trivy image myapp:latest
# Docker Scout
docker scout cves myapp:latest
CI/CD: Build and Push
GitHub Actions
name: Docker Build & Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
target: production
Multi-Platform Builds
- uses: docker/setup-qemu-action@v3
- uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
One Dockerfile — images for x86 and ARM (Apple Silicon, AWS Graviton).
Size Comparison: Before and After
| Stack | Monolithic Dockerfile | Multi-stage | Savings |
|---|---|---|---|
| Rails 8 | 1.8 GB | 120 MB | 93% |
| Next.js 15 | 1.0 GB | 120 MB | 88% |
| Laravel 11 | 800 MB | 95 MB | 88% |
| Django 5 | 700 MB | 150 MB | 79% |
| FastAPI | 500 MB | 80 MB | 84% |
| Go (gin) | 800 MB | 8 MB | 99% |
| Express + TS | 400 MB | 80 MB | 80% |
Final Checklist
Dockerfile Structure
- [ ] Minimum 2 stages: builder + production
- [ ] For Rails/Laravel/Next.js: 3-4 stages (base, deps, assets, production)
- [ ] Final stage on slim/alpine/distroless — not full image
- [ ]
.dockerignore— exclude .git, node_modules, tmp, log, tests
Caching
- [ ] COPY dependency files first, then code
- [ ]
--mount=type=cachefor bundler/npm/pip cache - [ ] BuildKit enabled:
DOCKER_BUILDKIT=1 - [ ] CI:
cache-from: type=ghafor GitHub Actions
Security
- [ ] Non-root USER in final stage
- [ ] No secrets in image (use
--mount=type=secret) - [ ] Scanning: Trivy/Scout/Snyk in CI pipeline
- [ ] Base image: update regularly (CVE)
Production
- [ ]
HEALTHCHECKinstruction for orchestrator - [ ] Image size < 200 MB for interpreted, < 20 MB for Go/Rust
- [ ] Multi-platform: linux/amd64 + linux/arm64
- [ ] Tags:
:latest+:sha(for rollback)
Comments (0)