This guide walks through adopting kamal-previews in an existing Rails app that already deploys to staging via Kamal 2. By the end you'll have a preview environment automatically created on every pull request.
The example uses PostgreSQL on a single shared deploy host
(staging.example.com) and DNS suffix preview.example.com. Substitute
your own values throughout.
You need:
- A Kamal-deployed staging app, with
config/deploy.staging.ymland (optionally).kamal/secrets.staging. kamal-previews uses these as the template for each per-PR app — most settings (image, registry, builder, accessories, env tags) are inherited verbatim. - A staging host that can run multiple Kamal apps simultaneously.
Each preview gets its own
kamal-proxy-routed service on the same host. - A wildcard DNS record
*.preview.example.com → <staging host IP>(or a CNAME to a load balancer that points at the staging host). - An SSH key that can
ssh deploy@<staging host>and rundocker. This is the same key Kamal uses to deploy. - Database admin credentials that can
CREATE DATABASEon your staging DB cluster. Same cluster as staging is fine.
Decide what hostnames preview apps should get. The default is
<branch-slug>.preview.example.com. Examples:
| Branch | Slug | URL |
|---|---|---|
feature/checkout-rewrite |
checkout-rewrite |
https://checkout-rewrite.preview.example.com |
fix/123-bad-redirect |
123-bad-redirect |
https://123-bad-redirect.preview.example.com |
bug/very-long-branch-name-that-keeps-going-forever |
first 50 chars (no trailing -) |
… |
Run bin/kamal-previews slugify --branch <name> from a clone of this repo
to preview the slug for any branch name. If you want a different URL shape
(e.g. pr-123.preview.example.com), see the
domain-label-pattern reference.
Add a wildcard record to your DNS:
*.preview.example.com. IN A <staging host IP>
If you proxy through Cloudflare, set encryption mode to Full (strict) —
kamal-proxy will request HTTP-01 Let's Encrypt certs per host.
Cookie-domain footgun. If your app sets
Domain=.preview.example.comon its session cookie, every preview environment will share cookies with every other preview environment. Don't do that. Seedocs/dns-and-tls.md.
Most Kamal staging configs work out of the box. Two things to double-check:
-
proxy:is configured withhost:set. This is what tellskamal-proxyto multiplex by hostname. kamal-previews overridesproxy.hostin each per-PR config but leaves the rest of the proxy block alone. Recommended: enable SSL for the proxy.proxy: host: staging.example.com ssl: true response_timeout: 60
-
Your image is buildable from a clean checkout. kamal-previews runs
kamal deployfrom the runner, which does a fresh clone. If you currently rely on uncommitted changes (e.g. viabuilder.context: "."in your staging config), set thebuilder-contextinput to override.
The per-PR app needs to know which database to connect to. kamal-previews
sets DATABASE_NAME (and FEATURE_BRANCH_SLUG, FEATURE_BRANCH_DB_SLUG,
FEATURE_BRANCH_LABEL) on the per-PR app's environment. The simplest
hookup is a one-liner in config/database.yml:
default: &default
adapter: postgresql
staging:
primary:
<<: *default
database: <%= ENV.fetch("DATABASE_NAME") { "myapp_staging" } %>
username: <%= ENV.fetch("DB_USER") %>
password: <%= ENV.fetch("DB_PASSWORD") %>
host: <%= ENV.fetch("DB_HOST") %>If you also use SolidQueue / SolidCache / SolidCable on Postgres, give them their own per-PR databases the same way:
staging:
primary: { ..., database: <%= ENV.fetch("DATABASE_NAME") %> }
cache: { ..., database: <%= ENV.fetch("DATABASE_NAME") %>_cache }
cable: { ..., database: <%= ENV.fetch("DATABASE_NAME") %>_cable }
queue: { ..., database: <%= ENV.fetch("DATABASE_NAME") %>_queue }When configured this way, kamal-previews can clone all four. See
docs/databases.md for the
clone hook.
Copy examples/postgres/preview.yml
into your repo at .github/workflows/preview.yml and edit the with:
and env: blocks. The minimum-viable shape:
name: Preview environment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
delete:
permissions:
contents: read
packages: write
pull-requests: write
deployments: write
# Cancel a superseded deploy when a new commit lands on the same PR,
# but never cancel a teardown — letting a `closed` event get cancelled
# would leave the preview app + databases orphaned on the host.
concurrency:
group: kamal-previews-${{ github.event.pull_request.number || github.event.ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }}
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: web-ascender/github-actions-kamal-previews@v1
with:
base-deploy-file: config/deploy.staging.yml
base-secrets-file: .kamal/secrets.staging
domain-suffix: preview.example.com
deploy-host: staging.example.com
database-engine: postgres
databases: |
DATABASE_URL=myapp_staging:myapp_{db_slug}
env:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# Only needed if your base-secrets-file shells out to
# `bin/rails credentials:fetch`:
RAILS_MASTER_KEY: ${{ secrets.STAGING_MASTER_KEY }}Add the secrets at the repo level under Settings → Secrets and variables → Actions:
DEPLOY_SSH_KEY— same key Kamal usesSTAGING_MASTER_KEY— only if your.kamal/secrets.stagingshells out tobin/rails credentials:fetch ...for the URLDATABASE_ADMIN_URL— only if your staging app role lacksCREATEDBprivilege (then provide a separate admin URL with that privilege)
Push any branch and open a pull request. Within a few minutes you should see:
- A "Preview environment is building…" comment on the PR.
- A "View deployment" button on the PR (linked to the preview URL).
- The comment updating to "Preview environment is ready" with the URL.
Open the URL in a browser — you should see your app, populated with the
data from myapp_staging cloned moments ago.
If your deploy host isn't publicly reachable, open a VPN tunnel as a sibling step before the kamal-previews step. The two are independent concerns — pick whichever VPN action you already trust:
steps:
- uses: actions/checkout@v4
- uses: <your-vpn-action>@v1
with: { ... }
- uses: web-ascender/github-actions-kamal-previews@v1
with: { ... }The tunnel established by the earlier step stays open for the rest of
the job. Add whatever credential secrets the VPN action requires
alongside DEPLOY_SSH_KEY. The reusable-workflow form doesn't support
this pattern — for private hosts, stick with the composite action.
The deploy / teardown lifecycle is event-driven and very reliable, but
GitHub Actions does occasionally miss events (outage, branch deleted while
Actions was disabled, etc.). The sweeper reconciles registered preview
environments against open PRs once a day and tears down orphans. See
examples/postgres/sweep.yml.
docs/databases.md— engine-specific notes, including how to sanitize PII before cloning and how to use the in-container clone alternative.docs/dns-and-tls.md— wildcard certs, DNS-01 vs. HTTP-01 challenges, the cookie-domain footgun.docs/secrets.md— Kamal'skamal secretsintegration with 1Password, AWS Secrets Manager, Doppler, and friends.docs/resource-limits.md— keep preview costs in check via memory/CPU caps, branch filtering, and a hard cap on concurrent previews.docs/troubleshooting.md— common failure modes and how to diagnose them.docs/reference.md— every input, output, and secret.