Skip to content
Development

Auto-deploying Rails 8 to staging with Kamal and a self-hosted GitHub Actions runner

By Victor Da Luz
railskamalgithub-actionscidev-logblog-manager

I kept forgetting to deploy to staging. Merged a branch, moved on, then three days later wondered why staging was out of date. The fix is obvious, make deploys automatic, but I put it off because I assumed it would be complicated.

It was complicated. Just not in the ways I expected.

What I was trying to do

blog-manager is a Rails 8 app I deploy with Kamal 2 to a homelab server. Staging runs on a separate LXC container on the same Proxmox host. I wanted every merge to main to automatically deploy to staging, keeping it continuously up to date.

Why a self-hosted runner

GitHub-hosted runners can’t reach blog-manager-staging.internal; it’s a private LAN address. And even with a tunnel, I’d still need Docker available in the runner for Kamal to build and push the image.

The clean answer is a self-hosted runner on the homelab LAN. It has direct access to the staging host, Docker is already installed, and I control the environment entirely.

I set up a new LXC container on Proxmox (Debian 13), installed the GitHub Actions runner as a systemd service, and registered it with the blog-manager-staging label. The workflow targets it with runs-on: [self-hosted, blog-manager-staging].

The first wall: Ruby

The workflow needed bundle install to get Kamal’s gem dependencies. My first attempt used actions/setup-ruby@v1, and it fails on self-hosted runners. It looks for Ruby in $RUNNER_TOOL_CACHE, which doesn’t exist unless you’ve set up the tool cache infrastructure.

The fix: install Ruby directly on the runner using mise.

mise settings ruby.compile=false   # use prebuilt binaries, don't compile from source
mise use --global ruby@3.3.6

The ruby.compile=false setting matters. Without it, mise tries to compile Ruby from source, which takes 20+ minutes on a low-spec container. With prebuilt binaries, 30 seconds.

Then I added the Ruby bin path to the runner’s .env file (~/actions-runner/.env), which sets environment variables for every job.

The second wall: OOM

bundle install with native gem extensions needs memory. The LXC container had 512MB RAM and no swap (ZFS-based LXC containers can’t use swap files). Exit code 137. OOM kill.

The fix was to bump the container’s RAM on the Proxmox host:

pct set 1028 -memory 2048

Proxmox applies this live, no reboot needed. 2GB is comfortable for bundle install with native extensions.

The third wall: Docker permissions

Kamal needs Docker to build and push the image. The runner runs as gh-runner, which wasn’t in the docker group. After adding it and restarting the runner service, Kamal could authenticate to the registry and start the build.

The fourth wall: the one that took the longest

With Docker working, the image built and pushed successfully. But the container kept failing its health check:

ArgumentError: Missing `secret_key_base` for 'production' environment

secret_key_base is in Rails credentials, which need RAILS_MASTER_KEY to decrypt. The workflow was already setting this as an env var. So why wasn’t it reaching the container?

Kamal injects secrets into the container by reading .kamal/secrets-common, resolving the values, and writing them to a file on the remote host. That file is what the container reads at startup.

The secrets file read: RAILS_MASTER_KEY=$(cat config/master.key)

On the runner, config/master.key is gitignored and doesn’t exist after checkout. So cat fails, RAILS_MASTER_KEY becomes empty, and Kamal writes an empty value to the container’s env file.

My first attempt was to fall back to the env var if the file doesn’t exist:

RAILS_MASTER_KEY=${RAILS_MASTER_KEY:-$(cat config/master.key)}

This didn’t work either. After digging through Kamal’s source, here’s why: Kamal evaluates secrets files using Dotenv.parse, not a bash subprocess. Dotenv handles $(cmd) by running it as a backtick Ruby subprocess (which inherits the system environment). But ${VAR:-fallback} is dotenv’s own variable substitution, and it only sees the dotenv-local environment, not the workflow’s env vars.

This is also why the registry login worked the whole time: KAMAL_REGISTRY_PASSWORD=$(bin/rails credentials:fetch ...) uses $(cmd) syntax, so the subprocess inherits the real RAILS_MASTER_KEY. A maddening half-working state; image builds and pushes, container fails to start.

The fix: write config/master.key from the secret before running kamal deploy.

- name: Write Rails master key
  env:
    RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
  run: echo "$RAILS_MASTER_KEY" > config/master.key

- name: Deploy to staging
  env:
    RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
  run: bin/kamal deploy -d staging

The final workflow

name: Deploy staging

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-staging
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: [self-hosted, blog-manager-staging]
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v6

      - name: Install gems
        run: bundle install

      - name: Write Rails master key
        env:
          RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
        run: echo "$RAILS_MASTER_KEY" > config/master.key

      - name: Deploy to staging
        env:
          RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
        run: bin/kamal deploy -d staging

What I’d tell myself before starting

Don’t try the setup-ruby actions on a self-hosted runner. Install Ruby directly with mise and put it in the runner’s PATH via .env. Set ruby.compile=false first or you’ll wait 20 minutes for a source build.

Give the container at least 2GB RAM. 512MB is not enough for bundle install. Native gems need room to compile. ZFS swap doesn’t work in LXC.

Add gh-runner to the docker group. Restart the runner service afterward.

Understand how Kamal reads secrets. It uses dotenv, not bash. Env vars from your CI environment are not available inside ${VAR:-fallback} in the secrets file. Write config/master.key from your CI secret before running kamal deploy. The registry login working while the container startup fails is the tell; that asymmetry comes from the difference between $(cmd) subprocesses (inherit env) and dotenv variable substitution (doesn’t).

The staging deploy now runs automatically on every merge to main. Staging is always current. I haven’t thought about it since.

Related reading