July 4, 2026
Automatic Rails deploys with Kamal and GitHub Actions
I had Kamal deploying this site with one command. It worked. But every deploy still needed me at my laptop, with Docker running, on the right machine. This post is how I got rid of that step. Merge to main, the site updates, Telegram tells me it's live.
If you haven't set up Kamal yet, start with deploying Rails with Kamal. This picks up from there.
The problem with deploying from my laptop
The manual setup builds through a registry that Kamal runs on localhost:5555. That registry lives on the machine where you type kamal deploy. Which means the deploy is tied to that machine. No laptop, no deploy. Wrong laptop, no deploy. Docker daemon stopped, no deploy.
For one person that's fine right up until it isn't. I wanted what every team eventually wants: push to main and the thing ships. No human in the loop, no ceremony.
The shape of it
One GitHub Actions workflow, triggered on push to main:
- Build the image on the runner.
- Push it to GitHub Container Registry.
- Kamal connects to the server over SSH, pulls, and runs it blue-green.
- Telegram pings me: started, succeeded, or failed.
The rest of this post goes through each piece, then gives the full workflow to copy into a new project.
Build on the runner, not the server
My old config built on the server itself:
builder:
arch: amd64
remote: ssh://root@my-server
That remote was there to dodge amd64-on-ARM emulation when I built from my Mac. Real reason, good fix, for that context.
In CI the reason disappears. A GitHub ubuntu-latest runner is already amd64, same architecture as the server. So there's nothing to emulate, and no reason to make the production box compile assets while it's serving traffic. A CX22 has two vCPUs. I'd rather they answer requests than run a Docker build.
So the builder goes back to plain runner builds, and I add registry layer cache so repeat deploys reuse what didn't change:
builder:
arch: amd64
cache:
type: registry
options: mode=max,image-manifest=true,oci-mediatypes=true
This cache needs a Dockerfile build. Kamal can also build with buildpacks, but its pack builder keeps the cache in local Docker volumes, and a GitHub runner starts empty every time. With a Dockerfile, the layers live in GHCR and every deploy reuses them. Rails 8 generates the Dockerfile for you, so there is nothing to write.
Push to GHCR instead of the local registry
The localhost:5555 registry was the one piece bolted to my laptop. It had to go. The code already lives on GitHub, so GitHub Container Registry is the path of least resistance, and the layer cache above lives there too.
registry:
server: ghcr.io
username:
- KAMAL_REGISTRY_USERNAME
password:
- KAMAL_REGISTRY_PASSWORD
Those two secrets get filled in from the workflow, which brings me to auth.
Auth: the built-in GITHUB_TOKEN, not a PAT
GitHub Actions hands every run a short-lived GITHUB_TOKEN. Give the job packages: write and you can push to GHCR with it. No personal access token to create, store, or rotate.
permissions:
contents: read
packages: write
env:
KAMAL_REGISTRY_USERNAME: ${{ github.actor }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
There's one tradeoff worth saying out loud. That token dies when the run ends. So the server's registry login is only good during the deploy. That's fine, because the image is already on the server by then, and rollbacks and reboots reuse the local copy. Every real deploy comes from CI with a fresh token.
The only case it doesn't cover is pulling out-of-band: you SSH into the box and docker pull by hand, or the image got pruned. If you need that, swap in a PAT with read:packages. Two lines in .kamal/secrets. I didn't, because I never do that.
A dedicated deploy key, not my personal one
CI needs SSH access to run the containers on the server. Do not put your personal key in a repo secret. It's your key to everything, and now it's sitting in CI.
Generate a key that does exactly one job. Register its public half on the server, store the private half as DEPLOY_SSH_PRIVATE_KEY. If it ever leaks, you revoke one key and nothing else in your life breaks.
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key
ssh-copy-id -i ~/.ssh/deploy_key.pub root@my-server
Load that key into the workflow with the ssh-agent action, right before the deploy step. That is the whole SSH setup Kamal needs. net-ssh trusts the server on first connect, so there is no known_hosts to seed:
- name: Set up SSH
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
The two secrets
That's the whole secret list:
RAILS_MASTER_KEYdecrypts credentials. The workflow also writes it back toconfig/master.key, because that file is gitignored and Kamal reads it through.kamal/secrets.DEPLOY_SSH_PRIVATE_KEYis the dedicated key above.
SECRET_KEY_BASE shows up in the Kamal config too, but it's not a third secret to store. .kamal/secrets pulls it from Rails credentials at deploy time, so RAILS_MASTER_KEY already covers it.
Here is the whole .kamal/secrets. The registry lines pass through what the workflow puts in the environment:
RAILS_MASTER_KEY=$(cat config/master.key)
SECRET_KEY_BASE=$(bundle exec rails credentials:fetch secret_key_base)
KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
Store the two secrets once, from the repo root, with the GitHub CLI:
gh secret set RAILS_MASTER_KEY < config/master.key
gh secret set DEPLOY_SSH_PRIVATE_KEY < ~/.ssh/deploy_key
Telegram, from a single source of truth
The bot token and chat id already belong somewhere: Rails credentials. So the workflow reads them from there instead of adding a fourth place to keep a secret.
Create the bot with @BotFather, send it one message, and read your chat id from https://api.telegram.org/bot<token>/getUpdates. Then add both to the credentials with bin/rails credentials:edit:
telegram:
token: "123456:ABC..."
chat_id: "-1001234567890"
The workflow reads them with a few lines of Ruby, so the app does not even need to boot:
bundle exec ruby -e '
require "active_support"
require "active_support/encrypted_configuration"
config = ActiveSupport::EncryptedConfiguration.new(
config_path: "config/credentials.yml.enc",
key_path: "config/master.key",
env_key: "RAILS_MASTER_KEY",
raise_if_missing_key: true
)
print config.dig(:telegram, :token)
'
It reads them, masks them so they never print in the logs, and fires three messages: start, success, failure. The notify steps run with continue-on-error, because a deploy that actually worked should not go red just because Telegram had a bad second.
- name: Notify Telegram - deploy started
continue-on-error: true
run: |
curl -sS --max-time 15 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=๐ Deploy started"
One deploy at a time
Merge twice in a minute and you start two deploys. Let the running one finish and queue the next:
concurrency:
group: deploy
cancel-in-progress: false
A running deploy always finishes. A push that arrives in the meantime waits. If several arrive, GitHub keeps only the latest pending run, and that run already contains the commits of the others. So you get at most one extra deploy, and it ships the newest code.
Finishing matters more than it looks. Kamal holds a deploy lock on the server for the whole run, and the new container runs db:prepare before kamal-proxy switches traffic to it. Kill a run halfway and you can leave the lock behind, so the next deploy fails until you run kamal lock release. Or you leave a migrated database with the old code still serving it. Blue-green keeps the site up, but it does not clean up a run you killed.
When a deploy fails
Telegram tells you which run failed, with a link. Two cases come up.
GitHub sometimes finds no runner, and the run fails after 15 minutes with "The job was not acquired by Runner". Nothing touched the server. Run it again:
gh run rerun <run-id>
The workflow_dispatch trigger also gives you a "Run workflow" button in the Actions tab, or gh workflow run deploy.yml from the terminal. Use it to redeploy main without a new commit.
If a deploy died halfway and the next one says "Deploy lock found", release the lock from your machine and run the deploy again:
bin/kamal lock release
The full workflow
This is the complete .github/workflows/deploy.yml. Copy it into a new project as it is. The Telegram messages use the repository name, so there is nothing to rename.
name: Deploy
on:
workflow_dispatch:
push:
branches:
- main
# One deploy at a time. A running deploy always finishes: cancelling it could leave the
# Kamal lock on the server or a migration without the new code. Pushes that arrive in the
# meantime wait, and GitHub keeps only the latest one, which already contains the others.
concurrency:
group: deploy
cancel-in-progress: false
# Needed to push the built image to GitHub Container Registry with GITHUB_TOKEN.
permissions:
contents: read
packages: write
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
KAMAL_REGISTRY_USERNAME: ${{ github.actor }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
# Kamal reads the master key from config/master.key (see .kamal/secrets), which is
# gitignored, so recreate it from the RAILS_MASTER_KEY secret before deploying.
- name: Write Rails master key
run: |
install -m 600 /dev/null config/master.key
printf '%s' "${RAILS_MASTER_KEY}" > config/master.key
# Read Telegram bot token and chat id straight from Rails credentials (single source of
# truth), mask them so they never surface in logs, and expose them to the notify steps.
- name: Load Telegram notification credentials
run: |
read_credential() {
bundle exec ruby -e '
require "active_support"
require "active_support/encrypted_configuration"
config = ActiveSupport::EncryptedConfiguration.new(
config_path: "config/credentials.yml.enc",
key_path: "config/master.key",
env_key: "RAILS_MASTER_KEY",
raise_if_missing_key: true
)
print config.dig(:telegram, ARGV[0].to_sym)
' "$1"
}
TELEGRAM_BOT_TOKEN="$(read_credential token)"
TELEGRAM_CHAT_ID="$(read_credential chat_id)"
echo "::add-mask::$TELEGRAM_BOT_TOKEN"
echo "::add-mask::$TELEGRAM_CHAT_ID"
echo "TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN" >> "$GITHUB_ENV"
echo "TELEGRAM_CHAT_ID=$TELEGRAM_CHAT_ID" >> "$GITHUB_ENV"
- name: Notify Telegram - deploy started
continue-on-error: true
run: |
curl -sS --max-time 15 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=๐ Deploy started - ${{ github.event.repository.name }}
${{ github.ref_name }}@${GITHUB_SHA::7} by ${{ github.actor }}
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- name: Set up SSH
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
# Kamal builds the amd64 image here on the runner, pushes it to ghcr.io, then
# connects to the server over SSH to pull and run it (blue-green).
- name: Deploy with Kamal
run: bundle exec kamal deploy
- name: Notify Telegram - deploy succeeded
if: success()
continue-on-error: true
run: |
curl -sS --max-time 15 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=โ
Deploy succeeded - ${{ github.event.repository.name }}
${{ github.ref_name }}@${GITHUB_SHA::7} by ${{ github.actor }}
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- name: Notify Telegram - deploy failed
if: failure()
continue-on-error: true
run: |
curl -sS --max-time 15 \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=โ Deploy failed - ${{ github.event.repository.name }}
${{ github.ref_name }}@${GITHUB_SHA::7} by ${{ github.actor }}
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
The payoff
Merge is the deploy button now. The whole thing is one workflow file, one config diff, and two secrets.
The day-to-day change is smaller than it sounds and better than it sounds. I stopped thinking about deploys. I review the PR, I merge, I get a checkmark in Telegram a couple minutes later. The laptop stopped being load-bearing.