Carl Dawson

Carl Dawson

2024-09-07

Deploying Rails applications to a single server with Kamal

Kamal is a fanastic tool for managing deployments across many different machines, but it also works very well as a way to deploy your Rails application to a single server which you can then vertically scale to your heart’s content.

I’ve used the following step-by-step guide well over a dozen times to deliver projects to clients and to launch apps of my own. It takes you from rails new to an app running alongide Postgres and Redis on a single server, with deployments managed by GitHub Actions.


Contents

  1. Creating a new Rails app
  2. Provisioning the server
  3. Configuring Kamal
  4. Bootstrapping the server
  5. Creating a CD action
  6. Adding an SSL certificate
  7. Scaling Up
  8. Conclusion

Creating a new Rails app

Let’s get started by creating a new Rails application. Here are the versions of Ruby and Rails I’ll be using:

$ ruby --version
ruby 3.3.0
$ rails --version
Rails 7.2.1

As we want to build a production-ready app that can be horizontally scaled later (if the time comes), we’ll opt for PostgreSQL for the database rather than SQLite (though MySQL will work in much the same way).

$ rails new my_app -d=postgresql

If you already have PostgreSQL and Redis running locally, you’re good to boot up the app with bin/rails server and move on to Step 2. However, if you’re like me, you might prefer to run these accessories in Docker to make things a bit easier. With Docker installed, getting the accessories up and running is as simple as creating a docker-compose.yml file in the project root:

services:
  postgres:
    image: postgres:latest
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:latest
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Now you just need to run docker compose up to bring the accessories online and update your config/database.yml to match the credentials you passed to the PostgreSQL image:

default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  host: localhost
  username: postgres
  password: postgres

(ActionCable should already be setup to use the Redis instance running on localhost in development, you can check in config/cable.yml)

Then run bin/rails db:create to create the development and test databases, and start the server with bin/rails s. If you visit localhost:3000 in your browser now, you should see the Rails welcome screen, which means everything’s connected.

If your Rails app includes Tailwind or a JavaScript build-step, your app will include a Procfile.dev, to which you can add the following line:

accessories: docker compose up

That way, when you boot the development server with bin/dev, your accessories will also be booted (providing the Docker engine has been started).

With the app initialised, let’s add some functionality so that we can see the app working when it’s deployed (as the Rails welcome screen won’t show in production):

Create a Pages controller:

$ bin/rails g controller Pages index

Add a welcome message to the generated view:

<h1>Hello, Kamal!</h1>

Update your config/routes.rb:

root "pages#index"

Now your ready to deploy your app.

(Note: If you’re following along exactly and have implemented static functionality as above, you’ll need to run bin/rails db:migrate to generate a schema.rb file, otherwise the db:test:prepare call will fail in CI.)

Provisioning the server

Kamal uses SSH to connect to our servers, and as we’ll be using GitHub Actions for deployment, we’ll need to store an SSH key in the Actions Secrets. For this reason, I prefer to generate a new SSH key for each project (just make sure you don’t set a passphrase!):

$ ssh-keygen -C my_app -f ~/.ssh/my_app

Now provision a Ubuntu server with your preferred provider and add the public key you just generated during setup (cat ~/.ssh/my_app.pub). When the server is ready, it’s probably a good idea to check that you can connect to the server locally with SSH before proceeding:

$ ssh -i ~/.ssh/my_app [email protected]

Configuring Kamal

To get started with Kamal, we need the gem installed globally (gem install kamal). Then we can run kamal init in our project directory which will create the deploy.yml file, a .env template and the .kamal directory.

To ensure all our containers can communicate with one another, we’ll use a Docker network (which also has the added benefit of preventing external access to these services and ports). To create the network, we just need to rename the .kamal/hooks/docker-setup.sample file to .kamal/hooks/docker-setup. As you can see, this hook creates a Docker network called ‘kamal’:

#!/usr/bin/env ruby

hosts = ENV["KAMAL_HOSTS"].split(",")

hosts.each do |ip|
  destination = "root@#{ip}"
  puts "Creating a Docker network \"kamal\" on #{destination}"
  `ssh #{destination} docker network create kamal`
end

This hook will be executed (providing the ‘.sample’ extension is removed) after Docker has been installed on the server, which will happen when we use the kamal server bootstrap command shortly.

Now we can create our deploy.yml file:

service: my-app

image: registry_user/my-app

registry:
  server: ghcr.io
  username: registry_user
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  cache:
    type: registry
    options: mode=max

servers:
  web:
    hosts:
      - MY.SERVER.IP
    options:
      network: "kamal"

  job:
    hosts:
      - MY.SERVER.IP
    cmd: bundle exec sidekiq
    options:
      network: "kamal"

env:
  clear:
    POSTGRES_HOST: "my-app-db"
    REDIS_URL: "redis://my-app-redis:6379/0"
  secret:
    - RAILS_MASTER_KEY
    - POSTGRES_PASSWORD

accessories:
  db:
    image: postgres:latest
    host: MY.SERVER.IP
    env:
      clear:
        POSTGRES_USER: "my_app"
        POSTGRES_DB: "my_app_production"
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data
    options:
      network: "kamal"
  
  redis:
    image: redis:latest
    host: MY.SERVER.IP
    directories:
      - data:/data
    options:
      network: "kamal"

traefik:
  options:
    network: "kamal"

Note that we’re using the Docker network-ed names when we construct the environment variables for the POSTGRES_HOST and the REDIS_URL. It’s also important to point out that we have to add the “kamal” network to the Traefik container, or else Traefik won’t be able to communicate with our application container and our app won’t work.

Make sure you replace MY.SERVER.IP with the IP of the server you just provisioned, registry_user with your GitHub account name, and my-app (and my_app) with your desired application name.

(You may have noticed that the job container runs a Sidekiq process, if you also want to use Sidekiq, you’ll just have to install the gem by adding it to your Gemfile and running bundle.)

In the deploy.yml file, we’ve asked the image builder to cache intermediate layers (with mode=max) in our container registry, which will reduce subsequent deployment time by a lot (~5 minutes compared with ~25 minutes).

As we’re not using the DATABASE_URL environment variable to connect to our database in production, we also need to tweak the config/database.yml file to match our deployment configuration:

production:
  <<: *default
  host: <%= ENV["POSTGRES_HOST"] %>
  database: my_app_production
  username: my_app
  password: <%= ENV["POSTGRES_PASSWORD"] %>

Lastly, let’s create a db/production.sql file which Kamal will execute on the database container when it is created:

CREATE DATABASE my_app_production;

And disable forced SSL (for now) in config/environments/production.rb:

config.force_ssl = false

Now our app is ready to deploy.

Bootstrapping the server

As we want our app to build and deploy via GitHub Actions, we won’t use kamal setup here. Instead we’ll just get our servers ready for deployment by installing Docker and booting the accessories.

So Kamal can find the SSH key necessary to connect to the server, let’s add it to our SSH Agent:

$ ssh-add ~/.ssh/my_app

Then we can get Kamal to install Docker on the server:

$ kamal server bootstrap

Next, update your .env file with your RAILS_MASTER_KEY (from config/master.key), your KAMAL_REGISTRY_PASSWORD (if using the GitHub Container Repository, create a personal access token) and a POSTGRES_PASSWORD of you choice:

KAMAL_REGISTRY_PASSWORD=my_personal_access_token
RAILS_MASTER_KEY=my_master_key
POSTGRES_PASSWORD=my_secure_password

Then, sync up the enviroment variables with our server and boot the accessories:

$ kamal env push
$ kamal accessory boot all

Creating a CD action

We’re almost done with the basic setup of our production-ready deployment. Now all we need to do is add a CD workflow which builds and deploys our app when we push to the main branch (which also includes merging pull requests to main from other branches).

Our CD workflow is really quite simple. It checks out the code, installs Ruby, installs the Kamal gem and then calls the kamal deploy command:

name: CD

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with: 
          ruby-version: .ruby-version
          bundler-cache: true
      
      - name: Install Kamal
        run: gem install kamal

      - uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Deploy using Kamal
        run: kamal deploy
        env:
          RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
          KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }} 

Navigate to your GitHub repo and add the necessary Actions Secrets (Settings -> Security -> Secrets and variables). You can view the SSH key you created with:

$ cat ~/.ssh/my_app

Now, if all has gone according to plan, when you push your changes to GitHub, your app should build and deploy and be visible when you visit your server’s IP in your browser - give it a try!

Adding an SSL certificate

Now we know our app is running and accessible, let’s make it secure by having Traefik manage SSL certificate provisioning.

Before we do that, we’ll need to register a domain and point it to our server (as Traefik needs to know which domain we’ll be using as a host to set up a router).

Then, we’ll undo the change we made before and force SSL in production and push to main to redeploy the application.

# config/environments/production.rb
config.force_ssl = true

Our Rails app will now attempt to redirect any HTTP traffic to HTTPS, which our Traefik instance doesn’t yet accept, as it hasn’t provisioned a certificate. This means that the app will no longer load, so let’s fix it.

The first thing we need to do is connect to our server and create the file that will be used to store the certificates:

$ ssh -i ~/.ssh/my_app [email protected]
$ mkdir -p /letsencrypt
$ touch /letsencrypt/acme.json
$ chmod 600 /letsencrypt/acme.json

Now we need to edit the deploy.yml to add labels to our application container and configure Traefik to accept only HTTPS connections. First the application container:

servers:
  web:
    hosts:
      - MY.SERVER.IP
    options:
      network: "kamal"
    labels:
      traefik.http.routers.my_app.entrypoints: websecure
      traefik.http.routers.my_app.rule: Host(`my.domain.com`)
      traefik.http.routers.my_app.tls.certresolver: letsencrypt

And now the Traefik container:

traefik:
  options:
    network: "kamal"
    publish: "443:443"
    volume:
      - "/letsencrypt/acme.json:/letsencrypt/acme.json"
  args:
    entryPoints.web.address: ":80"
    entryPoints.websecure.address: ":443"
    entryPoints.web.http.redirections.entryPoint.to: websecure
    entryPoints.web.http.redirections.entryPoint.scheme: https
    entryPoints.web.http.redirections.entrypoint.permanent: true
    certificatesResolvers.letsencrypt.acme.email: "[email protected]"
    certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
    certificatesResolvers.letsencrypt.acme.httpchallenge: true
    certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web

As we have added lables to our application container, we’ll need to redeploy as labels cannot be changed after a container is started.

We’ll also need to reboot Traefik so that our new arguments are passed to that container:

$ kamal traefik reboot

Now you can trigger another deployment by pushing your changes to GitHub. Once the CD Action has completed, you should be able to access your web application at the specified URL (with HTTPS only).

(Note: if you’re using Cloudflare, ensure that your TLS setting is ‘Full’ otherwise Cloudflare will try to access your server via HTTP and your server will deny the connection.)

Scaling Up

If and when it comes time to scale your web application, you can do so by throwing more RAM and CPU power at the server the application is already deployed to. Or, you can opt to have multiple servers run your application, which is as simple as adding / changing the IP addresses listed in the deploy.yml file.

If you do choose to migrate to a multi-server set up, I’d recommend leaving the database on the original server, and that way you don’t have to deal with any database migrations!

Conclusion

This (lengthy) post detailed how you can run your entire infrastructure for a production-ready Rails application on a single low-cost server. In my opinion, this is the way all new Rails projects should be deployed, as scaling things up is so simple (and cost-effective) when the time comes.

In a future post, I’ll talk about how to monitor your Kamal-deployed projects so you can know when an application could use more resources. Thanks for reading!