Articles
AI Article 9 min 730

Docker Multi-Stage Builds: Rails, Node, PHP, Go Images Under 100 MB

How to shrink Docker images 10-15x with multi-stage builds. Examples for Rails, Next.js, Laravel, Django, FastAPI, Go. CI/CD, caching, security.

This article was generated by an AI model and may contain inaccuracies. Verify information before using in production.

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=cache for bundler/npm/pip cache
  • [ ] BuildKit enabled: DOCKER_BUILDKIT=1
  • [ ] CI: cache-from: type=gha for 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

  • [ ] HEALTHCHECK instruction 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)