Have you got your application successfully deployed using Kamal and GitHub Actions but the deploy time still seems a bit too much? Depending on what changes you are trying to push to the server, ”a few minute“ deploys should be perfectly possible with GitHub Actions, even shorter ones:
Let’s take a look at some ways you can tweak your workflow to speed things up. This post was inspired by the nice conversations with Adrien on BlueSky and I also wanted to share our experience with optimizing our own GitHub Actions deploy flow that we went through recently. Part of this post will be Ruby on Rails–specific, the rest should be rather universal.
Deploying with Kamal involves building a Docker image, a process with its own set of intricate rules. There are many best practices around optimizing the Dockerfile itself and/or the building process but as they are not Kamal nor GitHub Actions–specific, we won’t cover them here. Besides, the default Ruby on Rails Dockerfile is already pretty optimized. So what other options do we have?
Cash everything you can
While I never recommend employing caching as the first step of optimizing things, Docker is an exception. The Docker image format is well suited for caching, the cached layers are immutable and their invalidation strategy seems simple and robust. Therefore, we really want to cache the builds right after we get our deployment workflow working.
Caching Docker build images
To cache build image layers, we need to tweak two things, one of them is the Kamal config:
# config/deploy.yml
builder:
cache:
type: gha
options: mode=max
image: kamal-app-build-cache
Since GitHub offers a cache storage back-end supported by Docker, we use the gha
cache type so that the cache storage is as close to our runners as possible. The mode=max
option instructs Docker to cache even the intermediate build layers, not only those exported to the final image. And we also give our build image some (arbitrary) name.
But in the context of GitHub Actions, this is not yet sufficient for caching to work. If you looked at the Actions ⟶ Caches tab in your GitHub repository, it would still be empty. How come?
By default, Kamal uses the docker-container
driver to build images which, in turn, uses the BuildKit toolkit internally. While Kamal sets up registry caching correctly, caching still fails in the end because the BuildKit process is isolated from our GitHub Action runtime process. To connect the two, we need to expose the GitHub runtime to the workflow. Luckily, there is a GitHub Action ready just for this so all that is needed is adding the action to the workflow file. We put it right after setting up Docker Buildx:
# .github/workflow/deploy.yml
jobs:
deploy:
steps:
...
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Expose GitHub Runtime for cache
uses: crazy-max/ghaction-github-runtime@v3
If you run the build action again, you should start seeing "buildkit" entries in the Actions tab ⟶ Caches in your GitHub repository:
More importantly, your builds should now be running about twice as fast!
Caching application dependencies
There is one more thing that we can cache – application dependencies, i.e. the libraries it uses, etc. GitHub Actions support caching the assets of various package managers. For ruby, caching the bundled gems is configured in the workflow file with a single bundler-cache: true
option like this:
# .github/workflow/deploy.yml
jobs:
deploy:
steps:
...
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
Respect the build architecture
While Docker allows cross–platform builds quite easily, they tend to be much slower than when building an image on the same platform as is the target server. Especially those builds that involve a lot of computational work, such as when building ruby gems with native extensions.
Most of the standard GitHub Action runners are based on the x64 architecture so if your target server is ARM-based, your options are currently quite limited. You can:
switch to one of the larger Linux arm64–based runners but these are billed separately and are not free even for public repositories,
self-host a GitHub Action runner on a server of your choice,
migrate your application to an x64–architecture server,
switch to a completely different CI service which provides ARM-based builds (out of scope for this post),
or, of course, you can bite the bullet and reconcile to waiting for cross–platform builds.
Since 2024, there actually are some standard ARM–based runners available in GitHub Actions, namely macos-14
, macos-15
, and macos-latest
that run on the Apple silicon (M1) chip. Unfortunately, this setup has its own limitations and quirks, most importantly, the M1 chip does not support nested virtualization. Since the GitHub Action runner itself is virtualized and Docker needs that too, there is effectively no way to run Docker on M1–chip runners. Until GitHub releases hosted runners based on the newest M3 Apple silicon chips (which allegedly do support nested virtualization), there won’t be a feasible way to deploy to ARM-based servers natively using MacOS runners. In other words, until then, standard GitHub Action runners are best suited for deploying to x64 architecture servers only.
Don’t build multi-platform images unless you really need them
Unless you have multiple target servers that are mixed in their architectures, do not build multi-platform images. Building images for multiple architectures is inherently slow and there isn’t much you can do about it.
So, be sure you have a single arch
specified in the builder
section of Kamal config. For reasons mentioned above, preferably amd64
:
# config/deploy.yml
builder:
arch: amd64
Parallelize building native gems (misc tweak)
We have also tested whether Bundler is able to determine the number of processors in a GitHub Action run so that it can parallelize gem downloads and builds (see its --jobs
option) and yes, it works great without having to configure anything.
This is not the case though when Bundler builds gems with native extensions. Usually, it calls make
to compile the C extensions but these calls are not parallelized out of the box. So if you build a lot of gems with native extensions, you might want to define an environment variable that instructs make
to run multiple compile jobs in parallel:
MAKE="make -j4"
where 4
is the number of processors available in your runner or even slightly more, you can experiment with that.
Watch your runners performance
There is a new feature that has been released by GitHub just a few days ago: a place to monitor the performance statistics of your GitHub Actions. To view them, go to Insights ⟶ Actions Performance Metrics in your repository and you will see something like the following:
Just keep on mind that the numbers here are average numbers. In the case of deployment workflows the run time usually differs quite a lot based on whether gems need to be re-bundled, assets recompiled or not.
Conclusion
GitHub Actions are a specific CI/CD environment and it is quite easy to unknowingly create a Kamal deployment workflow that will under-perform in it. We shared a few tips that could help leveling up the runners speed so that we don’t have to wait for our deploys for longer than necessary.
Want more stuff like this? Follow us here or at BlueSky 🦋.
Top comments (0)