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.
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
- Creating a deploy job
- Putting it all together
- Using known_hosts for additional security
- Conclusion
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:
- Generate an SSH key pair
- Add the public key to the remote host
- Add the public key as a GitHub deploy key
- 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"
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
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).
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.
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.9.0
with:
ssh-private-key: ${{ secrets.TOMO_DEPLOY_PRIVATE_KEY }}
- run: bundle exec tomo deploy --color -s git_ref=$GITHUB_SHA
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
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.9.0
with:
ssh-private-key: ${{ secrets.TOMO_DEPLOY_PRIVATE_KEY }}
- run: bundle exec tomo deploy --color -s git_ref=$GITHUB_SHA
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
mainbranch, after the workflow namedCIhas 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.
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
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
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!
-
Check out Deploying Rails From Scratch With Tomo for an introductory tutorial. ↩