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.
| Hook | When it fires |
|---|---|
pre-connect | Before SSHing into hosts |
pre-build | Before the image is built |
pre-deploy | After image pull, before any container restarts |
post-deploy | After 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:
--primaryruns on one host (first in thewebrole). No--primary, no fix--versionpins to the image Kamal just pulled. Without it you run against the old live image and miss the new migrations-dforwardsKAMAL_DESTINATIONso 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:
kamal app stop(or pull from LB)kamal app exec --primary --reuse "bin/rails db:rollback STEP=2"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.
Technical co-founder specialized in SaaS, DevOps, AI agents, and data platforms. Building and scaling with Ruby on Rails, n8n, and fast feedback loops.
Build Custom n8n Nodes & Deploy with Kamal
Learn how to create your own n8n community nodes from scratch and deploy them to production using Kamal. Includes a random number generator example, TypeScript node structure, Docker development setup, and production deployment.
Rails Migration Best Practices
Three rules that save you from broken deploys: never modify a committed up method, never call models from migrations, and always provide a reversible down.
Refactoring Fat Models in Rails: Delegate, Extract, and Compose
Reduce complexity in Rails models with method delegation, extraction into service classes, and composed_of - keeping ActiveRecord lean and SRP-aligned.