Deploying Rails 8 to Heroku
A Rails 8 deploy to Heroku finishes green and the application is broken. The build succeeds, the
release phase succeeds, the dyno boots, / renders, and then the first background job, the first
cache write or the first Turbo broadcast raises PG::UndefinedTable. Nothing in the deploy output
warned about it, because the task that was supposed to create those tables decided it had nothing
to do and said so by printing nothing.
Everything below was run either against launchkit-codes, the Heroku application this site runs
on, or against a scratch Rails 8.1.3.1 app on a local PostgreSQL 17.7. Heroku commands were
read-only: apps:info, pg:info, releases:output, the builds API, and a handful of one-off
dynos via heroku run. The scratch app is a plain rails new --database=postgresql with the Solid
installers run, pinned to rails 8.1.3.1, solid_cache 1.0.10, solid_queue 1.7.0 and
solid_cable 4.1.0.
Three phases, and only the middle one can stop the deploy
git push heroku main starts a build, then a release, then a dyno boot, and each of the three can
surprise you differently. Here is the real build log for release v136 of this site, fetched from
the Platform API builds endpoint, trimmed to its first and last lines:
-----> Building on the Heroku-24 stack
-----> Using buildpack: heroku/ruby
-----> Ruby app detected
-----> Using Ruby version: ruby-3.3.9
-----> Installing bundler 4.0.10
-----> Installing dependencies using bundler 4.0.10
Running: BUNDLE_WITHOUT='development:test' BUNDLE_PATH=vendor/bundle BUNDLE_BIN=vendor/bundle/bin BUNDLE_DEPLOYMENT=1 bundle install -j4
Bundle complete! 55 Gemfile dependencies, 142 gems now installed.
-----> Preparing app for Rails asset pipeline
Running: rake assets:precompile
≈ tailwindcss v4.3.3
Done in 13s
Asset precompilation completed (16.08s)
Cleaning assets
Running: rake assets:clean
-----> Detecting rails configuration
-----> Discovering process types
Procfile declares types -> release, web
Default types for buildpack -> console, rake
-----> Compressing...
Done: 135.4M
-----> Launching...
! Release command declared: this new release will not be available until the command succeeds.
Released v136
Assets are compiled in the build, not on the dyno, which is why a missing tailwindcss binary or
a JavaScript error fails the push rather than the page. The line that matters operationally is the
last one with the exclamation mark: a Heroku release phase gates the release. A non-zero exit there
leaves the previous release serving traffic, which is the behaviour you want and also the reason
the release command must be idempotent. It runs on every deploy, including the deploys where
nothing about the schema changed.
The Procfile on this repository is three lines of content:
web: bin/rails server
release: bin/rails db:prepare db:load_solid_schemas
# worker: bin/jobs
The second task is not standard and the rest of this page is mostly about why it exists.
Heroku ignored the .ruby-version file in the repository
.ruby-version is tracked in this repository and contains ruby-4.0.5. The slug contains it too.
The build used Ruby 3.3.9. Both facts, read off the running application:
$ heroku run --no-tty -a launchkit-codes -- bash -c 'cat /app/.ruby-version; ruby -v'
ruby-4.0.5
ruby 3.3.9 (2025-07-24 revision f5c772fc7c) [x86_64-linux]
The build log says why, in a warning that is easy to scroll past because the build succeeded:
###### WARNING:
You have not declared a Ruby version in your Gemfile.
To declare a Ruby version add this line to your Gemfile:
```
ruby "3.3.9"
```
The classic heroku/ruby buildpack reads the ruby directive in the Gemfile and the RUBY
VERSION stanza that Bundler writes into Gemfile.lock. It does not read .ruby-version, and
Heroku's own Ruby versions article does not
mention the file at all: it documents the Gemfile keyword and says of the lockfile that "the locked
version of the Ruby version will always 'win'". This Gemfile.lock has no RUBY VERSION stanza,
because Bundler stopped emitting one when the version is not declared in the Gemfile, so there was
nothing left for the buildpack to read and it fell back to its own default.
The consequence is that a repository can be developed on one Ruby for months and deployed on
another the whole time, with a green build every push. Declaring ruby "4.0.5" in the Gemfile is
the fix and it costs a failed build the day Heroku has not packaged that version for heroku-24
yet, which is a much better failure than discovering the mismatch from a backtrace.
One database, four connection configs
Heroku's PostgreSQL add-on gives you one database and sets one environment variable. A Rails 8 app
generated today declares four connections, because Solid Cache, Solid Queue and Solid Cable each
want their own. The generated production block names three databases that do not exist on
Heroku and a role that does not exist either:
production:
primary: &primary_production
<<: *default
database: rails_deploy_heroku_production
username: rails_deploy_heroku
password: <%= ENV["RAILS_DEPLOY_HEROKU_DATABASE_PASSWORD"] %>
cache:
<<: *primary_production
database: rails_deploy_heroku_production_cache
migrations_paths: db/cache_migrate
Set DATABASE_URL the way Heroku does and ask the app what it resolved. In the scratch app, with
the generated file untouched:
primary {adapter: "postgresql", host: "localhost", port: 15432, database: "heroku_scratch", username: "mehdifarsi"}
cache {adapter: "postgresql", database: "rails_deploy_heroku_production_cache", username: "rails_deploy_heroku"}
queue {adapter: "postgresql", database: "rails_deploy_heroku_production_queue", username: "rails_deploy_heroku"}
cable {adapter: "postgresql", database: "rails_deploy_heroku_production_cable", username: "rails_deploy_heroku"}
DATABASE_URL reaches primary and stops, because
ActiveRecord::DatabaseConfigurations#environment_value_for falls back to it only when the entry
is named primary. That resolution order, and the CACHE_DATABASE_URL style key it looks for
first, is taken apart in what database.yml actually resolves to. What
matters on a dyno is the second, third and fourth lines above: those configs kept the localhost
defaults from the file and will try a Unix socket that does not exist in the container.
Two ways out, and both work. Copy the URL into three more config vars, which the code above
supports directly, or point all four at DATABASE_URL in the YAML:
production:
primary: &primary_production
<<: *default
url: <%= ENV["DATABASE_URL"] %>
cache:
<<: *primary_production
migrations_paths: db/cache_migrate
queue:
<<: *primary_production
migrations_paths: db/queue_migrate
cable:
<<: *primary_production
migrations_paths: db/cable_migrate
Both resolve to the same four identical configs. The YAML version reads DATABASE_URL at every
boot, so there is nothing to re-sync when the add-on hands out a new URL; the config-var version
is a snapshot taken at the moment you ran heroku config:set. That is the reason to prefer the
YAML, and it is the shape this repository ships. The cost of the YAML version is that the file
now encodes a deployment assumption, so the same database.yml does not describe a machine with
four real databases, which is what the Kamal path wants.
db:prepare creates none of the Solid tables and prints nothing
bin/rails db:prepare against a fresh single database, with all four configs pointing at it and
db/schema.rb committed the way it is in any real repository, produces no output whatsoever. Here
is the database afterwards:
List of relations
Schema | Name | Type | Owner
--------+----------------------+-------+------------
public | ar_internal_metadata | table | mehdifarsi
public | posts | table | mehdifarsi
public | schema_migrations | table | mehdifarsi
(3 rows)
No solid_cache_entries. No solid_queue_jobs. No solid_cable_messages. The release phase
exits 0, the deploy is released, and the application is one cache write away from a 500.
The mechanism is nine lines in ActiveRecord::Tasks::DatabaseTasks:
def initialize_database(db_config)
with_temporary_pool(db_config) do
begin
database_already_initialized = migration_connection_pool.schema_migration.table_exists?
rescue ActiveRecord::NoDatabaseError
create(db_config)
retry
end
unless database_already_initialized
schema_dump_path = schema_dump_path(db_config)
if schema_dump_path && File.exist?(schema_dump_path)
load_schema(db_config)
end
end
!database_already_initialized
end
end
database_tasks.rb:651. prepare_all calls that once per config, in database.yml order. The
primary pass finds no schema_migrations table, loads db/schema.rb, and creates
schema_migrations as a side effect. The cache pass then asks the same database the same
question, gets true, and skips its schema file. So do queue and cable. The check is per
database, the four configs are one database, and three of them lose.
The ordering detail is worth knowing because it changes what you see. On an app with no committed
db/schema.rb at all, the primary pass loads nothing, schema_migrations still does not exist
when cache is reached, and db/cache_schema.rb does get loaded. That run produced
solid_cache_entries and still no queue or cable tables. Which three of the four you lose depends
on which file happens to create schema_migrations first, which is not a thing to reason about at
deploy time.
Here is what the missing tables cost, from the production-mode scratch app:
cache write -> ArgumentError: No unique index found for key_hash
perform_later -> SolidQueue::Job::EnqueueError: ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR: relation "solid_queue_jobs" does not exist
cable insert -> ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR: relation "solid_cable_messages" does not exist
The Solid Cache one is the nastiest of the three, because No unique index found for key_hash
reads like a schema design complaint rather than a missing table.
The obvious fix is refused, and forcing it is worse
Rails ships a per-config schema load task, so the first thing to try is the first thing that fails:
$ RAILS_ENV=production bin/rails db:schema:load:cache
bin/rails aborted!
ActiveRecord::ProtectedEnvironmentError: You are attempting to run a destructive action against your 'production' database. (ActiveRecord::ProtectedEnvironmentError)
If you are sure you want to continue, run the same command with the environment variable:
DISABLE_DATABASE_ENVIRONMENT_CHECK=1
Tasks: TOP => db:schema:load:cache => db:check_protected_environments
Adding DISABLE_DATABASE_ENVIRONMENT_CHECK=1 to the release command does make the tables appear:
the scratch database went from 3 tables to 18 on that run. The check was right, though, and the
word in the error is the accurate one. db/queue_schema.rb declares 13 tables and every one of
them is create_table ..., force: :cascade, which is a DROP TABLE IF EXISTS before the
CREATE. The release command runs on every deploy.
$ psql -p 15432 -d heroku_scratch -c "INSERT INTO solid_queue_jobs (queue_name, class_name, arguments, priority, active_job_id, created_at, updated_at) VALUES ('default', 'PingJob', '{}', 0, 'abc', now(), now());"
INSERT 0 1
$ psql -p 15432 -d heroku_scratch -tAc "SELECT 'jobs before redeploy: ' || count(*) FROM solid_queue_jobs;"
jobs before redeploy: 1
$ bin/rails db:schema:load:cache db:schema:load:queue db:schema:load:cable
$ psql -p 15432 -d heroku_scratch -tAc "SELECT 'jobs after redeploy: ' || count(*) FROM solid_queue_jobs;"
jobs after redeploy: 0
RAILS_ENV=production, DATABASE_URL and DISABLE_DATABASE_ENVIRONMENT_CHECK=1 were exported for
that whole sequence, which is what a release phase with the override in it looks like.
Every scheduled job, every retry waiting on a backoff and every recurring task row is gone, on a deploy that changed a CSS file. The cache is emptier than it should be and nobody notices; the queue losing rows is a silent data loss bug that looks like a flaky background job for weeks.
A release command that is safe to run every time
What the release phase needs is a task that loads a schema file only when its table is absent.
lib/tasks/solid_schemas.rake in this repository is that task; the version below is the same
thing written for a stock generated app, where all three Solid schemas are missing rather than
two:
namespace :db do
desc "Load the solid_* schemas into the primary database when their tables are missing"
task load_solid_schemas: :environment do
connection = ActiveRecord::Base.connection
{
"solid_cache_entries" => "db/cache_schema.rb",
"solid_queue_jobs" => "db/queue_schema.rb",
"solid_cable_messages" => "db/cable_schema.rb"
}.each do |table, schema_file|
if connection.table_exists?(table)
puts "[db:load_solid_schemas] #{table} already present - skipping."
else
puts "[db:load_solid_schemas] #{table} missing - loading #{schema_file}."
load Rails.root.join(schema_file)
end
end
end
end
It loads the schema file directly rather than going through db:schema:load:NAME, so it never
reaches db:check_protected_environments and needs no override. First run on a fresh database,
with the middle of the Solid Queue load cut out because it is 21 more lines of the same thing:
[db:load_solid_schemas] solid_cache_entries missing - loading db/cache_schema.rb.
-- create_table("solid_cache_entries", {force: :cascade})
-> 0.0105s
[db:load_solid_schemas] solid_queue_jobs missing - loading db/queue_schema.rb.
-- create_table("solid_queue_blocked_executions", {force: :cascade})
-> 0.0068s
[... 21 create_table and add_foreign_key lines cut ...]
-- add_foreign_key("solid_queue_scheduled_executions", "solid_queue_jobs", {column: "job_id", on_delete: :cascade})
-> 0.0004s
[db:load_solid_schemas] solid_cable_messages missing - loading db/cable_schema.rb.
-- create_table("solid_cable_messages", {force: :cascade})
-> 0.0021s
Every run after that, which is what release v136 of this site actually printed:
$ heroku releases:output v136 -a launchkit-codes
[db:load_solid_schemas] solid_cache_entries already present - skipping.
[db:load_solid_schemas] solid_cable_messages already present - skipping.
Two lines rather than three, because this repository's db/schema.rb already contains the
solid_queue_* tables and the task here only guards cache and cable. The version above, written
for a stock generated app, guards all three.
The table-name guard is the weak point and it is worth stating rather than hiding. The task checks
one table per schema file, so a half-loaded schema, or a Solid Queue upgrade that adds a table to
db/queue_schema.rb without touching solid_queue_jobs, passes the check and loads nothing. The
cost of the alternative, comparing the whole table set, is a release command that has an opinion
about the contents of a gem's schema file. Keeping the guard cheap and the failure mode loud in
the release log is the trade made here. The same gap opens on the container path, for the same
reason and with a different place to put the fix, which is
deploying Rails with Kamal; what the release phase guarantees about
ordering is in
migrations that do not break a running deploy.
What the dyno sets that is not in heroku config
heroku config on this application lists ten variables, and RAILS_ENV is not one of them. The
values that make a Rails app behave like a production Rails app arrive from a shell script the
buildpack writes into the slug:
$ heroku run --no-tty -a launchkit-codes -- cat /app/.profile.d/ruby.sh
export LANG=${LANG:-en_US.UTF-8}
export PUMA_PERSISTENT_TIMEOUT=${PUMA_PERSISTENT_TIMEOUT:-95}
export RACK_ENV=${RACK_ENV:-production}
export RAILS_ENV=${RAILS_ENV:-production}
export SECRET_KEY_BASE=${SECRET_KEY_BASE:-<128 hex characters, redacted here>}
export RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES:-enabled}
export RAILS_LOG_TO_STDOUT=${RAILS_LOG_TO_STDOUT:-enabled}
export GEM_PATH="$HOME/vendor/bundle/ruby/3.3.0:$GEM_PATH"
export PATH="$HOME/bin:$HOME/vendor/bundle/bin:$HOME/vendor/bundle/ruby/3.3.0/bin:$PATH"
export DISABLE_SPRING="1"
export MALLOC_ARENA_MAX=${MALLOC_ARENA_MAX:-2}
export BUNDLE_PATH=${BUNDLE_PATH:-vendor/bundle}
export BUNDLE_WITHOUT=${BUNDLE_WITHOUT:-development:test}
export BUNDLE_BIN=${BUNDLE_BIN:-vendor/bundle/bin}
export BUNDLE_DEPLOYMENT=${BUNDLE_DEPLOYMENT:-1}
One value in that block is a real secret for a live application, so it is the only thing on this page that has been altered: the 128 hex characters are replaced by a note. Everything else is verbatim.
The SECRET_KEY_BASE line deserves a minute. This application sets RAILS_MASTER_KEY and its
credentials contain a secret_key_base, and neither is what signs its cookies:
$ heroku run --no-tty -a launchkit-codes -- bin/rails runner 'puts "secret_key_base == ENV: #{Rails.application.secret_key_base == ENV["SECRET_KEY_BASE"]}"; puts "credentials hold one: #{!Rails.application.credentials.secret_key_base.nil?}"'
secret_key_base == ENV: true
credentials hold one: true
Rails.application.secret_key_base prefers ENV["SECRET_KEY_BASE"], the shell default wins
because nobody set the config var, and the signing key for every session cookie on the application
is a value that was generated by a buildpack and is not written down anywhere you control. Setting
SECRET_KEY_BASE yourself with heroku config:set takes it back. The cost is one more secret to
store outside the repository, on an application that already keeps
its secrets in encrypted credentials precisely to avoid that.
RAILS_SERVE_STATIC_FILES=enabled is the other line worth noticing, because it means Puma is
serving your compiled assets. There is no nginx in front of it.
The connection budget is 20
heroku-postgresql:essential-0 is the $5 plan, and its numbers put a ceiling on how you scale
before anything else does:
$ heroku pg:info -a launchkit-codes
Plan: essential-0
Status: Available
Connections: 6/20
PG Version: 18.3
Data Size: 16.5 MB / 1 GB (1.62%) (In compliance)
Tables: 40/4000 (In compliance)
Fork/Follow: Unsupported
Rollback: Unsupported
Six of twenty, with a single Basic web dyno running SOLID_QUEUE_IN_PUMA=true so the job
supervisor shares the process. Puma defaults to 3 threads and database.yml sizes the pool from
RAILS_MAX_THREADS, which is unset here, so the pool default of 5 applies. A second web dyno
roughly doubles that count, and WEB_CONCURRENCY above 1 multiplies it again per worker. Twenty
connections is between three and four dynos, not thirty.
Rollback: Unsupported on this plan is the line to read twice before a migration. There is no
point-in-time restore here to undo a bad db:prepare, so
a migration that is safe in both directions is the
whole of your safety net.
One note on dyno sizing, because the obvious commands lie. nproc inside a Basic dyno printed 8
and free -m printed 63257 MB of total memory. Both describe the host machine. The container's
actual limit is in the cgroup:
$ heroku run --no-tty -a launchkit-codes -- cat /sys/fs/cgroup/memory.max
cat: /sys/fs/cgroup/memory.max: No such file or directory
$ heroku run --no-tty -a launchkit-codes -- cat /sys/fs/cgroup/memory/memory.limit_in_bytes
536870912
512 MB. A Puma worker count derived from nproc on a Heroku dyno will take the application over
that and into R14 Memory quota exceeded.
Getting from nothing to a first deploy
The whole of what it takes to deploy Rails to Heroku from an empty account is three commands, and
those three are the only block on this page that was not executed while writing it, because each
of them creates a billable resource on a real account. They are the sequence
written in the deploy-heroku.md that ships with this product's documentation:
heroku create your-app-name
heroku addons:create heroku-postgresql:essential-0
heroku config:set RAILS_MASTER_KEY="$(cat config/master.key)"
Everything else the application needs is already in the repository: the Procfile with its
release line, and the production block in database.yml pointing all four configs at
DATABASE_URL.
The one production setting worth arguing about is assume_ssl, and the advice you will find is
backwards for Heroku. Heroku's router terminates TLS and sets X-Forwarded-Proto, which is all
config.force_ssl = true needs to see a plain HTTP request for what it is and redirect it.
config.assume_ssl = true makes request.ssl? answer yes to every request, including the ones
that really did arrive in the clear, so force_ssl stops redirecting and starts serving 200s over
http://. This application had that, and Google indexed http:// copies of pages it already had
over https://. config/environments/production.rb here now reads
config.assume_ssl = ENV["ASSUME_SSL"] == "true", and ASSUME_SSL is not among the ten config
vars, which is to say it is off:
$ curl -s -o /dev/null -D - http://launchkit.codes/ | head -4
HTTP/1.1 301 Moved Permanently
Content-Length: 0
Content-Type: text/html; charset=utf-8
Location: https://launchkit.codes/
Turn assume_ssl on only for a proxy that really does hide the scheme, which Heroku's is not.
What this page does not cover
The Heroku application behind every measurement here is launchkit-codes, a single Basic web dyno
with one essential-0 database and a mailgun:starter add-on, on the heroku-24 stack with the
classic heroku/ruby buildpack. A container-stack deploy, Cloud Native Buildpacks, and the
heroku-26 stack the build log keeps offering were not tested, and none of the buildpack detail
above should be assumed to survive the move: /app/.profile.d/ruby.sh is an artefact of the
classic buildpack and of nothing else.
Also out of scope, and each for a reason. Pipelines, review apps and heroku pg:promote, because
this application has one environment and no staging. Autoscaling and dyno formation past web: 1,
because the connection arithmetic above is where the interesting limit is at this size and the
rest is a pricing page. Active Storage on a dyno, which needs S3 and therefore an AWS account this
page has not got. And the whole question of whether Solid Queue in the web dyno is the right call
at all, which is
Solid Queue against Sidekiq rather than a deployment question.
No claim is made here about whether the buildpack's generated SECRET_KEY_BASE survives across
builds. The value was read once, from one dyno, and a single sample proves nothing about the next
deploy. Set the config var and the question stops mattering.
Comments
No comments yet. Be the first.