Rails comes with a Dockerfile nowadays, which is nice.
But, it’s a Debian image, and we can save a lot of space by using Alpine instead.
The defaults
If we create new Rails 8 project with SQLite, here’s the default Dockerfile we get:
$ rails _8.0.0_ new sqlite-example
$ cat sqlite-example/Dockerfile
# 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 sqlite_example .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name sqlite_example sqlite_example
# 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=3.3.5
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 curl libjemalloc2 libvips sqlite3 && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
# 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 && \
chown -R rails:rails db log storage tmp
USER 1000:1000
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
If we used PostgreSQL instead of SQLite, there’s some slight differences:
$ rails _8.0.0_ new postgres-example -d postgresql
$ git diff --no-index -U1 sqlite-example/Dockerfile postgres-example/Dockerfile
diff --git a/sqlite-example/Dockerfile b/postgres-example/Dockerfile
index cdb6e8a..5a7eca8 100644
--- a/sqlite-example/Dockerfile
+++ b/postgres-example/Dockerfile
@@ -4,4 +4,4 @@
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
-# docker build -t sqlite_example .
-# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name sqlite_example sqlite_example
+# docker build -t postgres_example .
+# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name postgres_example postgres_example
@@ -18,3 +18,3 @@ WORKDIR /rails
RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
+ apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
@@ -32,3 +32,3 @@ FROM base AS build
RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y build-essential git pkg-config && \
+ apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
The only changes are swapping sqlite3
to postgresql-client
, and adding libpq-dev
during the build phase.
Differences when using Alpine
Here’s the diff when we directly port that Dockerfile to Alpine:
diff --git a/Dockerfile b/Dockerfile
index cdb6e8a..1b5e6e2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,3 +11,3 @@
ARG RUBY_VERSION=3.3.5
-FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
+FROM docker.io/library/ruby:$RUBY_VERSION-alpine AS base
@@ -17,5 +17,3 @@ WORKDIR /rails
# Install base packages
-RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
- rm -rf /var/lib/apt/lists /var/cache/apt/archives
+RUN apk add --no-cache curl jemalloc vips sqlite tzdata gcompat
@@ -31,5 +29,3 @@ FROM base AS build
# Install packages needed to build gems
-RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y build-essential git pkg-config && \
- rm -rf /var/lib/apt/lists /var/cache/apt/archives
+RUN apk add --no-cache build-base git pkgconfig
@@ -61,4 +57,4 @@ COPY --from=build /rails /rails
# 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 && \
+RUN addgroup -S -g 1000 rails && \
+ adduser -S -u 1000 -G rails -s /bin/sh rails && \
chown -R rails:rails db log storage tmp
1) Use an Alpine base image
-FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
+FROM docker.io/library/ruby:$RUBY_VERSION-alpine AS base
Instead of debian slim
ruby images, use alpine
.
2) Use apk
instead of apt
-RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
- rm -rf /var/lib/apt/lists /var/cache/apt/archives
+RUN apk add --no-cache curl jemalloc vips sqlite tzdata gcompat
-RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y build-essential git pkg-config && \
- rm -rf /var/lib/apt/lists /var/cache/apt/archives
+RUN apk add --no-cache build-base git pkgconfig
Alpine’s package management tool is apk
, and the package names differ slightly.
Note that we have to add 2 more runtime packages:
tzdata
, since our Alpine image doesn’t ship with it (Rails needs this)gcompat
, to allow running glibc-compiled binaries (Thruster needs this)
For PostgreSQL, the only additional differences are that we need postgresql-client
instead of sqlite
, and we need libpq-dev
in our build phase.
-RUN apk add --no-cache curl jemalloc vips sqlite tzdata gcompat
+RUN apk add --no-cache curl jemalloc vips postgresql-client tzdata gcompat
-RUN apk add --no-cache build-base git pkgconfig
+RUN apk add --no-cache build-base git libpq-dev pkgconfig
3) Adding users and groups
-RUN groupadd --system --gid 1000 rails && \
- useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
+RUN addgroup -S -g 1000 rails && \
+ adduser -S -u 1000 -G rails -s /bin/sh rails && \
chown -R rails:rails db log storage tmp
The commands here are also slightly different.
Note that we use /bin/sh
here, instead of bash
, since bash
isn’t present by default.
Space savings
Let’s see how much space we save:
docker build -t sqlite-debian .
docker build -t postgres-debian .
docker build -t sqlite-alpine .
docker build -t postgres-alpine .
$ docker image ls sqlite-debian
REPOSITORY TAG IMAGE ID CREATED SIZE
sqlite-debian latest 22f522bda5c5 10 minutes ago 557MB
$ docker image ls sqlite-alpine
REPOSITORY TAG IMAGE ID CREATED SIZE
sqlite-alpine latest 850fb4c1d33b 10 minutes ago 358MB
$ docker image ls postgres-debian
REPOSITORY TAG IMAGE ID CREATED SIZE
postgres-debian latest df6929587489 10 minutes ago 607MB
$ docker image ls postgres-alpine
REPOSITORY TAG IMAGE ID CREATED SIZE
postgres-alpine latest 86227294615f 10 minutes ago 358MB
Not bad - our Alpine images are almost half the size.
Other areas for improvements
Version your OS
In most cases, you’ll want to specify the OS version as well, so that there’s no question what version you’re running:
FROM ruby:$RUBY_VERSION-slim-bookworm AS base
# or
FROM ruby:$RUBY_VERSION-alpine3.20 AS base
Here’s the OS versions we got earlier when we weren’t being explicit:
$ docker run --rm -it ruby:3.3.5-slim sh -c 'grep VERSION_ID /etc/os-release'
VERSION_ID="12"
$ docker run --rm -it ruby:3.3.5-alpine sh -c 'grep VERSION_ID /etc/os-release'
VERSION_ID=3.20.3
Release links:
Conclusion
For completeness’s sake, here’s the final Alpine Dockerfile using SQLite:
# 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 sqlite_example .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name sqlite_example sqlite_example
# 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=3.3.5
FROM docker.io/library/ruby:$RUBY_VERSION-alpine AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apk add --no-cache curl jemalloc vips sqlite tzdata gcompat
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apk add --no-cache build-base git pkgconfig
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
# Run and own only the runtime files as a non-root user for security
RUN addgroup -S -g 1000 rails && \
adduser -S -u 1000 -G rails -s /bin/sh rails && \
chown -R rails:rails db log storage tmp
USER 1000:1000
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]