A Rails image that weighs more than a gigabyte is not a Docker problem, it is a packaging problem that Docker makes visible. FROM ruby:3.4 hands you a full Debian userland, a C compiler, headers for half of /usr/include, git, and whatever Node toolchain you dragged in for the asset pipeline. All of it ships to every node, on every deploy, for as long as the service lives.
Here is a Dockerfile that works, that I still find in production, and that costs a fortune in pull time:
FROM ruby:3.4
WORKDIR /app
COPY . .
RUN bundle install
RUN bundle exec rails assets:precompile
EXPOSE 3000
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
Seven lines, roughly a gigabyte before a single line of your app code lands. Everything below is a change you can make this afternoon, in order, measuring as you go.
If you generated your app with Rails 8, rails new already wrote you a multi-stage Dockerfile, and it is a decent one: slim base, split stages, a non-root user. That file quietly does steps 1, 3 and 4 of this post. Keep reading anyway, for the apps that predate it, and for the day the generated file breaks and you need to know why each line is there.
Where the gigabyte actually comes from
Before optimizing anything, look at what you built. Two commands tell you almost everything:
docker build -t shop:naive .
docker images shop:naive
docker history shop:naive
docker images gives you the number to be embarrassed about. docker history gives you the reason: a per-layer breakdown, newest instruction first, which puts your own COPY lines at the top and the base image at the very bottom. Scroll down to that last entry and on a stock Rails image it is the biggest single line in the list, before your code lands at all. That ordering matters, because it tells you the first fix is not clever caching. It is throwing away a base image you never needed.
One caveat on the numbers. A registry reports the compressed download, while your disk sees the unpacked tree, and the gap runs two to three times. The image this post ends with unpacks to 287 MB and pulls as 114 MB. Both are real and they measure different things, which is how these posts end up arguing past each other. Everything here was measured on amd64 against Ruby 3.4 on Debian trixie. Your numbers will drift with architecture and with whatever the base image absorbed last month, so trust the ratios, not my decimals.
Step 1: Pick a smaller base
ruby:3.4 is Debian trixie with the full build toolchain baked in. ruby:3.4-slim is the same Ruby on a stripped Debian, and the gap is not subtle. Unpacked on disk, the full image sits around a gigabyte and slim lands comfortably under 200 MB. ruby:3.4-alpine goes lower still, into double digits, because it swaps glibc for musl and coreutils for busybox.
Reach for slim first. Alpine is tempting and it is also where teams lose an afternoon, because musl is not glibc. The big gems have caught up: nokogiri has shipped x86_64-linux-musl builds since 1.16, and pg compiles cleanly once the headers are there. It is the tail that bites. Any gem without a musl binary builds from source on alpine, which is slower and occasionally just fails, and you find out at the worst time. Ruby's allocator under musl is also worse for long-running web processes unless you link jemalloc yourself. Alpine is a fine destination once you have verified your gem set against it. It is a bad place to start a migration.
One allocator note while we are here. jemalloc is not an alpine workaround, it is worth having on slim too: Ruby's default malloc fragments under a multi-threaded Puma, and long-running processes routinely drop double-digit percentages of memory after the swap. On Debian it costs two lines, apt-get install libjemalloc2 in the runtime stage and LD_PRELOAD pointing at the library. The Dockerfile Rails 8 generates does this by default, which tells you how settled the practice is.
The base swap alone is usually the single largest win available to you, and it costs one word in one line.
Step 2: .dockerignore is not optional
COPY . . copies whatever the client sends to the daemon, and by default that is your entire working directory. In a mature Rails repo that means .git with years of history, tmp/cache, a local node_modules from the last time someone ran yarn on their laptop, log/development.log, coverage, and storage full of test uploads.
.git
.gitignore
.env*
log
tmp
storage
coverage
node_modules
spec
test
*.md
Dockerfile*
.dockerignore
The size win here is real but modest. The security win is the one worth caring about. If .env lands in a layer, deleting it in a later RUN rm does not remove it. Layers are append-only, and the file stays readable to anyone who can pull the image and unpack it. A .dockerignore is the cheapest thing standing between your production credentials and your registry, though not the only one: BuildKit secret mounts keep a value out of the layer entirely, and Rails encrypts its credentials as long as the master key lives outside the image. Use those as well. None of them help if COPY . . swept the file in before you thought about any of this.
Step 3: Multi-stage builds
Your build needs gcc, make, headers and git. Your runtime needs Ruby, your gems, your compiled assets and a database client library. These are two different machines and there is no reason to ship the first one. The Dockerfile below assumes the Rails 8 default of Propshaft and importmap, which needs no Node at all. If you bundle JavaScript with esbuild or similar, the builder stage needs Node and yarn too, and the runtime still does not.
FROM ruby:3.4-slim AS builder
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libpq-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# RAILS_ENV belongs here too, not just in the runtime stage: assets:precompile
# boots the app, and a development boot loads the gems BUNDLE_WITHOUT skipped.
ENV RAILS_ENV=production \
BUNDLE_PATH=/usr/local/bundle \
BUNDLE_WITHOUT=development:test \
BUNDLE_DEPLOYMENT=1
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf /usr/local/bundle/cache /usr/local/bundle/ruby/*/cache
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile
FROM ruby:3.4-slim
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y libpq5 && \
rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
WORKDIR /app
ENV RAILS_ENV=production \
BUNDLE_PATH=/usr/local/bundle \
BUNDLE_WITHOUT=development:test
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder --chown=rails:rails /app /app
# Rails writes a pidfile, logs and cache at runtime, so the non-root user
# has to own these directories. .dockerignore kept them out of the image.
RUN mkdir -p tmp/pids log storage && chown -R rails:rails tmp log storage
USER 1000:1000
EXPOSE 3000
ENTRYPOINT ["./bin/docker-entrypoint"]
CMD ["./bin/rails", "server"]Several things in there are doing more work than they look like they are. BUNDLE_DEPLOYMENT=1 freezes the lockfile, so a Gemfile that drifted from Gemfile.lock fails the build instead of quietly resolving to something new. That is the Ruby answer to npm ci, and it belongs in every image you ship.
BUNDLE_WITHOUT=development:test keeps rspec, factory_bot, debug and the rest of your dev stack out of the runtime entirely, which is both smaller and less to explain to a security auditor.
The rm -rf on the bundle cache sits inside the same RUN as bundle install, and that placement is the whole point. Each RUN produces a layer, and a deletion in layer six does not shrink layer five. If you clean up in a separate instruction, the bytes are still in the image, just hidden behind a whiteout file. Clean up where you make the mess.
SECRET_KEY_BASE_DUMMY=1 lets assets:precompile boot the app during the build without a real secret. Before Rails 7.1 people faked this with a throwaway env var and it worked, mostly. Now there is a supported switch, so use it.
RAILS_ENV=production belongs in the builder too, not just in the runtime stage, and leaving it out is the single most common way this Dockerfile fails. assets:precompile boots your application. Without the variable Rails boots in development, Bundler.require reaches for the development gems that BUNDLE_WITHOUT just told it to skip, and the build dies on a Bundler::GemRequireError for debug. The error names a gem you never asked for, which is why it costs people an hour.
The runtime stage installs libpq5 rather than libpq-dev. The dev package exists so gcc can compile against it, and you are not compiling anything at runtime. This distinction repeats itself across every native dependency you have: something-dev in the builder, the bare shared library in the runtime.
The USER 1000:1000 line needs the two before it. Files arrive from the builder owned by root, and Rails still writes a pidfile, a bootsnap cache and logs, so dropping privileges without handing over ownership buys you an Errno::EACCES on tmp/pids and a container that exits before Puma binds a port. COPY --chown plus a chown on the directories Rails writes to is the whole fix. The ENTRYPOINT points at bin/docker-entrypoint, which Rails generates for you and which step 6 rewrites.
Step 4: Layer order decides your build time
Look at where COPY Gemfile Gemfile.lock ./ sits relative to COPY . .. That ordering is not cosmetic. Docker caches each layer keyed on the instruction and the content it touches, and the first invalidated layer invalidates everything after it.
Copy your whole app before installing gems and every controller tweak throws away the bundle cache, so CI reinstalls a hundred gems to ship a two-line change. Copy the lockfile first and bundle install stays cached until dependencies genuinely move. The difference between those two orderings is minutes per build, every build, for the life of the project. It is the cheapest performance work in your entire pipeline.
The same logic applies to apt-get: put it above anything that changes often, because system packages change roughly never.
BuildKit can push this one step further. A cache mount keeps the gem cache on the build host, outside any layer, so even when the lockfile does change, bundler only downloads the gems that actually moved:
RUN --mount=type=cache,target=/usr/local/bundle/cache \
bundle installThe mount never lands in the image, which also retires the rm -rf on the cache directory from the Dockerfile above. It requires BuildKit, the default builder since Docker 23, and in CI it requires a runner whose build cache survives between jobs, otherwise the mount is empty every run and buys you nothing.
Step 5: What the Node playbook gets you, and where it stops
The standard Node trick at this stage is to build on Debian and run on alpine, and it works for pure JavaScript, which is just text and genuinely does not care about libc. The rule is not different in Ruby. The odds are. A gem with a native extension is compiled against the libc of the stage that built it, so compile nokogiri or pg on Debian, copy the bundle into an alpine runtime, and the extension will not load: the glibc symbols it was linked against are not there. Node hits the same wall the moment it reaches for bcrypt or sharp, it just reaches less often, because most of that dependency tree is text. In Rails a native extension is the normal case, not the exception, which is why copying a Node tutorial breaks a Rails deploy. If you want alpine, both stages have to be alpine:
FROM ruby:3.4-alpine AS builder
RUN apk add --no-cache build-base git postgresql-dev yaml-dev
# ... bundle install, precompile assets
FROM ruby:3.4-alpine
RUN apk add --no-cache libpq tzdata
# ... copy the bundle and the app
Which is a fine setup, provided you actually run your test suite inside it. Do not find out on a Friday deploy that one transitive gem never built against musl.
If you would rather stay on slim, there is still fat to trim: --no-install-recommends on every apt call, rm -rf /var/lib/apt/lists/* in the same layer, and dropping .git through .dockerignore. Be honest about the returns, though. The Dockerfile above copies the whole application directory into the runtime, source assets and all, and in a fresh Rails 8 app that entire tree is 15 MB against a 104 MB bundle. Hand-tuned COPY lines buy you a rounding error and cost you a Dockerfile nobody wants to maintain. The gems are where the weight is.
Step 6: Distroless, and the honest caveat
Distroless images contain a language runtime, TLS roots and nothing else. No shell, no package manager, no busybox. Attack surface drops to almost nothing and so does size.
The catch for us is simple: there is no official distroless Ruby. Google publishes distroless images for Java, Node, Python, C and a static base, and Ruby has never been in that matrix. You can build your own on top of distroless/base by copying a Ruby runtime in, but you now own that runtime, its patches and its CVE feed. The closest maintained equivalent is Chainguard's Ruby image, which is worth a look if a near-zero CVE report is a contractual requirement rather than a preference.
For most Rails teams the realistic floor is a trimmed alpine or slim runtime somewhere near 100 to 150 MB of pull, which unpacks to roughly three times that on the node. A Rails app on slim lands around 114 MB pulled, and that is fine. The Node writeups that end at 78 MB are ending on a step Ruby does not have.
There is one distroless lesson worth stealing even if you never use the image. Shell-less runtimes break bin/docker-entrypoint, because Rails ships it as bash. Writing it in Ruby costs nothing and removes the dependency:
#!/usr/local/bin/ruby
# bin/docker-entrypoint
# Prepares the database before the server boots. No bash required,
# which is what makes this survive a shell-less base image. The shebang
# names the interpreter directly: distroless ships no /usr/bin/env either.
if ARGV.first == "./bin/rails" && ["server", "s"].include?(ARGV[1])
require "fileutils"
FileUtils.rm_f("tmp/pids/server.pid")
system("./bin/rails", "db:prepare", exception: true)
end
exec(*ARGV)
exec replaces the Ruby process with the real command, so your server ends up as PID 1 and receives SIGTERM directly. That detail matters more than the size question: an entrypoint that stays alive as a parent swallows the signal, and Kubernetes ends up SIGKILLing your Puma mid-request every rollout.
Step 7: Pin the base by digest
Everything above assumes ruby:3.4-slim means the same thing tomorrow that it means today, and it does not. A tag is a pointer. The image behind it is rebuilt whenever Debian patches something, and each rebuild silently shifts your size, your CVE report and occasionally your behavior. That is the drift I flagged back in the measurement section, and it has a fix.
A digest is the content hash of one specific build, and it cannot move. docker buildx imagetools inspect ruby:3.4-slim prints it, and pinning is one line:
FROM ruby:3.4-slim@sha256:<digest from imagetools inspect> AS builderKeep the tag in front of the digest for the human reading the file: the digest decides what you pull, the tag documents what you meant. The trade-off is that base image security patches stop arriving for free, so a pinned digest belongs next to Dependabot or Renovate, where the bump shows up as a pull request with a CI run instead of as a surprise.
What this actually buys you
Pull time is the obvious one. Every scale-up event, every new node, every rescheduled pod pays the image size again, and a cold pull of a gigabyte is the difference between a pod that is ready in seconds and one that keeps your on-call awake during an incident. Autoscaling is worthless if the new replica needs a minute to arrive.
CI pays it too, on every branch, for every developer, forever. So does registry replication if you push to more than one region, where you are billed for the privilege of moving those same unused compiler headers around the planet.
Then there is the CVE report. A full Ruby image carries an entire Debian userland plus a toolchain, and every one of those packages is a line in your scanner output. You are not going to patch gcc in a Rails runtime, so the honest fix is to not ship gcc.
Things that look clever and are not
docker-slim, maintained these days as slim, promises to do this automatically by observing a running container and stripping everything it did not touch. In a Rails app that is a trap with your name on it. Zeitwerk resolves constants at runtime, and any code path your smoke test missed is a file the tool considers dead weight. You get a smaller image and a NoMethodError three weeks later on the one endpoint nobody exercised.
Building from scratch sounds like the logical endpoint and is not reachable. Ruby needs a libc, and even if you solved that you would be missing CA certificates, timezone data and a DNS resolver, and would spend a week rediscovering why base images exist.
The steps that matter are boring and ordered. Swap the base, add a .dockerignore, split the build, order your layers. That is most of the win, it takes an afternoon, and nothing about it requires a new tool.
To keep it that way, put hadolint in CI. It is a Dockerfile linter that flags most of what this post covers: unpinned base images, apt-get without cleanup, a missing --no-install-recommends. One binary, one second per run, and it catches the regression six months from now when someone edits the Dockerfile in a hurry.
Happy shrinking!
