RL ROLAND LOPEZ
// 5 min read

Run Rails Migrations Once in Multi-Host Kamal Deploys

Run Rails Migrations Once in Multi-Host Kamal Deploys — Stop ActiveRecord::ConcurrentMigrationError on multi-host Kamal deploys with a pre-deploy hook that runs db:prepare once on the primary host.

💡

TL;DR: Move db:prepare out of bin/docker-entrypoint and into a Kamal pre-deploy hook that runs once on the primary host against the newly pulled image.

The race condition

What you’ll learn: why the default Rails entrypoint breaks on multi-host Kamal deploys.

The default Rails entrypoint runs migrations on every boot:

# bin/docker-entrypoint
if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
  ./bin/rails db:prepare
fi

One host: fine. Three hosts behind a load balancer: three containers boot at the same moment, all three call db:prepare, all three race the Postgres advisory lock that ActiveRecord uses to serialize migrations. One wins. The other two raise ActiveRecord::ConcurrentMigrationError and crash the boot. Kamal Proxy never sees a healthy response and the deploy stalls.

Kamal does not own your DB state. Anything that needs to happen exactly once per deploy is your job, and pre-deploy is the hook for it.

The pre-deploy hook

What you’ll learn: the full hook script and where each Kamal lifecycle hook fires.

HookWhen it fires
pre-connectBefore SSHing into hosts
pre-buildBefore the image is built
pre-deployAfter image pull, before any container restarts
post-deployAfter all containers finish booting

pre-deploy is the sweet spot: new code is in the registry, old containers still serve traffic, nothing has restarted yet.

Drop this at .kamal/hooks/pre-deploy and chmod +x it:

#!/bin/bash
set -euo pipefail

if [ "${KAMAL_COMMAND:-}" = "rollback" ]; then
  echo "Skipping db:prepare on rollback"
  exit 0
fi

if [ -z "${KAMAL_VERSION:-}" ]; then
  echo "KAMAL_VERSION not set, refusing to run db:prepare" >&2
  exit 1
fi

if [ -z "${KAMAL_DESTINATION:-}" ]; then
  echo "KAMAL_DESTINATION not set; refusing to run against base config" >&2
  exit 1
fi

exec bin/kamal app exec -d "$KAMAL_DESTINATION" \
  --primary --version="$KAMAL_VERSION" "bin/rails db:prepare"

The hook runs on the deployer machine, not inside a container. That is why bin/kamal and KAMAL_* env vars are available.

Three flags do the work:

  • --primary runs on one host (first in the web role). No --primary, no fix
  • --version pins to the image Kamal just pulled. Without it you run against the old live image and miss the new migrations
  • -d forwards KAMAL_DESTINATION so staging migrations stay off prod
⚠️

Skip the rollback short-circuit and you will re-run db:prepare against the new schema while booting old code on rollback. Fail loud, not silent.

Trim the entrypoint, fix bin/kamal

What you’ll learn: the block to delete and the binstub rewrite that keeps Kamal callable from CI and dev containers.

Delete from bin/docker-entrypoint:

if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
  ./bin/rails db:prepare
fi

Containers boot faster, no DB calls on cold start, smaller failure surface.

Kamal hooks run wherever the deploy is kicked off - laptop, dev container, CI runner. The stock bin/kamal starts with require "bundler/setup" and resolves your entire Gemfile, which breaks in stripped-down environments. Rewrite it to install only Kamal via bundler/inline:

require "rubygems"
require "bundler"

kamal_version = Bundler::LockfileParser
  .new(File.read(Bundler.default_lockfile))
  .specs
  .find { |s| s.name == "kamal" }
  &.version
  &.to_s || abort("kamal not found in Gemfile.lock")

require "bundler/inline"

gemfile do
  source "https://rubygems.org"
  gem "kamal", kamal_version, require: false
end

load Gem.bin_path("kamal", "kamal")

Reading the version from Gemfile.lock means the binstub can never drift from the gem you deploy with. bundle update kamal and the binstub follows.

Rollbacks, locks, long migrations

What you’ll learn: the adjacent patterns that pair with the hook in production.

Manual lock. Block CI deploys while you debug:

kamal lock acquire -m "Investigating bug XY"
kamal lock status
kamal lock release

Schema rollback. Hook skips db:prepare on rollback, which is correct - schema rollbacks are deliberate:

  1. kamal app stop (or pull from LB)
  2. kamal app exec --primary --reuse "bin/rails db:rollback STEP=2"
  3. kamal rollback [VERSION]

Never serve traffic against a half-rolled-back schema.

Long migrations. pre-deploy is right for 1-30 seconds. For backfills measured in minutes, run them as a standalone kamal app exec task before kicking off the deploy at all - Kamal Proxy will flag the deploy as stuck otherwise.

ℹ️

Ship checklist: add .kamal/hooks/pre-deploy, rewrite bin/kamal, delete the entrypoint block, deploy to staging first to confirm KAMAL_DESTINATION forwards. One commit, fully reversible.

Roland Lopez
Written by
Roland Lopez

Technical co-founder specialized in SaaS, DevOps, AI agents, and data platforms. Building and scaling with Ruby on Rails, n8n, and fast feedback loops.

Built by Agent Skynet See the agency