Continuous Deployment with Kamal

This site deploys on every push to main. No manual steps, no SSH sessions, no forgetting to deploy. Here's how the pieces fit together.

Kamal on the server

Kamal is a deployment tool from 37signals. It wraps Docker containers with zero-downtime deploys, health checks, and rolling restarts.

Configuration lives in config/deploy.yml:

service: goodbran
image: goodbran/goodbran
servers:
  - 192.0.2.1

registry:
  server: ghcr.io
  username: goodbran
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL

The server runs a Docker container. GitHub Actions builds and pushes that container. Kamal handles the rest.

GitHub Actions for CI

The workflow file in .github/workflows/ci.yml runs tests on every push. Not much to say here — standard Rails testing with a Postgres service container.

Automatic deploys

The second workflow in .github/workflows/deploy.yml triggers on pushes to main:

name: Deploy

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: $
          password: $

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/goodbran/goodbran:$
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Setup Kamal
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.3

      - name: Deploy with Kamal
        env:
          KAMAL_REGISTRY_PASSWORD: $
          RAILS_MASTER_KEY: $
          DATABASE_URL: $
        run: |
          gem install kamal
          kamal deploy --version=$

The build uses Docker layer caching via GitHub Actions cache. First deploys are slow; subsequent ones reuse layers.

The server setup

The target server needs:

  • Docker installed
  • A user with SSH key access
  • docker run permissions

Kamal connects over SSH, pulls the image, and orchestrates the container swap. It boots the new container, waits for the health check to pass, then redirects traffic and stops the old one.

Secrets management

Two locations:

  • GitHub Secrets: GITHUB_TOKEN (auto), RAILS_MASTER_KEY, DATABASE_URL. These flow into the workflow.
  • Server env: Kamal passes these to the container at runtime.

The RAILS_MASTER_KEY unlocks Rails' encrypted credentials. The DATABASE_URL points to the Postgres instance.

The result

Push to main. Two minutes later, the change is live. No manual deploy commands, no context switching from coding to ops.

That is continuous deployment: every successful build ships immediately.