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 runpermissions
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.