In this series, we’re going through the process of recreating a workflow similar to Heroku’s review apps for web applications using Kamal for deployments. The first article covered using Terraform to create the infrastructure for your apps through GitHub Actions, and the follow-up discussed how to use Kamal destinations to configure deployments to different environments. These two articles cover the foundation for our Kamal-powered review apps.
After creating new servers and configuring Kamal for the new environments, the next step is automatically performing deployments when opening or updating a pull request. Here is where everything begins to come together to have a usable system using your own infrastructure that you and your team can use to validate new functionality or bug fixes before merging into your main branch.
Following along with the series
During Part 1 of this series, we set up the TeamYap application with a GitHub Actions workflow file located at .github/workflows/review-app-deploy.yml
. This workflow provisions the infrastructure to use for our future review apps when opening or updating a pull request.
I’ll add a new job to that workflow called deploy
to handle the deployments using the new destination set up with Kamal and have it run on the ubuntu-latest
GitHub-hosted runner as we do with the provision
job.
I’ll also use the needs
setting to indicate that it requires the provision
job to finish successfully before attempting to run the deployment and ensure that the servers have been provisioned on our cloud service:
name: Review app deployment
on:
pull_request:
types:
- opened
- reopened
- synchronize
jobs:
provision:
# Omitting provisioning steps
deploy:
runs-on: ubuntu-latest
needs: provision
# More configuration coming soon.
Next, I’ll begin setting up the environment variables needed for the deployment to complete successfully under the env
key. For this step, we’ll need all the environment variables we configured for Kamal in the review
destination configuration file and secrets (covered in Part 2 of this series). I’ll set most of the values of these environment variables as secrets in the GitHub repository:
name: Review app deployment
on:
pull_request:
types:
- opened
- reopened
- synchronize
jobs:
provision:
# Omitting provisioning steps
deploy:
runs-on: ubuntu-latest
needs: provision
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.REVIEW_APP_KAMAL_REGISTRY_PASSWORD }}
RAILS_MASTER_KEY: ${{ secrets.REVIEW_APP_RAILS_MASTER_KEY }}
POSTGRES_USER: ${{ secrets.REVIEW_APP_POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.REVIEW_APP_POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.REVIEW_APP_POSTGRES_DB }}
REVIEW_APP_IP: #???
REVIEW_APP_HOST: #???
Naming convention used for secrets
As a side note, I’m appending REVIEW_APP_
to the name I set in the GitHub repository secrets to help me quickly view which secrets I’m using for review app deployments—you can name your secrets anything you prefer, as long as they’re set up correctly in the workflow YAML file.
Setting and Retrieving Outputs Between Jobs
The deploy
job now has the environment variables it needs set, except for the REVIEW_APP_IP
and REVIEW_APP_HOST
variables. I can’t set these as a secret in the GitHub repository because these are dynamically generated values from our cloud service when provisioning the server using Terraform. How can we retrieve this information for the deployment?
In this scenario, we need to do two things:
- We need to output the server’s IP address using Terraform.
- We need to pass the output value from the
provision
job to thedeploy
job.
The Terraform script set up for this workflow in Part 1 already outputs the provisioned server’s IPv4 address, so we don’t need to modify it. That leaves us with passing that value over between jobs.
Defining an output to pass to a dependent job
GitHub Actions jobs are isolated from each other, so we can’t get this information directly from the provision
job. However, we can output data from one job to make it available to a dependent job in the form of key-value pairs. We’ve set up the deploy
job to depend on provision
using the needs
setting so we can pass the data we need between them.
First, I’ll need to update the provision
job to include a step that will use Terraform’s output to retrieve the new server’s IP address and store it somewhere. Under the provision
job, I’ll add a new step under the steps
key to do this:
name: Review app deployment
on:
pull_request:
types:
- opened
- reopened
- synchronize
jobs:
provision:
# Omitting setup
steps:
# Omitting existing steps.
# New step: Outputting the IP address to use in the `deploy` job.
- name: Set up Terraform outputs
id: terraform-outputs
run: |
echo "review_app_ip=$(terraform output -raw ipv4_address)" >> $GITHUB_OUTPUT
deploy:
# Omitting current setup.
This new step will echo and append a string to the GITHUB_OUTPUT
environment variable. GITHUB_OUTPUT
is a special environment variable in GitHub Actions that allows us to take the data from one step available elsewhere in the job. GITHUB_OUTPUT
accepts a key-value pair, so in this example, the key is review_app_ip
, and the value is the output we get from the terraform output
command.
When using GITHUB_OUTPUT
in a step, it’s essential to include a unique identifier in the id
key since we’ll need it to retrieve the data elsewhere in the job. I’ve identified this step as terraform-outputs
, which I’ll use to retrieve the value of the review_app_ip
key I set here.
As mentioned earlier, jobs in GitHub Actions can’t communicate with each other, so I need some way to make the IP address I stored in GITHUB_OUTPUT
available to the deploy
job. We can do this using the outputs
setting in the job:
name: Review app deployment
on:
pull_request:
types:
- opened
- reopened
- synchronize
jobs:
provision:
# New setting
outputs:
review_app_ip: ${{ steps.terraform-outputs.outputs.review_app_ip }}
# Omitting the remaining provisioning setup and steps
deploy:
# Omitting current setup.
The outputs
setting lets us make data available to dependent jobs through key-value pairs. Here, I’m setting the review_app_ip
key to contain a value, which is the value of the review_app_ip
key I set in the terraform-outputs
step. Retrieving a value from GITHUB_OUTPUT
takes the form of steps.<id>.outputs.<output-key>
, where <id>
is the step identifier and <output-key>
is the key given to the string appended to GITHUB_OUTPUT
.
These two updates to the provision
job now give the deploy
job access to the provisioned server’s IP address.
Retrieving the value of an output
To retrieve an output from another job, we can use the value in needs.<job-id>.outputs.<key>
, as shown here:
name: Review app deployment
on:
pull_request:
types:
- opened
- reopened
- synchronize
jobs:
provision:
outputs:
review_app_ip: ${{ steps.terraform-outputs.outputs.review_app_ip }}
# Omitting the remaining setup and steps.
deploy:
runs-on: ubuntu-latest
needs: provision
env:
# Omitting existing environment variables and setting up new values.
REVIEW_APP_IP: ${{ needs.provision.outputs.review_app_ip }}
REVIEW_APP_HOST: ${{ needs.provision.outputs.review_app_ip }}
Outputs can only be passed between dependent jobs
When retrieving data from another GitHub Actions job through outputs, remember that it only works if the job fetching the data depends on the one providing the data via the needs
setting. It’s an easy step to miss.
These environment variables will now contain the values from the key-value pair set up in the provision
job. With this, I can set up the steps for deploying the application to the new server using Kamal.
Setting up Deployment Steps
The deployment job will consist of a few steps to set up the GitHub-hosted runner:
name: Review app deployment
on:
pull_request:
types:
- opened
- reopened
- synchronize
jobs:
provision:
# Omitting provisioning steps.
deploy:
runs-on: ubuntu-latest
needs: provision
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.REVIEW_APP_KAMAL_REGISTRY_PASSWORD }}
RAILS_MASTER_KEY: ${{ secrets.REVIEW_APP_RAILS_MASTER_KEY }}
POSTGRES_USER: ${{ secrets.REVIEW_APP_POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.REVIEW_APP_POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.REVIEW_APP_POSTGRES_DB }}
REVIEW_APP_IP: ${{ needs.provision.outputs.review_app_ip }}
REVIEW_APP_HOST: ${{ needs.provision.outputs.review_app_ip }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Configure SSH Key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.REVIEW_APP_SSH_PRIVATE_KEY }}
- name: Kamal setup
if: github.event.action == 'opened' || github.event.action == 'reopened'
run: bundle exec kamal setup -d review
- name: Kamal deploy
if: github.event.action == 'synchronize'
run: bundle exec kamal deploy -d review
- I’ll check out the code from my repo into the runner using the
actions/checkout
action. - Next, I’ll use the
ruby/setup-ruby
action to install Ruby and all the gem dependencies for the application. I do this step to use the same version of Kamal defined in my Gemfile.
Installing Kamal on your own
Instead of installing Ruby and the project’s gem dependencies, I can install Kamal separately or run Kamal through Docker as an alternative. However, I recommend using the same version you use in your project to ensure everything works with your current setup and avoid surprises when you have to deploy to production.
- The runner will also need an SSH key set up to perform the Kamal deployment. The webfactory/ssh-agent action helps us get this working quickly without the complexities around setting up SSH keys on the server.
- At this point, the server is ready for deployments, so we’ll add a few steps to run the initial Kamal setup command for the review destination (
kamal setup
) if this is a newly opened or reopened pull request or a regular deployment (kamal deploy
) when updating the pull request’s branch.
The first two steps are straightforward and don’t need much explanation. If you want to learn more about the actions used to check out the repo’s code and set up Ruby, I recommend reading through the documentation for each action to understand what’s going on in these steps.
Setting up an SSH Key on GitHub Actions for Deployments
Kamal uses SSH to connect to the servers it will deploy to and run the various commands it needs to get the application up and running. The server we spin up in this GitHub Actions workflow will automatically have our public SSH key, which I have configured in my Hetzner Cloud project, but the runner used for the deploy
job won’t have its corresponding private SSH key, so it’s up to us to get it set up.
The webfactory/ssh-agent action used in the workflow performs all the actions needed to configure an SSH key for a GitHub-hosted runner. In most cases, we only need to supply it with a private SSH key, and the action takes care of the rest. Before this step works, we also need to handle some prerequisites on GitHub.
Generating a dedicated SSH key
While you can use your personal SSH key to set up this workflow, it’s highly recommended to create a separate key when setting it up on GitHub. It’ll help keep your systems secure if the key is ever compromised and makes it easier to control which repositories can access it without exposing other repos.
To learn how to generate a new SSH key, check out the Generating a new SSH key section in GitHub’s documentation. Make sure you save the keys in different files besides the default location so you don’t accidentally overwrite any existing keys in your system.
Adding the public SSH key as a deploy key
GitHub Actions will need the public SSH key configured in the code repository it will access so it knows that the workflow can perform deployments using that specific key. Otherwise, you’ll receive an error indicating it can’t read from the repo.
Setting up the public SSH key in a repository can be done by going to the Settings section in the repo on GitHub, going to Deploy Keys, and adding a new deploy key. Copy the value of the public SSH key as the deploy key’s value, and you’re all set.
Configuring the private SSH key as a repository secret
Configuring a deploy key on a repo attaches the public key, but we’ll still need the private SSH key on the server to perform the Kamal deployments. The secure way to handle this is to create a new repository secret that contains the value of the private SSH key. We can then refer to the SSH key without exposing its contents throughout the workflow process.
The Using secrets in GitHub Actions documentation explains how to create repository secrets. When creating the new secret, make sure to copy the entire private SSH key and not the public SSH key used previously—the value should start with -----BEGIN ... PRIVATE KEY-----
and finish with -----END ... PRIVATE KEY-----
.
Running Kamal Setup and Deploy
The GitHub-hosted runner is ready to use Kamal to deploy the application to the provisioned server. The final two steps in this workflow handle this, but each step is done conditionally depending on what triggered the workflow, whether when opening, reopening, or updating the pull request. Why do we have two distinct steps for handling the deployment conditionally?
The issue is that Kamal currently requires us to explicitly use the kamal setup
command for the first deploy so it can install Docker if it’s not on the server, set up the accessories and the proxy service, and get the network working for our app. This step isn’t idempotent, meaning the command will fail if we attempt to re-run the kamal setup
command after it completes successfully. Once we set up the server with Kamal, all subsequent updates need the kamal deploy
command.
As a first pass to get our review app workflow running, I opted to create these two steps to distinguish when we need to run Kamal for the first time (opening or reopening a pull request, which provisions a new server) or update our application (when updating the pull request’s branch).
However, setting up these steps isn’t perfect. For example, if we open a new pull request and the deployment fails, we’ll need to re-run the kamal setup
command again. The only way it’ll happen in our workflow is when the pull request is opened or reopened. We can’t push a fix to the branch since it’ll trigger the kamal deploy
command, which will fail since the work didn’t correctly set up the server. The only way around this problem is to either manually complete the Kamal setup or close and reopen the pull request to delete the server with the incomplete setup and create a brand-new one.
Unfortunately, Kamal doesn’t offer a simple way to know if the Kamal setup command was successful. I’ll explore a few ways to work around this issue in a future article. For now, we’ll go with these steps instead.
Running Our First Kamal Deployment on GitHub Actions
The GitHub Actions workflow is fully ready to deploy our app to a fresh new server on the cloud (full YAML workflow). After committing these updates to the repo and double-checking that our repo has everything it needs (deploy key, secrets, etc.), we’ll open a new pull request and see what happens.
The provision
job in the workflow will spin up the new server on Hetzner Cloud, as set up and shown in Part 1. The only thing that changed for this first job is configuring the job to store the new server’s IP address as an output. The new step saves the information to the $GITHUB_OUTPUT
environment variable, making it ready to use in the deploy
job.
After deploying the new server, GitHub Actions will kick off the new deploy
job and go through each step after setting up the environment variables from the repository secrets and the output from the first job. It’ll set up Ruby and install the gems to ensure we have Kamal available, configure the SSH key on the runner, and run kamal setup
on the review
destination. If we’ve set everything correctly, the app will be up and running on the server in a few minutes.
As we set up in the workflow, the deploy
job will only execute the kamal setup
step when opening or reopening a pull request, skipping the kamal deploy
step when the pull request branch gets updated. Let’s double-check that the conditional checks work by updating the TeamYap codebase.
Let’s say I update the application’s dependencies, then commit and push these changes to the repo. This commit will trigger the workflow on GitHub Actions, going through the provision
job again but making no changes since the server is already up and running for this pull request. It’ll then jump to the deploy
job and run the kamal deploy
step instead of kamal setup
.
Wrapping Up
The work done in the previous parts of this series to spin up new servers and configure Kamal to split up the deployment settings for review apps finally got put to work, and we finally have fully automated deployments to newly provisioned infrastructure by simply opening or updating a pull request on GitHub. This step replicates what Heroku and other services do under the hood, with the benefit of giving you complete control of the underlying systems and deployment process.
Although we have functional deployments, our review app workflow is not quite there yet. Since the process spins up new servers on demand, we won’t know where the workflow deploys the application unless we skim through the workflow’s output and find the IP address. Doing this is a bit of a time-waster, so I’ll show how to create unique URLs for your review apps in the next article. Stay tuned!
Need help getting Kamal up and running for your web app?
If you or your team want hands-on assistance on automating Kamal deployments or need some help with any other Kamal or Rails-related setup, reach out with your questions and let's talk.
Screencast
If this article or video helped you with automating your deployments on GitHub Actions, consider subscribing to my YouTube channel for similar videos containing tips on helping Rails developers ship their code with more confidence, from development to deployment.