# Building Review Apps with Kamal and GitHub Actions
> A deep dive into implementing preview environments for pull requests using Kamal deployment, PostgreSQL schema isolation, and GitHub Actions automation.

Canonical URL: https://rida.me/blog/kamal-github-review-apps/


# 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](https://devcenter.heroku.com/articles/github-integration-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](https://kamal-deploy.org/) for deployment. Everything is containerized with Docker—from local development with devcontainers and [ngrok](https://ngrok.com/) 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](https://docs.github.com/en/actions).

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

```yaml
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:

```yaml
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](https://github.com/pgvector/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:

```yaml
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:

```yaml
- 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:

```ruby
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:

```yaml
- 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](/tags/learning-log/).*

---

{{< share-discuss text="Building something similar?" twitter="true" hn="true" >}}
