Building Review Apps with Kamal and GitHub Actions

When I was using Heroku at work, one feature stood out as a game-changer for our development workflow: Review Apps. They made pull request testing seamless—isolated environments spun up automatically, inherited configuration from staging, and even provisioned separate add-ons like PostgreSQL and Redis. It transformed how we collaborated and tested changes.

Fast forward to today. I’m running my Agentify app on a Hetzner server using Kamal for deployment. Everything is containerized with Docker—from local development with devcontainers and ngrok for tunneling, to production deployment on my Hetzner box. As I dove deeper into agentic coding, I found myself wishing for that same pull request workflow. I wanted to test changes in isolation, share environments with others, and validate features before merging.

So I built it myself.

The Challenge: Non-Standard Kamal Setup

My Kamal setup isn’t typical. I split my infrastructure across two repositories:

  1. App repository: Contains my Rails application with Kamal deployment configuration
  2. Server repository: Manages shared resources (Kamal accessories), primarily the PostgreSQL database

This split meant I couldn’t rely on Rails’ standard db:create behavior since database permissions are managed separately. The solution? Schema-based isolation within a single database.

Instead of creating separate databases per pull request, I decided to:

  • Use one shared agentify_preview database
  • Create unique schemas for each PR: pr_123, pr_124, etc.
  • Isolate all the Solid gems (SolidQueue, SolidCache, SolidCable) within their respective schemas

The Implementation

Manual Deployment Triggers

I didn’t want every pull request automatically spinning up a preview environment—that felt excessive and resource-heavy. Instead, I implemented a comment-based trigger system using GitHub Actions.

Commenting /deploy on any pull request kicks off the deployment workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
name: Preview Deploy

on:
  issue_comment:
    types: [created]

jobs:
  Deploy:
    runs-on: ubuntu-latest
    if: github.event.issue.pull_request && contains(github.event.comment.body, '/deploy')

Database Schema Isolation

The core of the Rails configuration centers around dynamic schema management. Here’s how I configured database.yml to handle schema isolation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
preview:
  primary: &primary_preview
    <<: *default
    host: <%= ENV["DB_HOST"] %>
    database: agentify_preview
    username: agentify_preview
    password: <%= ENV["AGENTIFY_DATABASE_PASSWORD"] %>
    schema_search_path: extensions, <%= ENV["DB_SCHEMA"] %>
  cache:
    <<: *primary_preview
    schema_search_path: extensions, <%= ENV["DB_SCHEMA"] %>_cache
    migrations_paths: db/cache_migrate
  queue:
    <<: *primary_preview
    schema_search_path: extensions, <%= ENV["DB_SCHEMA"] %>_queue
    migrations_paths: db/queue_migrate
  cable:
    <<: *primary_preview
    schema_search_path: extensions, <%= ENV["DB_SCHEMA"] %>_cable
    migrations_paths: db/cable_migrate

The DB_SCHEMA environment variable gets set dynamically to pr_#{pr_number}, ensuring each pull request gets its own isolated namespace within the shared database.

You’ll notice extensions comes first in the schema_search_path. I use pgvector to add vector support to PostgreSQL for my AI features. Since my app database user has locked-down permissions and can’t create extensions, I created a separate extensions schema where the database admin installs shared extensions like vector. All other schemas can then reference these extensions through their search path without needing creation privileges.

Kamal Preview Configuration

My deploy.preview.yml configuration handles the preview-specific deployment settings:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
service: agentify-preview
proxy:
  ssl: true
  host: <%= ENV.fetch("APP_HOST", "preview.agentify.rida.me") %>

env:
  clear:
    RAILS_ENV: preview
    DB_HOST: infra-postgres
    DB_SCHEMA: <%= ENV.fetch("DB_SCHEMA", "pr_#{ENV['VERSION']&.sub('pr-', '') || 'default'}") %>
    VERSION: <%= ENV.fetch("VERSION", "pr-#{ENV['PR_NUMBER']}") %>

The Deployment Workflow

The GitHub Action workflow handles the entire deployment lifecycle:

  1. Setup: Check out the PR code, install Ruby and Kamal
  2. Schema Creation: Create all necessary PostgreSQL schemas
  3. Deploy: Run kamal deploy --version $VERSION --destination preview
  4. Database Setup: Run migrations and seeds within the isolated schemas
  5. GitHub Integration: Create deployment status and comment with preview URL

Here’s the key database setup step:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
- name: Setup preview database
  run: |
    # Create all necessary schemas in production database
    kamal app exec --version $VERSION --destination preview 'bin/rails runner "
      schemas = [\"pr_${{ env.PR_NUMBER }}\", \"pr_${{ env.PR_NUMBER }}_cache\", \"pr_${{ env.PR_NUMBER }}_queue\", \"pr_${{ env.PR_NUMBER }}_cable\"]
      schemas.each { |schema| ActiveRecord::Base.connection.execute(\"CREATE SCHEMA IF NOT EXISTS #{schema}\") }
      puts \"Created schemas: #{schemas.join(\", \")}\"
    "'
    # Load schemas for all databases and run seeds
    kamal app exec --version $VERSION --destination preview 'bin/rails db:migrate db:seed'

Visual Differentiation

To make it obvious which environment you’re viewing, I added a simple helper method that modifies the app name:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def app_name
  base_name = t("layouts.application.app_name")

  # Check if we're in a preview environment with a PR number
  if Rails.env.preview? && ENV["VERSION"]&.start_with?("pr-")
    pr_number = ENV["VERSION"].sub("pr-", "")
    "#{base_name} - PR ##{pr_number}"
  else
    base_name
  end
end

Now when you visit pr-199.agentify.rida.me, the app title clearly shows “Agentify - PR #199”.

Automatic Cleanup

The cleanup workflow runs automatically when a pull request is closed, tearing down both the container and the database schemas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
- name: Drop preview schemas
  run: |
    kamal app exec --version $VERSION --destination preview 'bin/rails runner "
      schemas = [\"pr_${{ env.PR_NUMBER }}\", \"pr_${{ env.PR_NUMBER }}_cache\", \"pr_${{ env.PR_NUMBER }}_queue\", \"pr_${{ env.PR_NUMBER }}_cable\"]
      schemas.each do |schema|
        ActiveRecord::Base.connection.execute(\"DROP SCHEMA IF EXISTS #{schema} CASCADE\")
        puts \"Dropped schema: #{schema}\"
      end
    "'

- name: Remove preview deployment
  run: |
    yes | kamal remove --version $VERSION --destination preview

What I Learned

Building this system taught me several things about deployment automation and Rails configuration:

Schema isolation is powerful: PostgreSQL’s schema system provides true isolation while sharing database resources. Each preview environment gets its own namespace without the overhead of separate databases. The schema search path also enables elegant sharing of extensions like pgvector—a single extensions schema can serve all preview environments while maintaining security boundaries through user permissions.

GitHub Actions integration is rich: The deployment status API creates a proper deployment record, complete with environment URLs and status tracking. It feels just like Heroku’s native integration.

Kamal’s flexibility shines: While Kamal has sensible defaults, it’s flexible enough to handle complex multi-environment scenarios. The destination-specific configuration files make it straightforward to customize preview deployments.

Manual triggers reduce noise: Auto-deploying every PR would have been expensive and cluttered. The comment-based trigger keeps deployments intentional while remaining easy to use.

The Result

The final system gives me:

  • On-demand preview environments via /deploy comments
  • Isolated database schemas per pull request
  • Automatic cleanup when PRs are closed
  • Proper GitHub deployment integration
  • SSL-enabled subdomains like pr-123.agentify.rida.me

It’s not quite as polished as Heroku’s offering, but it scratches exactly the itch I had. Most importantly, it gives me confidence to experiment with agentic features, knowing I can easily test and share changes in isolated environments.

The entire implementation took about a weekend to build and debug. For anyone running Rails on their own infrastructure, it’s a worthwhile investment that significantly improves the development workflow.


This is part of my ongoing documentation of building with AI agents. If you’re interested in more technical breakdowns and learning logs, follow along as I continue sharing what I’m building.