Back

Using Alpine for your Rails Dockerfile

Posted December 05, 2024

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:

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"]