Dockerfile vs. Buildpacks with Rails 8 and Kamal
I was curious what the final image size and deploy times would be when you utilize the default Dockerfile that comes with Rails 8 vs. the new Buildpacks functionality that was released in Kamal(2.7.0), figured I'd run a few deploys with both.
My interest in buildpacks initially stemmed from moving between projects and needing to make an update to a Dockerfile. When comparing the projects, there were a few different ways to handle your gem cache, your package cache, and remembering which packages were needed between stages. These were all artifacts of the application that didn't really add new features or bring much value to the application when we ultimately just want to be able to deploy our application.
Rails new
Let's install the latest version of Rails(8.0.2) and deploy with both options and see the results. I've added a few tests to highlight typical workflows, such as running the initial kamal setup
, making a code change to your application, and installing a new gem.
$ rails -v
Rails 8.0.2
$ ruby -v
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24]
$ rails new eight
$ cd eight
Dockerfile
First up, let's deploy using the out-of-the-box Dockerfile and Kamal setup.
With a new Rails 8.0.2 app, we’ll go ahead and update config/deploy.yml with a few app-specific details and point it to a server. I updated the default config/deploy.yml file with the following options:
- image - Updated to my Docker Hub handle.
- servers -> web - Pointed it to a server on Digital Ocean(s-1vcpu-2gb, ubuntu-24-10-x64).
- proxy -> host - Pointed(proxied, SSL set to full) a temporary CNAME at the IP address via Cloudflare.
With those options updated, I then committed everything so that the changes were prepped for the deploy and started the deploy.
$ kamal setup
....Kamal installing Docker, building the Dockerfile and deploying...
Finished all in 119.5 seconds
A full setup and deploy in just under two minutes, that’s great, feels like the Capistrano days of power and simplicity. About 60 seconds of the setup is just installing Docker, which is to be expected; you can work around that by using an image that already has Docker if it really matters to you. On Digital Ocean, the Docker image is still running on Ubuntu 20 instead of Ubuntu 24 so I opted for just a vanilla Ubuntu 24 image. If you take out those 60 seconds though, you're set up and live in just about a minute, neat. Installing Docker is a Kamal-specific step that happens with both approaches as well.
It’s really great to see that Rails out of the box can easily be built and deployed into a container in just a few minutes. Rails 8 is a huge step forward for deployments. RIP FastCGI, Webrick - for those who remember the good old days.
Just for good measure, let's add a change to trigger a rebuild and deploy our app again.
I added a helper method to ApplicationHelper:
module ApplicationHelper
def hello
"world"
end
end
$ kamal deploy
...Deploying...
Finished all in 107.0 seconds
You'll notice with this deploy that we get to leverage some of our Docker build cache since we've already deployed this app with our initial kamal setup
.
Now let's add a new gem to our Gemfile. Lets add mission_control-jobs
so we have a dashboard for solid_queue
.
$ bundle add mission_control-jobs
$ kamal deploy
...Deploying...
Finished all in 212.3 seconds
This change took a little bit longer because we had to reinstall all of our gems. Adding the Gem broke the build cache since the Gemfile.lock changed. This is expected, and you can workaround this by adding a few more cache lines like this to your Dockerfile for the bundle install step. With a cache step like this in place, it would then just install the mission_control-jobs
and find the gems that have already been installed via our build cache.
RUN --mount=type=cache,id=gem-cache-3.3,sharing=locked,target=/srv/vendor \
find /srv/vendor -type d -wholename 'ruby/3.3.0' -delete && \
bundle config set app_config .bundle && \
bundle config set path /srv/vendor && \
mkdir -p vendor && \
bundle config set --local path vendor && \
cp -ar /srv/vendor . && \
rm -rf vendor/ruby/*/cache vendor/ruby/*/bundler/gems/*/.git && \
find vendor/ruby/*/gems/ -name "*.c" -delete && \
find vendor/ruby/*/gems/ -name "*.o" -delete
This is one one approach to cache your gems, there are many
It’s also helpful to look at the final image size since this will impact deploy time slightly based on how long it takes to push and pull the image to your servers. The compressed size in Docker Hub comes to 219.79 MB, which is pretty good for a starting Rails application.
Buildpacks
With a Dockerfile based deploy via Kamal complete, I went ahead and rebuilt the server with the same image so we can start fresh. I also removed the mission_control-jobs
gem so that we can add it again and test with buildpacks as well as the ApplicationHelper
method.
Here are the additional changes I made to deploy via Buildpacks:
- Add a Procfile with
web: ./bin/docker-entrypoint ./bin/thrust ./bin/rails server.
If you look at the default Dockerfile, you’ll notice this is combining theENTRYPOINT
andCMD
. - Update
asset_path
to point to the /workspace directory instead of /rails -asset_path: /workspace/public/assets
. You'll notice this in the precompile output(Storing cache for /workspace/public/assets
) that the assets are saved here instead of /rails. - env -> clear - Set the port to 80 for kamal-proxy -
PORT: 80
. You could also set the proxy -> app_port to 3000, your call. - Specify the builder and buildpacks needed for the Pack CLI.
builder:
arch: amd64
pack:
builder: heroku/builder:24
buildpacks:
- heroku/ruby
- heroku/procfile
We could remove the Dockerfile at this point since it’s no longer needed and to ensure it’s not somehow being used anywhere but that’s not necessary. It's important to note though, that you don't need a Dockerfile or something to describe how your app is built; with the correct buildpacks in place it just builds.
With our buildpacks configuration in place, we can go ahead and set up the server.
$ kamal setup
....Kamal installing Docker, building the Dockerfile and deploying...
Finished all in 203.5 seconds
A little over a minute slower than using the Dockerfile approach.
Next, we’ll make the code change to trigger a rebuild of our application by adding the hello
method to our ApplicationHelper like we did previously.
$ kamal deploy
...Deploying...
Finished all in 71.7s
This is about a 3o second savings compared to using a Dockerfile.
Lastly, let's add back the mission_control-jobs
gem and deploy.
$ bundle add mission_control-jobs
$ kamal deploy
...Deploying...
Finished all in 124.3 seconds
This saves us a little over a minute compared to using a Dockerfile.
The compressed Docker Hub image size for the buildpacks version is 211.37 MB, nothing substantial but slightly under the default Dockerfile build.
Recap
Here's an overview of the deploy times and image sizes for the two different approaches, a couple of things to look at:
- The initial setup time for buildpacks is a bit longer, which I believe is due to the buildpack image being a bit larger than the
docker.io/library/ruby
base image. The buildpack image includes a bunch of pre-installed Ubuntu packages to help with the build phase instead of downloading additional packages when building. - The difference in compressed image size is negligible.
- Buildpacks really help out with a few additional caching steps that it automatically detects and takes care of for you. You'll notice this with code changes and updates to your Gemfile.
kamal setup | Code change | Gemfile change | Compressed image size | |
---|---|---|---|---|
Dockerfile | 119.5s | 107.0s | 212.3s | 219.79 MB |
Buildpacks | 203.5s | 71.7s | 124.3s | 211.37 MB |
Using a fully customizable Dockerfile is great; it'll often be the tool you reach for, especially if you've already gone through the work of fine-tuning your Dockerfile.
There are other times though, where you might not need as much customization, and buildpacks have already figured out all of the "fun" stuff. Or maybe you do need more customization and you can reach for any of the existing Heroku and Paketo buildpacks to handle those additional changes.
It's also nice to be able to deploy your application Heroku-style and let the buildpack pack things up for you.