LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 0 views

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.

#rails #deployment #docker

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.