Continuous Deployment with Kamal
Every manual deploy is a tax on shipping. You pay it in context switches, terminal tabs, and the quiet dread of “did I remember to pull first?” Kamal and GitHub Actions make this tax disappear for single-server setups. Here’s how.
The three pieces
You need three things: a Kamal config that knows your server, a secrets file that works in both environments, and a GitHub Actions workflow that ties them together.
1. Secrets that work everywhere
The trick is making .kamal/secrets work on your laptop and in CI. For the registry password, it’s straightforward – just read the environment variable. For the Rails master key, you need a fallback:
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(printenv RAILS_MASTER_KEY || cat config/credentials/production.key)
Locally, printenv RAILS_MASTER_KEY fails silently and cat reads your production key file. In CI, the environment variable is set from GitHub Secrets and printenv succeeds. One line, both worlds.
2. SSH access for GitHub Actions
Your CI runner needs SSH access to the server. A dedicated key pair keeps things clean.
Generate one:
ssh-keygen -t ed25519 -C "[email protected]"
No passphrase – the key needs to work non-interactively.
Then wire it up:
-
Private key goes to your GitHub repo as a secret named
SSH_PRIVATE_KEY. Settings > Secrets and variables > Actions > New repository secret. Paste the entire contents of the private key file, from-----BEGINto-----END. -
Public key goes to your server’s
~/.ssh/authorized_keys. SSH in and append it:
echo "ssh-ed25519 AAAA... [email protected]" >> ~/.ssh/authorized_keys
While you’re in GitHub Secrets, add two more:
-
KAMAL_REGISTRY_PASSWORD– your Docker Hub access token -
RAILS_MASTER_KEY– the contents ofconfig/credentials/production.key
Three secrets total. That’s the entire credential surface.
3. The deploy workflow
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
DOCKER_BUILDKIT: 1
RAILS_ENV: production
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Deploy
run: bundle exec kamal deploy
env:
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
The webfactory/ssh-agent action handles the SSH agent setup – loading the private key into memory without writing it to disk. Kamal then uses that agent to SSH into your server and deploy.
Docker Buildx is there because Kamal uses it to build the image. If you’re building for a different architecture than the runner (amd64 image on an arm64 laptop, for example), Buildx handles the cross-compilation transparently.
What happens on push
- You push to
main - GitHub Actions checks out the code, installs Ruby, and loads your SSH key
-
kamal deploybuilds the Docker image, pushes it to your registry, SSHes into the server, pulls the image, and cuts over traffic
The whole thing takes about three minutes. No terminal required. The deploy log lives in the Actions tab if you ever need to debug.
That’s it
Three GitHub Secrets, a two-line secrets file, and a 30-line workflow. Push to main, wait for green, and it’s live. The best deploy process is the one you stop thinking about.