Auto-deploying Rails 8 to staging with Kamal and a self-hosted GitHub Actions runner
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
Moving CI to a self-hosted runner after GitHub broke our billing
Dead CI, live deploys: folding every job onto the homelab runner, deleting the hosted-runner compensation machinery, and the CVE backlog waiting behind the gate.
A CI failure playbook for a one-person Rails project
Writing down the rules for what to do when CI goes red on a solo Rails project, and the GitHub limitation that turned the merge gate into a load-bearing comment.
A hardening ticket that needed re-deriving before I could implement it
A stale task that would have undone the CI migration, a master-key fix that escalated from shrink-the-window to close-it, and a parser that silently eats plausible syntax.