Optimize your Rails Dockerfile
Rails comes with a default Dockerfile for a long time. It's fairly decent and optimized Dockerfile already relying on multiple build stages and other optimization techniques. Rails 8.2 comes with another incremental improvement which is worth to backport to your existing Rails app today.
Dockerfiles and Rails
A Dockerfile is a text document that contains all the commands a user could call on the command line to assemble an image. It is used to create Docker images by specifying a set of instructions needed to build the image. These instructions include things like base image, dependencies, environment variables, and more.
Rails luckily provides us with a default Dockerfile. By using the default Dockerfile provided by Rails, developers can quickly deploy their Rails applications without having to manually write and configure a Dockerfile from scratch.. This default Dockerfile is configured specifically for Rails applications and includes the necessary instructions to set up an environment suitable for running a Rails app on Kamal and other popular Docker-based PaaS platforms and tools.
Rails 8.2 will come with new performance optimizations for the Dockerfile build step that can shave off up to 70 seconds of Docker image build time. Let's have a look at what these optimizations are.
New optimizations
The new Docker build optimizations for Rails 8 Dockerfiles are:
-
Streamlined asset precompilation: The first optimization involves merging the
rm -rf node_modulescommand into the asset precompilation layer within the Dockerfile template. Previously, this command was executed as a separate step, creating an additional layer that required BuildKit to perform a full filesystem diff for the deleted files. By integrating this cleanup into theassets:precompileRUN step, the overhead is significantly reduced, saving approximately 13 seconds in build time. This change ensures a more efficient build process by minimizing unnecessary steps. -
Efficient file ownership: The second optimization focuses on replacing the slower
chown -Rcommand withCOPY --chownin the test fixture Dockerfile. While the main Dockerfile template already utilizedCOPY --chown=rails:rails --from=build, the test fixture had not been updated to reflect this improvement. By setting file ownership at copy time using--chown, the process avoids creating an extra diff layer, resulting in a substantial time saving of around 50 seconds. This change aligns the test fixture with the main template, ensuring consistency and efficiency across the board.
Dockerfile template
You can find the original Rails Dockerfile template here. As of the time of writing it looks like this:
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t <%= app_name %> .
# docker run -d -p 80:<%= skip_thruster? ? 3000 : 80 %> -e RAILS_MASTER_KEY=<value from config/master.key> --name <%= app_name %> <%= app_name %>
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=<%= Gem.ruby_version %>
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y <%= dockerfile_base_packages.join(" ") %> && \
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
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems<%= (using_node? || using_bun?) ? " and node modules" : "" %>
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y <%= dockerfile_build_packages.join(" ") %> && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
<% if using_node? -%>
# Install JavaScript dependencies
ARG NODE_VERSION=<%= node_version %>
ARG YARN_VERSION=<%= dockerfile_yarn_version %>
ENV PATH=/usr/local/node/bin:$PATH
<% if yarn_through_corepack? -%>
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
rm -rf /tmp/node-build-master
RUN corepack enable && yarn set version $YARN_VERSION
<% else -%>
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
npm install -g yarn@$YARN_VERSION && \
rm -rf /tmp/node-build-master
<% end -%>
<% end -%>
<% if using_bun? -%>
ENV BUN_INSTALL=/usr/local/bun
ENV PATH=/usr/local/bun/bin:$PATH
ARG BUN_VERSION=<%= dockerfile_bun_version %>
RUN curl -fsSL https://bun.sh/install | bash -s -- "bun-v${BUN_VERSION}"
<% end -%>
# Install application gems
COPY vendor/* ./vendor/
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git<% if depend_on_bootsnap? -%> && \
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
bundle exec bootsnap precompile -j 1 --gemfile<% end %>
<% if using_node? -%>
# Install node modules
COPY package.json yarn.lock ./
RUN yarn install --immutable
<% end -%>
<% if using_bun? -%>
# Install node modules
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
<% end -%>
# Copy application code
COPY . .
<% if depend_on_bootsnap? -%>
# Precompile bootsnap code for faster boot times.
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
RUN bundle exec bootsnap precompile -j 1 app/ lib/
<% end -%>
<% unless dockerfile_binfile_fixups.empty? -%>
# Adjust binfiles to be executable on Linux
<%= "RUN " + dockerfile_binfile_fixups.join(" && \\\n ") %>
<% end -%>
<% unless options.api? || skip_asset_pipeline? -%>
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
<% if using_node? || using_bun? -%>
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \
rm -rf node_modules
<% else -%>
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
<% end -%>
<% end -%>
# Final stage for app image
FROM base
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
USER 1000:1000
# Copy built artifacts: gems, application
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
<% if skip_thruster? -%>
# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]
<% else -%>
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
<% end -%>
This is of course the template. To see the final version, run a rails new command with your original flags against Rails edge version to benefit with all the improvements like this. Generating a new Dockerfile like this will let you run a diff command against your current version and hopefully adopt all the incremental improvements.
Conclusion
I love how Rails is including the default Dockerfile template which can now be incrementally improved by the whole community. I recomment to compare your current Dockerfile with the latest version. I'll always include them with latest releases the Rails template as well.