Deploy a Rails Template with Kamal

Set up and deploy a Rails application template with Kamal.

Untitled is my Rails template that I’ve been maintaining and keeping up-to-date for over four years now. With the release of Rails 8 including Kamal I wanted to document the full setup process, since continuous and local deployments require a bit more work beyond cloning the repository. Follow along to deploy an application to staging and production with database replication for backups and continuous deployment with GitHub Actions.

Preparing the Configuration

To start, we’re going to customize the deployment configuration, set up the GitHub container registry, and set up a required secret.

Setting the Hosts

The template is set up to deploy a production host and optionally a staging host, so let’s customize the deployment configuration files. If you’d like a staging host uncomment and set a host in the config/deploy.yml file.

proxy:
ssl: true
- # host: untitled-staging.tristandunn.com
+ host: untitled-staging.tristandunn.com
healthcheck:
path: /health
Enabling a staging host in the config/deploy.yml file.

Other destinations in Kamal inherit from the base deployment configuration, so we only need to set the production host there.

proxy:
host: untitled.tristandunn.com
Setting a production host in the config/deploy.production.yml file.

Choosing a Container Registry

By default Kamal uses Docker Hub for the container registry, but I’d prefer to use GitHub instead. If you would like to use GitHub too, you need to create a token with the write:packages scope. The token goes in our .env file under the KAMAL_REGISTRY_PASSWORD variable.

KAMAL_REGISTRY_PASSWORD="GITHUB_TOKEN"
Setting the registry password in the .env file.

If you’d prefer to stick with Docker Hub, or use AWS or GCP, see the official Docker registry documentation for more information.

Defining Local Secrets

We also need a base secret key for Rails, which you can generate by running the bin/rails secret command and adding it to the same file.

KAMAL_REGISTRY_PASSWORD="GITHUB_TOKEN"
SECRET_KEY_BASE="RANDOM_SECRET"
Setting the secret key base in the .env file.

That should be enough configuration for you to deploy to a staging host, but if your application requires any other secret environment variables you should add them as well.

Local Deployment

We’re going to start by deploying from a local machine. You’ll need a server to deploy to, which can be any server capable of running Docker. I’m using DigitalOcean since I was deploying with Dokku there already.

The link to DigitalOcean is a referral link which will give you $200 in credit over 60 days and if you eventually spend $25 it will give me $25 in credit.

Creating and Configuring a Server

If you’re only using a production environment then a server with 2GB of memory should work to start, but if you’re running a staging environment on the same machine I’d recommend 4GB of memory or more. With real production traffic, you may also want more memory regardless of the number of environments. If you’re not familiar with creating a server on DigitalOcean, see the quickstart guide for details.

If you’d prefer to automate the creation, the repository includes Terraform configuration in the config/terraform directory, which can be used via the bin/terraform executable. The configuration will create a 2GB server in the NYC3 region and create a storage bucket in the same region.

After the server is created, you’ll need to copy the IP address and add it to the config/deploy.yml file for the web server.

# Deploy to these servers.
servers:
web:
- 192.168.0.1
Updating the IP address for the web server in the config/deploy.yml file.

You’ll also want to add DNS entries for your staging and production hosts to point to the same IP address.

Deploying to Staging

With Docker running we can set up our first Kamal application, which will install the required dependencies, such as Docker, on the remote server and deploy the application to the staging host.

$ eval $(cat .env) bin/kamal setup

Note that we’re evaluating the secrets in the .env file to make them available to Kamal. See the environment variable documentation and how to switch to a secret helper for more information.

Adding Database Replication

Litestream continuously streams SQLite changes to fully replicate the database. It’s basically a drop-in backup solution with litestream-ruby running inside the Puma server. And it works with an assortment of storage providers, including the S3-compatible Spaces from DigitalOcean that we’re going to use.

After creating a bucket take note of the origin endpoint. To access the bucket we need to create an access key which will provide an access key and secret. We then add all three to the .env file.

KAMAL_REGISTRY_PASSWORD="GITHUB_TOKEN"
LITESTREAM_ACCESS_KEY_ID="SPACES_ACCESS_KEY_ID"
LITESTREAM_REPLICA_HOST="SPACES_HOSTNAME"
LITESTREAM_SECRET_ACCESS_KEY="SPACES_ACCESS_KEY_SECRET"
SECRET_KEY_BASE="RANDOM_SECRET"
Setting the Litestream options in the .env file.

Also, be sure to not include https:// in the host.

Deploying to Production

We’re now ready to deploy to production. If we’ve already set up the staging host we can use the deploy command, otherwise we need to use the setup command here with production as the destination.

# If we deployed to staging.
$ eval $(cat .env) bin/kamal deploy --destination=production
# If we are only deploying to production.
$ eval $(cat .env) bin/kamal setup --destination=production

Continuous Deployment

Manually deploying is nice, but continuously deploying is even nicer. By default the template provides a workflow to deploy to the production destination when merged to the main branch. If you’d like to remove or customize the destination you can update the DESTINATION environment variable in the workflow.

Secrets for GitHub Actions

We need to mirror the variables in our .env file so GitHub Actions can build and deploy the project. You can add secrets specifically for your project under Settings -> Secrets and variables -> Actions.

The first two are to ensure it has access to your server and that Rails has a secret key base.

If you’re using Litestream you also need to add the following:

If you’re using the GitHub Container Registry then it automatically uses the provided GITHUB_TOKEN for the KAMAL_REGISTRY_PASSWORD, but if you’re using another registry you should set it as well and update the variable in the .github/workflows/cd.yml file.

Package Linking

Once you’ve created the package once in the GitHub Container Registry it’s helpful to link it to the repository with administrative access. You should be able to find the package under the “Packages” tab on your profile. Then under the “Package Settings” you can use “Add Repository” to associate your repository and grant it an administrative role. Write access will allow the action to update the package, but it’s helpful to have administrative access if you add a clean up action to delete old images to conserve space.

Deployment

If everything is set up correctly your next push to main should successfully deploy. If you want to try things out in staging first, you can remove the destination and adjust the workflow to trigger on any branch.

Improvements

While we have an application deployed in production, there are a couple of quality-of-life improvements we can make.

Using a Kamal Alias

Loading the environment variables makes the deployment commands pretty verbose. Instead, let’s use a shell alias to handle it all.

# Create a function to ensure Kamal and the environment variables are
# present, if an .env file is present.
kamal_command() {
if [ -f bin/kamal ]; then
if [ -f .env ]; then
eval $(cat .env) bin/kamal "$@"
else
bin/kamal "$@"
fi
else
echo "No bin/kamal script found."
fi
}
# Add a "kamal" alias for the function.
alias kamal="kamal_command"
Adding an alias to make deployments even easier.

Now deploying is as easy as running kamal deploy without having to manually include the secrets.

Adding a Litestream Dashboard

You can add a simple Litestream dashboard with a single line, but it’s recommended you add authentication to prevent unauthorized access.

# frozen_string_literal: true
Rails.application.routes.draw do
mount Litestream::Engine, at: "/litestream"
# ...
end
Mounting the Litestream dashboard in the config/routes.rb file.

Now you can check in on the replication process by visiting /litestream on your production domain.

Adjusting the Destinations

If we’re never planning to deploy to a production host, we can move the production destination configuration into the config/deploy.yml file. Now we don’t have to provide a destination when deploying.

One reason to keep it separate even with a single host is that it requires you to be a bit more verbose when manually deploying. Although if we’re using the continuous deployment workflow included that should never be required.

Conclusion

While there’s a bit more setup to start with Kamal compared to Dokku, for some reason it feels like there’s less magic to me. Kamal also comes with added bonus of easier multi-server usage, somewhat having the Rails community behind it, and an easier path forward when you (hopefully) need to scale up.