Deploying with Kamal using a locally built Ruby image

24 Oct 2026
Security For Rails Developers

Develop the right mindset for Rails security

Avoid shipping vulnerable code by learning how to prevent security issues in your Rails applications.

Get the course for $99

Yesterday, a new security release was published for Ruby, but the official docker images took a long time to become available. The Ruby team has it’s own docker repository, and they published a new version quickly, but it is Ubuntu based, so if you are using a smaller image, that might not be suitable for you. In case of a security release, you should wait too long and luckily you can still take things in your own hands and just build a Ruby image. I deploy my apps with Kamal, so here is a guide about how to do that in the future should you need.

The Dockerfile Rails generates for us starts like this:

ARG RUBY_VERSION=4.0.9
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base

The way Docker works is that if we have an image in our local Docker image store with the exact same name, Docker will use that instead of pulling it from the registry. This way we don’t need to touch the app’s Dockerfile at all, and once the official image is published, we can just remove the workaround.

First of all, we need a Dockerfile to build Ruby. I created a Dockerfile.ruby in the root of the app, which is basically a local equivalent of the official ruby:4.0.9-slim image, based on the docker-library/ruby one:

# syntax=docker/dockerfile:1

# Local equivalent of the official ruby:4.0.9-slim image.
FROM debian:bookworm-slim

RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends ca-certificates; \
    rm -rf /var/lib/apt/lists/*

RUN set -eux; \
    mkdir -p /usr/local/etc; \
    echo 'gem: --no-document' >> /usr/local/etc/gemrc

ENV LANG=C.UTF-8 \
    RUBY_VERSION=4.0.9 \
    RUBY_DOWNLOAD_URL=https://cache.ruby-lang.org/pub/ruby/4.0/ruby-4.0.9.tar.xz \
    RUBY_DOWNLOAD_SHA256=a8057adb39f51ad6dc3106237504c93dd792bb515837b4fe7817ae21ed7e51f4

RUN set -eux; \
    savedAptMark="$(apt-mark showmanual)"; \
    apt-get update; \
    apt-get install -y --no-install-recommends \
        autoconf \
        bzip2 \
        dpkg-dev \
        g++ \
        gcc \
        libbz2-dev \
        libffi-dev \
        libgdbm-compat-dev \
        libgdbm-dev \
        libglib2.0-dev \
        libgmp-dev \
        libncurses-dev \
        libssl-dev \
        libxml2-dev \
        libxslt-dev \
        libyaml-dev \
        make \
        ruby \
        wget \
        xz-utils \
        zlib1g-dev; \
    rustArch=; \
    dpkgArch="$(dpkg --print-architecture)"; \
    case "$dpkgArch" in \
        amd64) rustArch='x86_64-unknown-linux-gnu'; rustupUrl='https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-linux-gnu/rustup-init'; rustupSha256='20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c' ;; \
        arm64) rustArch='aarch64-unknown-linux-gnu'; rustupUrl='https://static.rust-lang.org/rustup/archive/1.28.2/aarch64-unknown-linux-gnu/rustup-init'; rustupSha256='e3853c5a252fca15252d07cb23a1bdd9377a8c6f3efa01531109281ae47f841c' ;; \
    esac; \
    if [ -n "$rustArch" ]; then \
        mkdir -p /tmp/rust; \
        wget -O /tmp/rust/rustup-init "$rustupUrl"; \
        echo "$rustupSha256 */tmp/rust/rustup-init" | sha256sum --check --strict; \
        chmod +x /tmp/rust/rustup-init; \
        export RUSTUP_HOME=/tmp/rust/rustup CARGO_HOME=/tmp/rust/cargo; \
        export PATH="$CARGO_HOME/bin:$PATH"; \
        /tmp/rust/rustup-init -y --no-modify-path --profile minimal --default-toolchain 1.91.1 --default-host "$rustArch"; \
    fi; \
    wget -O ruby.tar.xz "$RUBY_DOWNLOAD_URL"; \
    echo "$RUBY_DOWNLOAD_SHA256 *ruby.tar.xz" | sha256sum --check --strict; \
    mkdir -p /usr/src/ruby; \
    tar -xJf ruby.tar.xz -C /usr/src/ruby --strip-components=1; \
    rm ruby.tar.xz; \
    cd /usr/src/ruby; \
    autoconf; \
    gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \
    export LDFLAGS='-Wl,-z,relro -Wl,-z,now'; \
    ./configure \
        --build="$gnuArch" \
        --disable-install-doc \
        --enable-shared \
        ${rustArch:+--enable-yjit} \
        ${rustArch:+--enable-zjit}; \
    make -j "$(nproc)"; \
    make install; \
    rm -rf /tmp/rust; \
    apt-mark auto '.*' > /dev/null; \
    apt-mark manual $savedAptMark > /dev/null; \
    find /usr/local -type f -executable -not \( -name '*tkinter*' \) -exec ldd '{}' ';' \
        | awk '/=>/ { so = $(NF-1); if (index(so, "/usr/local/") == 1) { next }; gsub("^/(usr/)?", "", so); printf "*%s\n", so }' \
        | sort -u \
        | xargs -r dpkg-query --search \
        | awk 'sub(":$", "", $1) { print $1 }' \
        | sort -u \
        | xargs -r apt-mark manual; \
    apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
    cd /; \
    rm -rf /var/lib/apt/lists/* /usr/src/ruby; \
    [ "$(command -v ruby)" = /usr/local/bin/ruby ]; \
    ruby --version; \
    gem --version; \
    bundle --version

ENV GEM_HOME=/usr/local/bundle \
    BUNDLE_SILENCE_ROOT_WARNING=1 \
    BUNDLE_APP_CONFIG=/usr/local/bundle \
    PATH=/usr/local/bundle/bin:$PATH

RUN set -eux; \
    mkdir "$GEM_HOME"; \
    chmod 1777 "$GEM_HOME"

CMD ["irb"]

It looks like a lot, but what it does is fairly straightforward. We start from a slim Debian image, install the build dependencies, download the Ruby source tarball and verify its checksum(don’t skip this!). If we are on amd64 or arm64, we also install a Rust toolchain temporarily, so we can compile Ruby with YJIT and ZJIT enabled. Then we compile and install Ruby, and finally we remove all the build dependencies, except the shared libraries Ruby actually links against, to keep the image small. The last bit sets up the same GEM_HOME and bundler environment the official image has, so our app’s Dockerfile will work the same way with it.

When you need to do this for another Ruby version, you just need to update RUBY_VERSION, the download URL and the SHA256 checksum, which you can find on the Ruby downloads page.

Now we need to build this image before Kamal builds the app image. Kamal has hooks for this, so I added a .kamal/hooks/pre-build file(don’t forget to make it executable):

#!/usr/bin/env sh
set -eu
docker build \
  --file Dockerfile.ruby \
  --tag registry.docker.com/library/ruby:4.0.9-slim \
  .

The important part here is the tag. It needs to match exactly what is in the FROM line of the app’s Dockerfile. Compiling Ruby takes a few minutes the first time, but thanks to the build cache, subsequent deploys will make this step almost instant.

If you try to deploy at this point, you will notice it still tries to pull the image from Docker Hub. This is because by default Kamal uses a docker-container buildx driver, which runs the build in a separate container with its own isolated image store, so it doesn’t see the images we have locally. To fix this, we need to tell Kamal to use the docker driver in config/deploy.yml:

builder:
  arch: amd64
  driver: docker

With the docker driver, the build runs directly on the host’s Docker daemon, so the Ruby image we just built is visible to it. The tradeoff is that this driver can’t do multi-arch builds or cross compile, so the machine you deploy from needs to match the architecture of your servers. One more reason to switch to Omarchy! But if you are on an Apple Silicon Mac deploying to amd64 servers, you would want to use a remote builder instead and run the pre-build step there.

And that’s it, now you can run kamal deploy and your app will run on the freshly released Ruby version. Once the official image is available on Docker Hub, you can delete Dockerfile.ruby and the pre-build hook, remove the driver line from the config, and you are back to the default setup.

Or follow me on Twitter

Related posts