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 is the four decisions that made it clean.
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
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.
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.
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"
Two gotchas
concurrency: cancel-in-progress is safe here. Kamal deploys blue-green, so a newer push can cancel an in-flight deploy without leaving the site half-updated.
Old images pile up. After you switch registries, the previous images stick around on the server. Prune them once the first GHCR deploy is live, but don't prune the one currently running.
The payoff
Merge is the deploy button now. The whole thing is one workflow file, one config diff, and two secrets. If you want the complete file instead of the pieces, here's the workflow on GitHub.
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.