The go-to resource for upgrading Ruby, Rails, and your dependencies.

Step-by-Step Guide to Safely Upgrading Ruby in a Dockerized Rails App


In the early days of maritime shipping, goods were packed in variously sized barrels, crates, and sacks. Loading a ship was a complex, fragile puzzle. The invention of the standardized intermodal shipping container changed everything; it provided reliable infrastructure that made the contents irrelevant to the ship itself. Software containers, of course, serve a similar purpose for our applications. But when we upgrade the runtime inside that container — like moving to a new Ruby version — we aren’t modifying the cargo; we are fundamentally rebuilding the container’s internal environment.

Upgrading the Ruby version of a large and complex application is rarely a matter of changing a single line in a .ruby-version file. When your Rails application is containerized with Docker, a Ruby upgrade alters your base infrastructure. It impacts the base operating system image, the compilation of native extensions, your Continuous Integration (CI) pipelines, and the final artifact deployed to your production environment.

We often underestimate the operational friction of a Docker-based Ruby update. You might update the Dockerfile, run a build, and be immediately met with cascading compiler errors from gems like Nokogiri or ffi. Navigating these failures safely during a Ruby and Rails upgrade requires a deliberate workflow that prioritizes technical debt remediation while ensuring ongoing product development remains uninterrupted.

Before we get into the exact mechanics of that workflow, though, it’s worth taking a step back: any successful migration to a new Ruby version requires a solid understanding of how your application currently builds and runs.

Step 1: Auditing the Current Docker Environment

We should begin by examining the existing Dockerfile. First, identify the specific base image you are currently using. If your application relies on an outdated or unsupported OS distribution — such as Debian Buster or an old Alpine release — you will need to upgrade the OS alongside Ruby.

Next, review your Gemfile and Gemfile.lock for gems with native C extensions. Gems that interface with the database (pg, mysql2), parse XML (nokogiri), or handle background processing frequently require specific system-level libraries, like libpq-dev or build-essential. Document these system dependencies, as the new Ruby base image may have different pre-installed packages or package manager behaviors.

Step 2: Selecting the Appropriate Base Image

The choice of a Docker base image involves significant trade-offs between image size, build speed, and stability. There are two major approaches to selecting a base image for Rails applications; depending on the particular circumstances you find yourself in, one of them may be more useful than the other.

The first approach is to use Alpine Linux. Alpine images offer exceptionally small file sizes, which can reduce cloud infrastructure costs and image transfer times. One may wonder: if Alpine images are so much smaller, why not always use them? The answer lies in their C standard library. Alpine uses musl instead of the more common glibc. This distinction means that many pre-compiled native gems will not work without additional configuration, forcing your Dockerfile to compile them from source. This often increases build times and can introduce obscure, hard-to-debug runtime errors.

The second option is to use Debian-based images, such as the -slim variants. These provide a predictable GNU environment where pre-compiled gems function reliably, offering a pragmatic balance between size and compatibility.

Generally speaking, I prefer the second option for most Rails applications. For large enterprise applications prioritizing stability, Debian-based slim images (e.g., ruby:3.3.0-slim-bookworm) are usually the safer choice. They allow us to bypass many of the compilation headaches associated with native extensions.

Step 3: Implementing a Dual-Image Build Strategy

Modifying your primary Dockerfile immediately will likely break the build for other developers on your team. To avoid disrupting ongoing development, we can implement a dual-image strategy.

Let’s create a new file named Dockerfile.next. This file will serve as the testing ground for our Ruby upgrade.

# Dockerfile.next
FROM ruby:3.3.0-slim-bookworm

# Install essential system dependencies
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y \
    build-essential \
    libpq-dev \
    git \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy dependency files
COPY Gemfile Gemfile.lock ./

# Install dependencies
RUN bundle install

# Copy the application code
COPY . .

# Set the default command
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

By using Dockerfile.next, we can build and test the upgraded container locally. We can run the following command to build our experimental image and tag it as myapp:next:

$ docker build -t myapp:next -f Dockerfile.next .
...snip...
 => => naming to docker.io/library/myapp:next

I’ve abbreviated the output above for the sake of brevity, but assuming it builds successfully, we can verify the environment. Let’s inspect the state of our newly built container to confirm it is running the Ruby version we expect:

$ docker run --rm myapp:next ruby -v
ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [x86_64-linux]

This read-only inspection proves our base image is correct, allowing us to test the new environment while the rest of the team continues to use the stable, existing Dockerfile.

Step 4: Resolving Gem Dependencies and Native Extensions

With your new Docker environment configured, you will likely encounter failures during the bundle install step of the docker build. Ruby version bumps frequently expose vulnerable dependencies or incompatibilities in older gems.

One might be tempted to run bundle update to fix these issues. We should avoid this. That command updates all gems simultaneously, making it nearly impossible to isolate the root cause of subsequent regressions. Instead, we should use a targeted approach:

  1. When a gem fails to compile or install during the Docker build, review the error output carefully. It typically indicates a missing system library in your Dockerfile.next or an incompatibility with the new Ruby version.
  2. If the gem is incompatible, update only that specific gem. Of course, because the docker build failed, you do not have a working myapp:next container to run bundle inside. To fix this, you can spin up an interactive container using your new base image, mounting your local directory:
$ docker run -it --rm -v $(pwd):/app -w /app ruby:3.3.0-slim-bookworm bundle update nokogiri
  1. If a system library is missing instead of a gem version incompatibility, add it to the apt-get install command in your Dockerfile.next.

We will repeat this process iteratively until the entire docker build succeeds and the test suite passes within the new container. Strictly speaking, this deliberate, step-by-step approach manages risk and isolates variables during the upgrade.

Note: The bundle update command is aggressive by design; it will update the specified gem and any of its dependencies to the latest possible versions allowed by your Gemfile. By targeting specific gems, we limit the scope of these changes.

Step 5: Pipeline Integration and Gradual Rollout

Once Dockerfile.next builds successfully and your test suite passes locally, we must validate it in a production-like environment.

We can configure our CI/CD pipeline to build both the standard Dockerfile and Dockerfile.next in parallel. The primary build remains a blocking requirement for merging pull requests, while the Dockerfile.next build operates in an advisory capacity. This allows us to monitor the stability of the new Ruby version against ongoing code changes without halting deployment pipelines.

When the advisory build consistently passes and we are confident in the application’s stability, we can execute the final cutover. We will rename Dockerfile.next to Dockerfile, merge the changes, and deploy.

Of course, the upgrade process does not end with deployment. We should monitor application performance metrics closely — particularly memory allocations and response times — to ensure the new Ruby Virtual Machine is operating as expected.

Much like the maritime industry standardized on containers to make shipping predictable and reliable, we use Docker to bring that same reliability to software deployment. By treating the Docker environment itself as a core component of our upgrade strategy, rather than merely the vessel carrying our code, we maintain technical health and ensure predictable, safe deployments over the long term.

Sponsored by Durable Programming

Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.

Hire Durable Programming