The Rails Docker image
People searching for a Ruby on Rails Docker image are usually looking for something that does not
exist: an official base image with Rails already in it, the way there is one for Postgres or Redis.
Docker Hub still serves the repository, which is most of the confusion. library/rails is there, it
has 905 stars and 9,025,722 pulls, and it has been dead since 2017.
Everything below was run on 2026-09-26 against Docker 27.4.0, buildx v0.19.2-desktop.1 and BuildKit
v0.17.3 on Docker Desktop, on an Apple M2 Max with 12 cores running macOS arm64-darwin25. The
application is a scratch app generated with rails new rails-dockerfile --database=postgresql
--skip-test --skip-git on railties 8.1.3.1, whose bundle then resolved to rails 8.1.4. PostgreSQL
17.7 ran on the host at port 15432 and was reached from containers as host.docker.internal.
Docker Hub has no Rails image and has not since 2017
library/rails on Docker Hub is a tombstone, and its one-line description is the whole answer:
$ curl -s "https://hub.docker.com/v2/repositories/library/rails/" | jq '{description, star_count, pull_count, last_updated}'
{
"description": "DEPRECATED; use \"ruby\" instead",
"star_count": 905,
"pull_count": 9025722,
"last_updated": "2017-01-06T19:07:44.686101Z"
}
The tag list stops where you would expect a repository abandoned nine years ago to stop. There are
25 tags, latest is Rails 5.0.1, every one of them was pushed on or before 2017-01-06, and the
oldest is 4.2.1.
The deprecation note on the repository page explains why it was dropped, and the reasoning has aged
well: the image never installed Rails, because Rails comes from your Gemfile, so the only thing it
added over ruby was a few preinstalled client libraries. That is now your Dockerfile's job.
So the base image for a Rails application is ruby, which is very much alive: 1,576,530,088 pulls
and a last push of 2026-09-26T07:59:01Z as of the morning this was written. Which variant you take
matters more than people expect. Compressed arm64 sizes, read from the Hub API for the three tags of
Ruby 4.0.5:
| tag | compressed size, arm64 |
|---|---|
ruby:4.0.5 |
398.9 MB |
ruby:4.0.5-slim |
77.1 MB |
ruby:4.0.5-alpine |
48.0 MB |
Rails picks -slim, and that is the right default. Alpine saves 29 MB and buys you musl, which
means every native gem compiles against a different libc from the one on your laptop and your CI.
29 MB is not worth that conversation.
The Dockerfile you are looking for is already in your app
rails new writes a production Dockerfile into every application it generates, and has since Rails
7.1. There is nothing to find and nothing to copy off a blog. The source is an ERB template inside
railties:
$ gem contents railties | grep "app/templates/Dockerfile.tt"
/Users/mehdifarsi/.rvm/gems/ruby-4.0.5/gems/railties-8.1.4/lib/rails/generators/rails/app/templates/Dockerfile.tt
$ wc -l ~/.rvm/gems/ruby-4.0.5/gems/railties-8.1.3.1/lib/rails/generators/rails/app/templates/Dockerfile.tt
131
131 lines of template render to 77 lines of Dockerfile for a default PostgreSQL app. The missing 54
are branches for the options you did not take: skip_thruster? swaps EXPOSE 80 for EXPOSE 3000,
the sqlite branch adds a /rails/storage volume, the node branch installs a second toolchain. The
template is byte-identical between railties 8.1.3.1 and 8.1.4, so a patch upgrade does not quietly
change your build.
Here is the generated file with the comment lines removed and nothing else changed:
# syntax=docker/dockerfile:1
# check=error=true
ARG RUBY_VERSION=4.0.5
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
WORKDIR /rails
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
FROM base AS build
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libpq-dev libvips libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
COPY vendor/* ./vendor/
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile -j 1 --gemfile
COPY . .
RUN bundle exec bootsnap precompile -j 1 app/ lib/
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
FROM base
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
USER 1000:1000
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
Three stages, and the shape is worth reading once. base is the runtime, and both other stages
inherit from it, which is why the apt packages a running container needs are installed once while
the compiler toolchain is installed separately. build adds build-essential, libpq-dev and
pkg-config, installs gems, compiles assets, and is then discarded. The final stage copies two
directories out of build and keeps nothing else of it.
What each layer costs
docker history on the 565 MB image, top nine lines, truncated to fit:
$ docker history rails-dockerfile-demo --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" | head -9 | cut -c1-108
0B CMD ["./bin/thrust" "./bin/rails" "server"]
0B EXPOSE [80/tcp]
0B ENTRYPOINT ["/rails/bin/docker-entrypoint"]
56.4MB COPY --chown=rails:rails /rails /rails # buildkit
124MB COPY --chown=rails:rails /usr/local/bundle /usr/local/bundle # buildkit
0B USER 1000:1000
8.9kB RUN /bin/sh -c groupadd --system --gid 1000 rails && useradd rails --uid 1000 --gid 1000 --create-
0B ENV RAILS_ENV=production BUNDLE_DEPLOYMENT=1 BUNDLE_PATH=/usr/local/bundle BUNDLE_WITHOUT=development LD_
186MB RUN /bin/sh -c apt-get update -qq && apt-get install --no-install-recommends -y curl libjemalloc2
The largest single layer in a Rails image, before a line of your code is in it, is the apt install: 186 MB of Debian packages against 124 MB of gems and 56.4 MB of application. Anyone shrinking a Rails image by pruning gems is working on the second-biggest number.
That the toolchain is really gone is worth checking rather than assuming, because a multi-stage Dockerfile that accidentally leaves build tools in the final stage looks identical from outside:
$ docker run --rm --entrypoint bash rails-dockerfile-demo -lc 'command -v gcc >/dev/null && echo present || echo absent; id'
absent
uid=1000(rails) gid=1000(rails) groups=1000(rails)
Fifty seconds cold, seven seconds after a code change
Two --no-cache builds on the same machine, with ruby:4.0.5-slim already pulled, took 46.3 and
50.7 seconds. The second one:
$ time docker build --no-cache -t rails-dockerfile-demo .
docker build --no-cache -t rails-dockerfile-demo . 0.24s user 0.32s system 1% cpu 50.707 total
Four steps account for almost all of it, from the BuildKit log of that run: 17.0 s for the base
stage apt install, 10.2 s for the build stage apt install, 18.2 s for bundle install plus the
bootsnap gem precompile, and 1.0 s for assets:precompile. Assets are cheap because a default Rails
8.1 app has no CSS build step and importmap writes 23 files into public/assets.
Then touch one controller and build again:
$ echo "# touch $(date)" >> app/controllers/application_controller.rb
$ time docker build -t rails-dockerfile-demo .
docker build -t rails-dockerfile-demo . 0.10s user 0.09s system 2% cpu 6.668 total
9 of the 13 steps came back CACHED. The cache boundary is COPY . ., which is the entire reason
COPY Gemfile Gemfile.lock ./ sits on its own line above it: a change to application code
invalidates everything below that line, and bundle install is above it. Touch the Gemfile and you
pay the 18 seconds again.
Running the image needs four environment variables, and the first error names one
Start the image with nothing and it never reaches Puma. bin/docker-entrypoint runs ./bin/rails
db:prepare before it execs the command, and db:prepare loads the environment:
$ docker run --rm rails-dockerfile-demo
bin/rails aborted!
ArgumentError: Missing `secret_key_base` for 'production' environment, set this string with `bin/rails credentials:edit` (ArgumentError)
/rails/config/environment.rb:5:in '<main>'
Tasks: TOP => db:prepare => db:load_config => environment
The .dockerignore that ships beside the Dockerfile excludes /config/master.key and
/config/credentials/*.key, so the key is deliberately absent from the image and has to arrive as
RAILS_MASTER_KEY at run time. That much the error message covers, and
Rails credentials covers the rest of it.
What the error does not mention is the database, and on a Rails 8 app that is not one variable.
Setting DATABASE_URL alone got the primary database created and then failed on the Solid Cache,
Solid Queue and Solid Cable configurations, which fell back to a Unix socket that does not exist
inside the container:
PG::ConnectionBad: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory
Why DATABASE_URL reaches primary and nothing else is the subject of
Rails database.yml, resolved. The container version of the answer is
that you need four of them:
$ docker run -d --name rdf-boot -p 3402:80 \
-e RAILS_MASTER_KEY=$(cat config/master.key) \
-e DATABASE_URL="postgres://mehdifarsi@host.docker.internal:15432/rails_dockerfile_production" \
-e CACHE_DATABASE_URL="postgres://mehdifarsi@host.docker.internal:15432/rails_dockerfile_production_cache" \
-e QUEUE_DATABASE_URL="postgres://mehdifarsi@host.docker.internal:15432/rails_dockerfile_production_queue" \
-e CABLE_DATABASE_URL="postgres://mehdifarsi@host.docker.internal:15432/rails_dockerfile_production_cable" \
rails-dockerfile-demo
Time from that command returning to /up answering 200, measured by polling in a shell loop with no
sleep: 1.94 s and 1.84 s over two runs, with the four databases already created. The very first run
is slower, because db:prepare creates them.
Two ports are involved and only one of them is published. Both show up in the logs:
{"time":"2026-09-27T08:32:54.632410127Z","level":"INFO","msg":"Server started","http":":80"}
* Listening on http://0.0.0.0:3000
The JSON line is Thruster, which is what ./bin/thrust starts and what EXPOSE 80 refers to. Puma
sits behind it on 3000 and is not exposed. Deploying Rails with Kamal 2
covers what Thruster is doing in that position, and
Rails health checks covers why a 200 from /up is weaker evidence
than it looks.
tmp/local_secret.txt ships inside the image
The wrong turn first, because it is the reason this section exists. SECRET_KEY_BASE_DUMMY=1 on the
assets:precompile line is there so the build never needs production credentials, which is a good
idea. I assumed the obvious follow-on: that running a container with SECRET_KEY_BASE_DUMMY=1 set
would mint a fresh random key on every boot, so every restart would log everybody out and you would
find out fast. That is not what happens. Two containers from the same image printed the same key:
$ docker run --rm -e SECRET_KEY_BASE_DUMMY=1 -e DATABASE_URL=... rails-dockerfile-demo \
./bin/rails runner 'puts Rails.application.secret_key_base[0,16]'
8e29f1648d07d062
$ docker run --rm -e SECRET_KEY_BASE_DUMMY=1 -e DATABASE_URL=... rails-dockerfile-demo \
./bin/rails runner 'puts Rails.application.secret_key_base[0,16]'
8e29f1648d07d062
It is stable because nothing is generated at run time. secret_key_base in
railties-8.1.4/lib/rails/application/configuration.rb:523 calls generate_local_secret when
ENV["SECRET_KEY_BASE_DUMMY"] is set, and generate_local_secret, thirteen lines long at
configuration.rb:653, writes SecureRandom.hex(64) into tmp/local_secret.txt only unless
File.exist?(key_file) and otherwise reads the file back. The build stage created that file during
assets:precompile, and COPY --chown=rails:rails --from=build /rails /rails carried it into the
final image:
$ docker run --rm --entrypoint bash rails-dockerfile-demo -lc 'ls -l tmp/local_secret.txt; cat tmp/local_secret.txt | head -c 32'
-rw-r--r-- 1 rails rails 128 Sep 27 08:31 tmp/local_secret.txt
8e29f1648d07d0624afb0509aa40230e
The .dockerignore rule /tmp/* does not save you, because it filters the build context on the way
in and this file is written inside the build stage afterwards.
Pushed to a registry, the file goes along. Tagged, pushed, deleted locally, pulled back:
$ docker push -q localhost:5055/rails-dockerfile:v2
$ docker rmi -f localhost:5055/rails-dockerfile:v2 && docker pull -q localhost:5055/rails-dockerfile:v2
$ docker run --rm --entrypoint cat localhost:5055/rails-dockerfile:v2 tmp/local_secret.txt
8e29f1648d07d0624afb0509aa40230e472b87b0dcbc7525e4163169789839ae3e1687ea8c2499a68e723b38b147d9b334839cf9324c303bc4d115f771d949fe
This is not an exploit against a correctly run container. A production container gets
RAILS_MASTER_KEY or SECRET_KEY_BASE, the dummy branch is never taken, and the file is inert. It
becomes a live secret the moment somebody sets SECRET_KEY_BASE_DUMMY=1 to get a container to
start, which people do, because it works and because the error that sent them looking was about
credentials. On a public Docker Hub repository, everybody who pulls the tag then holds the key that
signs your cookies.
Appending && rm -f tmp/local_secret.txt to the precompile line removes the case. Built and
checked:
$ sed -n 55p Dockerfile
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && rm -f tmp/local_secret.txt
$ docker run --rm --entrypoint bash rdf-nosecret -lc 'test -f tmp/local_secret.txt && echo present || echo absent'
absent
$ docker run --rm -e RAILS_MASTER_KEY=$(cat config/master.key) -e DATABASE_URL=... rdf-nosecret \
./bin/rails runner 'puts "booted, assets #{Dir["public/assets/*"].size} files"'
booted, assets 23 files
The cost of that line is that SECRET_KEY_BASE_DUMMY=1 no longer starts a container at all, which
is the point, and which will annoy exactly one person on the day they were trying to debug something
unrelated.
libvips is 104 MB of the 186
The apt layer is large for one reason. Measured by building the base stage twice with a one-word difference between the two Dockerfiles:
$ docker images --format "{{.Repository}} {{.Size}}" | grep rdf-probe
rdf-probe-without 281MB
rdf-probe-with 385MB
Installed sizes in kilobytes from dpkg-query inside the image, for the image-format libraries that
arrive with it:
12353 libmagickcore-7.q16-10
6280 libopenexr-3-1-30
5596 librsvg2-2
3535 libvips42t64
1583 libheif1
libvips is in the Dockerfile because Rails 8.1 ships gem "image_processing", "~> 1.2"
uncommented in the generated Gemfile, which puts ruby-vips 2.3.0 in the lockfile of an
application that has never accepted a file upload. Removing both is a two-line change and the result
still runs:
$ docker images --format "{{.Repository}} {{.Size}}" | grep -E "rdf-novips|rails-dockerfile-demo"
rdf-novips 458MB
rails-dockerfile-demo 565MB
107 MB, 19 percent of the image, for deleting one gem and one package name from two apt lines.
Rails states the cost itself, on every boot:
Generating image variants require the image_processing gem. Please add `gem "image_processing", "~> 1.2"` to your Gemfile or set `config.active_storage.variant_processor = :disabled`.
Adding config.active_storage.variant_processor = :disabled to
config/environments/production.rb silences that and the image stays at 458 MB. So the warning is
not really the cost. The cost is that you have decided this application will never resize an
uploaded image, and on the day it needs to you are back to a 565 MB image plus a Gemfile change on
the deploy that discovers the requirement. For an application with no uploads, take the 107 MB. For
anything with an avatar in it, leave the Dockerfile alone.
Pushing to Docker Hub means building for an architecture you are not on
An M2 laptop builds arm64. Most servers you deploy to are amd64. The first attempt at doing both at once failed on the builder rather than on the Dockerfile:
$ docker buildx build --platform linux/amd64,linux/arm64 -t localhost:5055/rails-dockerfile:v1 --push .
ERROR: Multi-platform build is not supported for the docker driver.
Switch to a different driver, or turn on the containerd image store, and try again.
The default Docker Desktop builder cannot produce a manifest list. A docker-container builder can,
and that builder is where the QEMU emulation the Dockerfile's own comments warn about actually runs:
$ docker buildx create --name rdf-builder --driver docker-container \
--driver-opt network=host --config buildkitd.toml
$ time docker buildx build --builder rdf-builder --platform linux/amd64,linux/arm64 \
-t localhost:5055/rails-dockerfile:v1 --push .
docker buildx build --builder rdf-builder --platform linux/amd64,linux/arm64 ... 2:37.79 total
The registry there is a local registry:2 container on port 5055, not Docker Hub. The network=host
and --config flags exist only because that registry speaks plaintext HTTP and the builder runs in
its own container; buildkitd.toml is three lines declaring http = true for localhost:5055.
Against Docker Hub the command is the same with -t yourname/yourapp:v1 and a docker login first.
I did not push to a public repository from this machine, so nothing here is evidence about Hub rate
limits or push times.
What the emulation costs, measured by running docker build --no-cache --target build once per
platform, back to back:
| step | linux/arm64 | linux/amd64, emulated |
|---|---|---|
base stage apt-get install |
14.2 s | 48.8 s |
build stage apt-get install |
9.5 s | 26.0 s |
bundle install plus bootsnap |
16.4 s | 43.3 s |
A factor of about 3, consistently, on every step that executes instructions rather than moving
bytes. That is also the reason the generated Dockerfile carries -j 1 on both bootsnap lines with a
comment pointing at rails/bootsnap#495: parallel
compilation under QEMU was hitting a bug, and the template works around it for everybody, including
the large majority who never cross-build at all.
The manifest list that came out had four entries, two of which are not images:
$ docker buildx imagetools inspect localhost:5055/rails-dockerfile:v1
MediaType: application/vnd.oci.image.index.v1+json
Platform: linux/amd64
Platform: linux/arm64
Platform: unknown/unknown
Annotations:
vnd.docker.reference.type: attestation-manifest
Platform: unknown/unknown
Annotations:
vnd.docker.reference.type: attestation-manifest
The unknown/unknown pair are provenance attestations buildx attaches by default. Rebuilding the
same two platforms with --provenance=false produced an index with exactly two manifests and
nothing else.
Line 2 is a linter and it will fail your build
# check=error=true is the second line of every Dockerfile Rails generates, and most people read
straight past it. It turns BuildKit's Dockerfile lint warnings into build failures. One legacy
ENV line appended to the generated file was enough:
$ echo 'ENV FOO bar' >> Dockerfile
$ docker build --no-cache -t rdf-check4 .
1 warning found (use docker --debug to expand):
- LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format (line 78)
ERROR: failed to solve: lint violation found for rules: LegacyKeyValueFormat
$ echo $?
1
Worth knowing before you spend twenty minutes on a build that dies at the first step with a message about a style rule.
Seventeen assertions against the built image
Every claim above about what is inside the image is asserted in a Minitest file that runs against
the image rather than against the app. It lives in the scratch app as test/image_test.rb and
shells out to docker:
def test_master_key_is_not_in_the_image
assert_equal "absent", in_image("test -f config/master.key && echo present || echo absent").strip
end
def test_same_image_yields_the_same_secret_key_base_in_two_containers
cmd = [ "docker", "run", "--rm", "-e", "SECRET_KEY_BASE_DUMMY=1", *db_env, IMAGE,
"./bin/rails", "runner", "puts Rails.application.secret_key_base" ]
first, = sh(*cmd)
second, = sh(*cmd)
assert_equal first.strip, second.strip
assert_equal in_image("cat tmp/local_secret.txt").strip, first.strip
end
$ ruby test/image_test.rb -v
ImageTest#test_database_url_alone_leaves_the_solid_databases_unconfigured = 1.29 s = .
ImageTest#test_development_group_gems_are_absent = 0.37 s = .
ImageTest#test_dummy_secret_is_baked_into_the_image = 0.32 s = .
ImageTest#test_exposes_port_80_not_3000 = 0.02 s = .
ImageTest#test_assets_were_precompiled_at_build_time = 0.31 s = .
ImageTest#test_build_toolchain_is_not_in_the_final_stage = 0.32 s = .
ImageTest#test_runs_as_uid_1000_named_rails = 0.35 s = .
ImageTest#test_up_answers_200_with_all_four_urls = 2.42 s = .
ImageTest#test_master_key_is_not_in_the_image = 0.30 s = .
ImageTest#test_boot_without_a_secret_aborts_with_the_credentials_message = 0.85 s = .
ImageTest#test_same_image_yields_the_same_secret_key_base_in_two_containers = 2.56 s = .
Finished in 9.127707s, 1.2051 runs/s, 1.8625 assertions/s.
11 runs, 17 assertions, 0 failures, 0 errors, 0 skips
Nine seconds, a Docker daemon and a reachable PostgreSQL, so a suite like that does not belong in the unit test run. It belongs wherever the image is built, which for most people is CI. Its value is that it fails when somebody edits the Dockerfile and nothing else in the build notices, which is the normal case: every defect described on this page produced a green build.
What this post does not cover
Development with Docker is a different Dockerfile and a different argument. The one Rails generates says so on line 4, points at Dev Containers, and nothing above applies to a compose file with your source bind-mounted into the container. That case is Rails and Postgres in Docker Compose, which starts from the same generated file and explains why it is the wrong one for the job.
Also absent: the containerd image store, which is the other route to multi-platform builds out of
Docker Desktop and which I did not enable because switching it clears every local image;
--cache-to and --cache-from against a registry, which is how you get the seven-second rebuild on
CI instead of only on a laptop where the layers happen to be sitting; Docker Hub rate limits and
paid tiers, since a stale figure there is worse than none; image signing and SBOM generation, beyond
noticing two attestation manifests nobody asked for; distroless and other shell-less base images,
which trade away the bash -lc debugging used throughout this page; and Kamal, which builds this
same image on your behalf and is covered in
Deploying Rails with Kamal 2.
One number above should not be copied into anybody's budget. 50.7 seconds for a cold build assumes an M2 Max, 12 cores, and a network that pulled Debian packages at 56.9 MB/s. A two-core CI runner will not see it.
Comments
No comments yet. Be the first.