Deploying with Kamal using a locally built Ruby image
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 $99Yesterday, 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.