Review Apps With Kamal (Part 3): Automating Deployments

Set up your GitHub Actions workflows to spin up new servers and automate deployments on your own infrastructure using Kamal.

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

This article assumes you read Part 1 and Part 2 of this series, which cover the example application we’re using to demonstrate this setup. I highly encourage you to check those out if you want to follow along with this part.

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:

  1. We need to output the server’s IP address using Terraform.
  2. We need to pass the output value from the provision job to the deploy 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
  1. I’ll check out the code from my repo into the runner using the actions/checkout action.
  2. 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.

  1. 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.
  2. 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.

GitHub Actions Workflow - Provision Step with Output

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.

GitHub Actions Workflow - Initial Kamal Setup

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.

GitHub Actions Workflow - Kamal Deploy

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.

More articles you might enjoy

Article cover for Review Apps With Kamal (Part 2): Configuring Kamal Destinations
Kamal
Review Apps With Kamal (Part 2): Configuring Kamal Destinations

Learn how Kamal destinations help you easily deploy your web app to other servers, laying the groundwork for automating the creation of review apps.

Article cover for Review Apps With Kamal (Part 1): Provisioning Servers
GitHub Actions
Review Apps With Kamal (Part 1): Provisioning Servers

Are you using Kamal, but miss having automated review apps generated to check new updates? Learn how to provision servers to help you test your application.

Article cover for High Availability PostgreSQL Replication With Kamal
Kamal
High Availability PostgreSQL Replication With Kamal

Kamal is great for deploying web apps, but you're responsible for your data. Learn how to keep a copy of your data secure with PostgreSQL database replication.

About the author

Hi, my name is Dennis! As a freelancer and consultant, I work with tech organizations worldwide to help them build effective, high-quality software. It's my mission to help these companies get their idea off the ground quickly and in the right way for the long haul.

For over 20 years, I've worked with startups and other tech companies across the globe to help them successfully build effective, high-quality software. My experience comes from working with early-stage companies in New York City, San Francisco, Tokyo, and remotely with dozens of organizations around the world.

My main areas of focus are full-stack web development, test automation, and DevOps. I love sharing my thoughts and expertise around test automation on my blog, Dev Tester, and have written a book on the same topic.

Dennis Martinez - Photo
Learn more about my work Book a free consultation