35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Docker Multi-Stage Builds voor Rails 8: Maak je Image 60% Kleiner

Docker Multi-Stage Builds voor Rails 8: Maak je Image 60% Kleiner

TTB Software
devops
Stapsgewijze handleiding voor het bouwen van productie Docker images voor Rails 8 met multi-stage builds. Verklein je image van 1.2GB naar minder dan 500MB met echte Dockerfile voorbeelden en benchmarks.

Een standaard Rails 8 Docker image gebouwd vanuit ruby:3.3 weegt zo’n 1.2GB. Dat is 1.2GB die bij elke deploy naar je registry wordt gepusht, bij elke scale-up wordt gepulld, en op elke server in het geheugen zit. Met multi-stage builds krijg je dat onder de 500MB — soms onder 300MB als je agressief te werk gaat.

Rails 8 wordt geleverd met een Dockerfile generator (rails new maakt er automatisch één aan), en die gebruikt al multi-stage builds. Maar de gegenereerde versie is een startpunt, niet het eindresultaat. Hier lees je hoe je er echte besparingen uit haalt.

Hoe Multi-Stage Builds Werken

Een multi-stage Dockerfile gebruikt meerdere FROM statements. Elke FROM start een nieuwe build stage. Je kunt artefacten van eerdere stages naar latere kopiëren, en alles wat je niet nodig hebt in productie achterlaten.

# Stage 1: dependencies installeren
FROM ruby:3.3.6-slim AS build
# ... gems installeren, assets precompilen

# Stage 2: productie image
FROM ruby:3.3.6-slim AS production
# ... alleen het nodige kopiëren uit build stage

De build stage kan compilers, dev headers en Node.js bevatten — niets daarvan eindigt in je uiteindelijke image.

De Dockerfile, Stage voor Stage

Stage 1: Base

Begin met ruby:3.3.6-slim in plaats van ruby:3.3.6. De slim variant is Debian Bookworm zonder de extras. Dat alleen al scheelt zo’n 400MB.

FROM ruby:3.3.6-slim AS base

WORKDIR /rails

ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development:test"

Door BUNDLE_WITHOUT hier in te stellen slaat Bundler dev/test gems volledig over. Bij een typische Rails app elimineert dat 30-40% van je gem dependency tree.

Stage 2: Build

Hier gebeurt het zware werk. Build dependencies installeren, gems bundelen, assets precompilen — en dan alles weggooien (behalve de output).

FROM base AS build

RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y \
    build-essential \
    git \
    libpq-dev \
    node-gyp \
    pkg-config \
    python-is-python3 && \
    rm -rf /var/lib/apt/lists/*

COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache \
    "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git

COPY . .

RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

Een paar dingen om op te letten:

--no-install-recommends voorkomt dat APT voorgestelde pakketten binnenhaalt. Op Debian Bookworm kan dit 100-200MB per stage schelen.

De bundle cache opruimen (rm -rf ... cache) verwijdert de .gem bestanden die Bundler bewaart. Die zijn alleen nodig voor herinstallatie, en dat gebeurt niet in een container.

SECRET_KEY_BASE_DUMMY=1 is een Rails 7.1+ feature. Hiermee kan asset precompilatie draaien zonder een echte secret key, wat betekent dat je geen secrets hoeft mee te geven tijdens de build.

Stage 3: Productie

Kopieer alleen wat je nodig hebt uit de build stage.

FROM base AS production

RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y \
    curl \
    libpq5 \
    libvips && \
    rm -rf /var/lib/apt/lists/*

RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash

COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

RUN chown -R rails:rails db log storage tmp

USER 1000:1000

ENTRYPOINT ["/rails/bin/docker-entrypoint"]

EXPOSE 3000
CMD ["./bin/thrust", "./bin/rails", "server"]

Let op wat hier niet staat: geen build-essential, geen git, geen node-gyp, geen libpq-dev (we gebruiken libpq5, alleen de runtime library). De C compiler alleen al is goed voor zo’n 150MB.

Draaien als non-root gebruiker (UID 1000) is een security baseline. Als je container gecompromitteerd wordt, landt de aanvaller niet als root.

Echte Cijfers

Ik heb deze gemeten op een middelgrote Rails 8 app met 85 gems, Tailwind CSS en PostgreSQL. Alle images gebouwd op linux/amd64:

Aanpak Image Grootte Build Tijd
ruby:3.3.6 single stage 1.24 GB 2m 14s
ruby:3.3.6-slim single stage 843 MB 2m 31s
Multi-stage (zoals hierboven) 467 MB 2m 48s
Multi-stage + .dockerignore tuning 423 MB 2m 44s

Build tijd stijgt licht omdat Docker meerdere stages verwerkt, maar de deploy-time besparingen (kleiner push/pull) compenseren dat ruimschoots. Op een 100Mbps verbinding is het verschil tussen 1.2GB en 423MB pushen zo’n 60 seconden per deploy.

Het .dockerignore Bestand dat de Meesten Vergeten

Docker stuurt je hele projectdirectory naar de daemon als build context. Zonder .dockerignore gaat daar .git (vaak 100MB+), node_modules, tmp, log en test fixtures bij.

.git
.github
.gitignore
log/*
tmp/*
storage/*
node_modules
.bundle
vendor/bundle
test/
spec/
.rspec
coverage/
.env*
docker-compose*.yml
README.md

Bij de Rails app die ik testte ging de build context van 340MB naar 12MB. Build tijd daalde met zo’n 15 seconden omdat Docker niet honderden megabytes kopieerde die het nooit zou gebruiken.

Verder Gaan: Alpine vs Slim

Je ziet aanbevelingen om ruby:3.3.6-alpine te gebruiken in plaats van slim. Alpine gebruikt musl libc in plaats van glibc en produceert kleinere images (typisch 50-100MB kleiner). Maar er zijn afwegingen.

Sommige gems met C extensies compileren niet schoon op Alpine. nokogiri, grpc en pg werken meestal, maar minder gangbare gems kunnen uren debuggen opleveren. En musl’s memory allocator gedraagt zich anders dan die van glibc — ik heb Rails apps op Alpine 15-20% meer RSS geheugen zien gebruiken in steady state, omdat musl’s malloc minder agressief geheugen teruggeeft aan het OS.

Als je je gem dependencies strak controleert en op Alpine test in CI, is het een valide keuze. Voor de meeste teams geeft slim je 90% van de groottebesparing zonder de compatibiliteitsproblemen.

Layer Caching voor Snellere Rebuilds

Docker cached layers op basis van de instructies en betrokken bestanden. De volgorde van je COPY statements doet ertoe.

# Goed: Gemfile verandert zelden, dus deze layer is meestal gecached
COPY Gemfile Gemfile.lock ./
RUN bundle install

# Applicatiecode verandert vaak, maar het is een snelle copy
COPY . .

RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

Als je COPY . . vóór bundle install zet, invalideert elke codewijziging de bundle cache en forceert een volledige herinstallatie. Dat maakt van een rebuild van 10 seconden er één van 90.

Voor CI pipelines kun je BuildKit cache mounts gebruiken om de bundle cache te bewaren tussen builds:

RUN --mount=type=cache,target=/usr/local/bundle/cache \
    bundle install

Dit bewaart gedownloade .gem bestanden in een persistent cache volume, zodat zelfs een volledige herinstallatie alleen C extensies hoeft te compileren in plaats van alles opnieuw te downloaden.

Health Checks

Voeg een health check toe aan je Dockerfile zodat orchestrators (Docker Swarm, ECS, Kubernetes) ongezonde containers kunnen detecteren:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:3000/up || exit 1

Rails 8 bevat standaard een /up endpoint (via Rails.application.routes health check). Het gebruik van curl hier betekent dat je curl nodig hebt in je productie image — daarom hebben we het opgenomen in de apt-get install van de productie stage.

Deployen met Kamal 2

Als je Kamal 2 gebruikt voor deployment, bouwt en pusht het je Docker image automatisch. Je config/deploy.yml hoeft alleen naar de juiste registry te wijzen:

image: your-org/your-app
registry:
  server: ghcr.io
  username:
    - KAMAL_REGISTRY_USERNAME
  password:
    - KAMAL_REGISTRY_PASSWORD

Kamal draait docker build met je Dockerfile, tagt het en pusht het. De multi-stage Dockerfile die we hier hebben gebouwd werkt zonder aanpassingen met Kamal.

Veelvoorkomende Fouten

.bundle/config kopiëren vanuit development. Je lokale Bundler config kan BUNDLE_PATH instellen op vendor/bundle of platform-specifieke instellingen bevatten. De Dockerfile moet Bundler configuratie volledig via environment variabelen regelen.

Node.js installeren in de productie stage. Als je Propshaft (de Rails 8 standaard) gebruikt met Tailwind’s standalone CLI of importmap, heb je helemaal geen Node.js nodig — zelfs niet in de build stage. Installeer het alleen als je asset pipeline het vereist.

rm -rf /var/lib/apt/lists/* overslaan. Elke apt-get update downloadt pakketlijsten die 30-40MB innemen. Verwijder ze in dezelfde RUN layer.

FAQ

Hoeveel geheugen gebruikt een slim-based Rails container vergeleken met het volledige image?

De imagegrootte beïnvloedt het runtime geheugen niet direct. Een Rails 8 app gebruikt typisch 150-300MB RSS geheugen, ongeacht of het base image slim of full is. De groottebesparing zit in disk en netwerkoverdracht. Je containers starten sneller en je registry-kosten dalen, maar het draaiende proces gebruikt dezelfde hoeveelheid RAM.

Moet ik distroless images gebruiken voor Rails?

Distroless images verwijderen de package manager en shell volledig, wat zeer kleine images oplevert. Het Ruby distroless image is experimenteel en niet officieel ondersteund door het Ruby team. Je kunt geen docker exec doen in een distroless container om te debuggen, en je kunt geen monitoring agents installeren. Voor de meeste Rails apps is slim de praktische keuze. Reserveer distroless voor high-security omgevingen waar de reductie van het aanvalsoppervlak de debugging-beperkingen rechtvaardigt.

Werkt multi-stage building met Docker Compose in development?

Ja, maar je moet een specifieke stage targeten. Voeg target: base toe in je docker-compose.yml om de productie build over te slaan tijdens development:

services:
  web:
    build:
      context: .
      target: base
    volumes:
      - .:/rails
    command: ./bin/rails server -b 0.0.0.0

Dit mount je broncode als een volume en slaat asset precompilatie volledig over, wat je de snelle feedback loop geeft die je nodig hebt in development.

Waarom is mijn image nog steeds groot na multi-stage builds?

Controleer gems met grote native extensies. grpc alleen al voegt zo’n 50MB aan gecompileerde shared objects toe. Draai docker history your-image:latest om te zien welke layers ruimte innemen. Controleer ook of je .dockerignore daadwerkelijk wordt toegepast — Docker negeert syntaxfouten in dat bestand zonder melding.

Kan ik multi-stage builds gebruiken met GitHub Actions CI?

Zeker. GitHub Actions ondersteunt BuildKit standaard. Schakel layer caching in met actions/cache om herhaalde builds te versnellen:

- uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/your-org/your-app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

De gha cache backend slaat layers op in GitHub’s cache storage, die bewaard blijft tussen workflow runs. De eerste build duurt de volledige tijd; volgende builds met een ongewijzigde Gemfile zijn binnen een minuut klaar.

T

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation