workflow

Automate Rails deployments with GitHub Actions

A continuous deployment recipe than can be applied to Tomo, Kamal, Capistrano, and other SSH-based CLI tools.

I deploy all my Rails apps using Tomo1, a Ruby command-line tool I developed. Like Kamal, Capistrano, Mina and other similar gems, Tomo orchestrates deployments by executing scripts over SSH.

Recently, I switched one of my Tomo-equipped Rails projects to GitHub Actions. My goal was to set up a continuous deployment workflow that would be triggered on every successful push to the main branch.

Flowchart showing push → CI → deploy (SSH) → remote host
This continuous deployment process is pretty straightforward. The tricky part is getting SSH to work.

Although I’m using Tomo, the majority of this tutorial applies to any SSH-based deployment (Capistrano, Kamal, etc.). Read on to see how you can set up something similar for your project.

Setting up SSH keys

Deployment tools like Kamal, Capistrano, and Tomo authenticate with the remote host via SSH. Before setting up a GitHub Actions workflow, you’ll first need to do the following:

  1. Generate an SSH key pair
  2. Add the public key to the remote host
  3. Add the public key as a GitHub deploy key
  4. Add the private key as a GitHub secret

1. Generate an SSH key pair

$ ssh-keygen -t ed25519 -f github-actions_ed25519 -N "" -C "github-actions@example.com"
This will create a github-actions_ed25519 and github-actions_ed25519.pub key pair with no passphrase. I recommend using an email address to make the key easy to identify in case you need to revoke it later.

2. Add the public key to the remote host

To make this key pair valid for authentication, you need to append the public key to authorized_keys on the remote host (i.e. the deployment target).

$ ssh-copy-id -i github-actions_ed25519 user@host
One way of copying the public key to the remote host is by using the ssh-copy-id utility included in macOS. You can also manually SSH into the host and append to the ~/.ssh/authorized_keys file.

3. Add the public key as a GitHub deploy key

Create a deploy key in the GitHub UI (repository settings → security → deploy keys), pasting in the contents of the public key. This allows the remote host to clone and fetch the repository when SSH agent forwarding is used (this is the default behavior of Capistrano and Tomo).

Screenshot of GitHub deploy keys

4. Add the private key as a GitHub secret

To make the private key available to the GitHub Actions workflow, create a repository secret (repository settings → security → secrets and variables → actions) named something like TOMO_DEPLOY_PRIVATE_KEY with the contents of the private key.

Screenshot of GitHub Actions secrets

Creating a deploy job

With the key pair generated and in the appropriate locations, you can now write a job in your GitHub Actions workflow that runs an SSH-based deployment command like kamal deploy, tomo deploy or cap deploy.

The important step is to use an action like webfactory/ssh-agent to load the private key from the GitHub secret into the SSH agent. Here’s an example:

- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
  with:
    bundler-cache: true
- uses: webfactory/ssh-agent@v0.8.0
  with:
    ssh-private-key: ${{ secrets.TOMO_DEPLOY_PRIVATE_KEY }}
- run: bundle exec tomo deploy --color -s git_ref=$GITHUB_SHA
Loading the private key via webfactory/ssh-agent subsequently allows the tomo deploy command to connect to the remote host via password-less SSH.

Be specific about what commit is deployed

Git-based deployment tools like Tomo and Capistrano are often configured to deploy a certain branch, like main. For continuous deployment, this is a little too vague. A branch is a moving target, especially on a team that is frequently merging PRs and pushing commits.

Where possible, deploy the specific commit associated with the workflow. This will ensure that the commit that triggered the continuous deployment job is exactly the commit that gets deployed. In GitHub Actions, you can reference this commit using $GITHUB_SHA.

With Tomo, specifying the commit can be achieved via command-line option:

tomo deploy -s git_ref=$GITHUB_SHA
The git_ref setting overrides Tomo’s default deployment branch name with a specific commit.

Putting it all together

To trigger the deploy job, you’ll need to place it in an appropriate workflow. Here’s an example:

name: Deploy
on:
  workflow_run:
    branches:
      - main
    workflows:
      - CI
    types:
      - completed
concurrency:
  group: production
jobs:
  tomo:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - uses: actions/checkout@v3
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.TOMO_DEPLOY_PRIVATE_KEY }}
      - run: bundle exec tomo deploy --color -s git_ref=$GITHUB_SHA
.github/workflows/deploy.yml
This GitHub Actions workflow automatically deploys a Rails app whenever CI checks pass on the main branch.

Let’s go over the interesting parts of this workflow.

Triggering the workflow

First, this block at the top describes how the deploy workflow is triggered:

on:
  workflow_run:
    branches:
      - main
    workflows:
      - CI
    types:
      - completed

The simple way of understanding this rule is:

Run this workflow only on the main branch, after the workflow named CI has completed.

The implementation of the CI workflow is outside the scope of this article, but suffice it to say it should run unit tests, system tests, lint checks, and so on. To trigger the deploy, it needs to have name: "CI" and should live in github/workflows/ci.yml.

Here’s a snippet of an example CI workflow:

name: CI
on:
  pull_request:
  push:
    branches:
      - main
jobs:
  # run tests, linting, etc.
.github/workflows/ci.yml
CI jobs can be defined in a separate workflow. When this workflow completes on the main branch, it will trigger the deploy workflow to run.

Limiting concurrency

Running two deploys at once, targeting the same environment, could lead to errors or a corrupted deployment. This snippet prevents that:

concurrency:
  group: production

In practice, production can be whatever string you want. GitHub will ensure that all workflows that use the same group are executed one at a time. This means deploys will never overlap, even if multiple pushes to main are made in a short amount of time.

Deploying only if CI succeeds

The deploy workflow is triggered whenever the CI workflow completes, but this is not the same as saying the workflow succeeded. To check that the CI workflow completed successfully, you’ll need this additional condition:

if: ${{ github.event.workflow_run.conclusion == 'success' }}

Using known_hosts for additional security

SSH tools often default to lenient host key verification for a smoother developer experience, but this is not ideal for automated deployments. Each workflow execution starts as a blank slate, meaning SSH doesn’t have any information in order to know whether to trust remote host’s identity.

One way to improve security is to add the public key of the remote host to the known_hosts file up-front, before running the deploy command. During the deploy, if the expected and actual keys don’t match, SSH authentication will fail.

The public key of a remote SSH host can be obtained using ssh-keyscan. For example, here is the key for github.com:

$ ssh-keyscan -t ed25519 github.com
# github.com:22 SSH-2.0-babeld-dc5ec9be
github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl

A host’s public key can be added to the trusted known_hosts file in a workflow step like this:

- run: echo '1.2.3.4 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF1NVNx4YSNeykS2LyC9cQRsCTrLmV4qAuokVp76X1zl' >> ~/.ssh/known_hosts
In this case 1.2.3.4 is the hypothetical address of host being deployed to. Adding its public key to the known_hosts file ahead of time can prevent an attacker from impersonating that host.

Now you can lock down your SSH deployment tool to only trust the hosts keys that are explicitly listed in known_hosts. For example, in Tomo this is accomplished as follows:

set ssh_strict_host_key_checking: true
.tomo/config.rb
This is equivalent to passing -o StrictHostKeyChecking=yes when using the ssh command directly.

Conclusion

Whatever deployment tool or hosting platform you use, automating deployments is a great way to speed up software delivery and reduce the cycle time for feedback. It removes the ceremony around deploys and makes them an implicit and non-negotiable part of merging PRs and pushing code.

GitHub Actions is an easy way to set this up. I hope you found some tips and tricks from this article that you can take to your next Rails project!


  1. Check out Deploying Rails From Scratch With Tomo for an introductory tutorial. 

Share this? Copy link

Feedback? Email me!

Hi! 👋 I’m Matt Brictson, a software engineer in San Francisco. This site is my excuse to practice UI design, fuss over CSS, and share my interest in open source. I blog about Rails, design patterns, and other development topics.

Recent articles

RSS
View all posts →

Open source projects

mattbrictson/rails-template

App template for Rails 7 projects; best practices for TDD, security, deployment, and developer productivity. Now with optional Vite integration! ⚡️

1,055
Updated 1 month ago

mattbrictson/tomo

A friendly CLI for deploying Rails apps ✨

360
Updated 20 days ago

More on GitHub →