LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 0 views

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.

#rails #deployment

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.