Deploying a simple Sinatra app with Kamal
Kamal has been a refreshing new tool in the container deployment landscape and I want to a quick demo to highlight it’s power and simplicity.
The intro Kamal video from DHH does a great job of showing how to deploy a Rails app with Kamal but one of the biggest hurdles to doing this is containerizing your existing Rails application if you haven’t already done so. Picking up Kamal along with containerizing your application (whether it’s a Rails app or a Symphony app) at the same time can be a bit of lift.
I’m going to highlight Kamal’s overall utility without getting too involved on the Docker/containerization side of things. At the end of the day, if you can containerize your app then Kamal can deploy it. In its simplest form Kamal is just a wrapper around a few Docker commands(build, push, pull, run) that’s easy to understand and allows you to deploy containers as easily as Capistrano.
For the Sinatra side of things we’re just going to utilize the demo app from Sinatra’s home page which just has the ‘/frank-says’ endpoint configured. If we can get ‘/frank-says’ rendering on our server then we can call this a success. While the containerization of the Sinatra app for this isn’t perfect and there’s definitely room for improvement, I just want to emphasize that the focus should be on Kamal and not Docker specifics for this. For the containerization part of this I referenced a great article from Code With Jason that walks you through how to containerize a Sinatra application and then added a few additional steps to get it working with Kamal.
To get things started with Kamal go ahead and install the Kamal gem and run kamal init
. It looks like I’m running Ruby 3.2.2 with this but any modern version should work just fine with Kamal. kamal init
is going to create a few files for us, the only one we’ll deal with is config/deploy.yml which is our deploy recipe you could say.
Once we open our config/deploy.yml file you can see that Kamal has provided us with a pretty good starting point. The first thing we’ll do is name our service, this can be whatever you’d like and will be used to name your containers and services that Kamal deploys for you. The image setting is going to be the name of the container image when it’s pushed to your registry(docker hub, GitHub container registry, etc.).
For this example I’m going to utilize Github’s container registry which you’ll notice is fetching my login details from the gh
CLI command. If you don’t have these two values(username and oauth_token) already set you can easily set these from the command line by running gh config set -h github.com username timtim
and gh config set -h github.com oauth_token ghp_x
or basically instead of utilizing get
in the CLI commands, you’re just using set
instead.
deploy.yml
# Name of your application. Used to uniquely configure containers.
service: sinatra-hello-world
# Name of the container image.
image: nickhammond/sinatra-hello-world
# Deploy to these servers.
servers:
- 192.168.0.1
registry:
server: ghcr.io
username: <%= %x(gh config get -h github.com username).strip %>
password: <%= %x(gh config get -h github.com oauth_token).strip %>
Next we can start working on our simple and rough first draft of our Dockerfile. You should be at the very least comfortable with the basics of Docker and a Dockerfile. There’s a few additional things you’ll want to dig into later with Docker such as layer caching and multi-stage images to make your image smaller. Since the focus for this is on Kamal though and we just want to get our Sinatra application running, the quick and simple approach should work just fine.
Dockerfile
ARG RUBY_VERSION=3.2.2
FROM ruby:$RUBY_VERSION-slim as base
WORKDIR /sinatra
COPY . /sinatra
RUN bundle install
EXPOSE 4567
CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "4567"]
Within our Dockerfile you can see we’re building our image based on the Docker hub Ruby 3.2.2 image, setting a working directory, copying our app into there, installing our gems, setting a port, and then running our app.
The next file we’ll create is our Sinatra app which we’ll save as app.rb. All we need in this file is that example from the Sinatra homepage for the ‘/frank-says’ endpoint. When our app is rendering we should be able to hit ‘/frank-says’ and see Put this in your pipe & smoke it!.
app.rb
require 'sinatra'
get '/frank-says' do
'Put this in your pipe & smoke it!'
end
Next we create our Gemfile so that we can run bundle install
to install Sinatra, we could probably even just do a gem install within our Dockefile instead of dealing with Bundler but it’s simple enough.
Gemfile
gem 'sinatra'
And the last file we’ll create is the config.ru which is a simple configuration file for Rack that Puma will utilize to figure out how to boot our application. In here we just need to require our app and then run our Sinatra application.
config.ru
require './app'
run Sinatra::Application
app.rb, config.ru, and Gemfile is a pretty standard Sinatra setup but we could have probably made this even simpler by putting this all in one file.
Now that we have our Sinatra application created we need to tell Kamal where we want to deploy this thing. You’ll notice there’s a servers section within our deploy.yml, for this example we’re just going to deploy to one server but we need to update the address from the example 192.168.0.1 address.
I’ll use Digital Ocean for this example but any server that you have SSH access to that can install Docker will work. With Digital Ocean I already have a key configured so my SSH access is already ready to go once I create the droplet. We’ll go ahead and name this instance sinatra-demo and then we can grab the newly created IP address and plug it into our deploy.yml file. It’s also important to note that you should harden your server by setting up the correct networking rules and upgrade maintenance.
At this point we can try to run kamal setup
which does a few things. It’s going to ensure that Docker is installed, installs traefik which will be used as a local proxy for web traffic, and a few other items.
And it looks like our setup didn’t work, easy fix though.
I wanted to keep as many of the stumbling blocks in here as possible and this one should be pretty uncommon but Kamal expects a git repository to help with naming your containers. What’s really great about this is the SHA that you see in your git history and on your repository host(GitHub, gitlab, etc.) is going to match what’s deployed on the server. This is really helpful with rollbacks, correlating what’s actually on the server, and looking at a specific image for build artifacts.
With a simple git init
we can give it another go. And this is just pure user error on my part but it also highlights that Kamal is going to build your application from your Dockerfile by default, I made a booboo though and named it DOCKERFILE, simple fix. You can also override the Dockerfile that Kamal uses if for instance you want to utilize a different file for production vs development.
Alright, we can go ahead and try to run kamal setup
again and it looks like it’s in the Docker build phase so while that’s completing I want to go add in a custom health check for our Sinatra app.
I have the documentation pulled up here for defining a custom healthcheck with Kamal, the default healthcheck looks at /up
on port 3000. If you recall though from our Dockerfile we have Sinatra booting up 4567 and the only endpoint we have defined is /frank-says
.
We’ll go ahead and copy this snippet from the documentation and swap out those details so that our healthcheck will pass properly. A quick note that this is a Docker healthcheck. So Kamal is going to configure this as the healthcheck for the Docker container so later if you run docker ps
on your server you’ll see the health state matches based on this custom healthcheck.
Healthchecks with Kamal do require that curl
is installed within our container for it to run. Not on the server but in the actual container which means it needs to be installed as part of our Dockerfile, let’s go ahead and add that in.
Dockerfile
ARG RUBY_VERSION=3.2.2
FROM ruby:$RUBY_VERSION-slim as base
WORKDIR /sinatra
COPY . /sinatra
RUN bundle install && \
apt-get install curl -y
EXPOSE 4567
CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "4567"]
Now we’ll go ahead and run kamal setup
again and oops, we forgot to set our gem source for Bundler. Simple fix, we’ll add in ruby gems and continue on.
Gemfile
source 'https://rubygems.org'
gem 'sinatra'
And let’s try another kamal setup
, looks like I’m missing a apt-get update
call within the Dockerfile to ensure we can find the curl
package. Simple enough to add that in and run kamal setup
again.
Dockerfile
ARG RUBY_VERSION=3.2.2
FROM ruby:$RUBY_VERSION-slim as base
WORKDIR /sinatra
COPY . /sinatra
RUN bundle install && \
apt-get update -qq && \
apt-get install curl -y
EXPOSE 4567
CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "4567"]
It looks like we made it through the Docker build since you’ll see the red build steps are now complete and we built out image in about 78 seconds. We can definitely make this faster but I just want to focus on the Kamal bits for this, leave the Docker optimization for another time.
We can go ahead and open up where our application will be which is at IP/frank-says
and we’ll see that the server is returning a Bad Gateway
error which is expected for now, there’s nowhere to route our request to yet.
Looks like our setup failed which was intentional but I wanted to highlight how you’ll see application level errors coming through with Kamal. Looking at the output, Sinatra needs a web server, of course. Looking at the docs you can see we can just install Puma and we should be able to boot our Sinatra app.
Gemfile
source 'https://rubygems.org'
gem 'sinatra'
gem 'puma'
Running another kamal setup
and alas, Puma has native extensions that it needs to install so we need to add build-essential
which gives us all of our C bindings.
We can get that added in and then we’ll also add —no-install-recommends
to try reduce the amount of things apt tries to install. I’ve also moved the apt steps to the beginning since those won’t change as often as our Gemfile will in the future which relates to our bundle install
command.
ARG RUBY_VERSION=3.2.2
FROM ruby:$RUBY_VERSION-slim as base
WORKDIR /sinatra
COPY . /sinatra
RUN apt-get update -qq && \
apt-get install build-essential curl -y --no-install-recommends && \
bundle install
EXPOSE 4567
CMD ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "4567"]
This should be the last tweak to get our Sinatra application live so lets run another kamal setup
and see if our container comes online. A refresh on our /frank-says
page and there we go, our Sinatra app is deployed on our Digital Ocean droplet via Kamal.