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 -----BEGIN to -----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 of config/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

  1. You push to main
  2. GitHub Actions checks out the code, installs Ruby, and loads your SSH key
  3. kamal deploy builds 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.