Deploying Rails 8 to Render
A Rails 8 deploy to Render fails twice before it fails in an interesting way, and only the third failure is quiet. The first two are loud and take a minute each to fix. The third one produces a green deploy, a working home page, and an application whose cache, job queue and Turbo broadcasts are all sitting on tables that were never created.
Nothing below ran on Render. I have no deployed service to point at, so every claim here is about
what a Rails 8.1 application does when it is given the environment Render gives it: one
PostgreSQL instance, one DATABASE_URL injected from a fromDatabase reference, a PORT, and
nothing else. The application is a scratch rails new -d postgresql on rails 8.1.4,
solid_cache 1.0.10, solid_queue 1.7.0, solid_cable 4.1.0, puma 8.0.2 and pg 1.6.3,
running on Ruby 4.0.5 against PostgreSQL on port 15432, on an Apple M2 Max. Where a fact comes
from Render's documentation rather than from a command, the sentence says so and links the page.
The two things that are genuinely Render's and cannot be checked from here, the plan names and
what the container image contains, are not claimed at all.
The two files
Render's Rails guide asks for a build command, a start command and a render.yaml if you want the
service defined in the repository. This is the blueprint the rest of this page uses. It parses
under YAML.safe_load_file, which is the only thing about it I can verify from here; Render's
blueprint reference is the authority on the field names.
services:
- type: web
name: rails-deploy-render
runtime: ruby
buildCommand: ./bin/render-build.sh
startCommand: bin/rails server
healthCheckPath: /up
envVars:
- key: DATABASE_URL
fromDatabase:
name: rails-deploy-render-db
property: connectionString
- key: RAILS_MASTER_KEY
sync: false
- key: WEB_CONCURRENCY
value: "2"
- key: RAILS_MAX_THREADS
value: "3"
databases:
- name: rails-deploy-render-db
databaseName: rails_deploy_render
No plan: and no region:, because plan identifiers are Render's and change; take them from the
dashboard rather than from an article. sync: false on RAILS_MASTER_KEY means the blueprint
declares the variable and refuses to carry its value, which is what you want for the contents of
config/master.key.
There is no Procfile and no -b 0.0.0.0 in the start command, and neither is an omission.
bin/rails server binds every interface outside development, which railties decides on one line:
default_host = environment == "development" ? "localhost" : "0.0.0.0"
That is railties-8.1.4/lib/rails/commands/server/server_command.rb:221. With PORT=10000 in the
environment the boot banner said so:
[62091] * Puma version: 8.0.2 ("Into the Arena")
[62091] * Min threads: 3
[62091] * Max threads: 3
[62091] * Environment: production
[62091] * Workers: 2
[62091] * Preloading application
[62091] * Listening on http://0.0.0.0:10000
The build script is the one from Render's guide, verbatim:
#!/usr/bin/env bash
set -o errexit
bundle install
bundle exec rails assets:precompile
bundle exec rails assets:clean
bundle exec rails db:migrate
Commit it with the executable bit set. Without it the command fails as
permission denied: ./bin/render-build.sh and nothing in the log explains why; git ls-files -s
should print mode 100755.
The build fails before it reaches the database
Render's Ruby runtime is not a buildpack that writes environment variables into your process for
you. Nothing sets SECRET_KEY_BASE. So if RAILS_MASTER_KEY is not in the service's environment
at build time, the second line of the script is where the deploy stops, with config/master.key
moved aside to simulate a fresh clone:
$ RAILS_ENV=production bin/rails assets:precompile
bin/rails aborted!
ArgumentError: Missing `secret_key_base` for 'production' environment, set this string with `bin/rails credentials:edit` (ArgumentError)
Raised at railties-8.1.4/lib/rails/application/configuration.rb:543. The fix is the
RAILS_MASTER_KEY entry in the blueprint above, with the contents of config/master.key pasted
into the dashboard once. The alternative, exporting SECRET_KEY_BASE_DUMMY=1 for the build the way
the generated Dockerfile does, precompiles assets fine and then leaves you with a booted app that
cannot decrypt its own credentials, so it only moves the failure to the first request that reads
one.
One database, four connections
This is where it gets specific to hosts that hand you a single Postgres, which Render does and
Heroku does. rails new -d postgresql writes a production block with four entries in it:
production:
primary: &primary_production
<<: *default
database: rails_deploy_render_production
username: rails_deploy_render
password: <%= ENV["RAILS_DEPLOY_RENDER_DATABASE_PASSWORD"] %>
cache:
<<: *primary_production
database: rails_deploy_render_production_cache
migrations_paths: db/cache_migrate
queue:
<<: *primary_production
database: rails_deploy_render_production_queue
migrations_paths: db/queue_migrate
cable:
<<: *primary_production
database: rails_deploy_render_production_cable
migrations_paths: db/cable_migrate
DATABASE_URL is merged into exactly one of them. activerecord-8.1.4 does it at
database_configurations.rb:307 with url ||= ENV["DATABASE_URL"] if name == "primary", and the
full merge behaviour is the subject of
what config/database.yml actually resolves to. With only Render's
variable set, printing the four configs gives:
$ bin/rails runner 'ActiveRecord::Base.configurations.configs_for(env_name: "production").each { |c| puts [c.name, c.database, c.host.inspect].join("|") }'
primary|render_single|"localhost"
cache|rails_deploy_render_production_cache|nil
queue|rails_deploy_render_production_queue|nil
cable|rails_deploy_render_production_cable|nil
Three connections with no host. They fall back to a local Unix socket, and a Render web service does not have one:
$ bin/rails db:migrate
bin/rails aborted!
ActiveRecord::ConnectionNotEstablished: connection to server on socket "/tmp/.s.PGSQL.5432" failed: No such file or directory (ActiveRecord::ConnectionNotEstablished)
Is the server running locally and accepting connections on that socket?
The socket path differs on Linux. The failure does not.
Pointing all four at the same URL is worse than the error
The obvious fix is the one config/database.yml documents in its own comments: a non-primary
connection named cache reads CACHE_DATABASE_URL. Set all three to the value Render gave you and
the four configs resolve to the same database. Then run the build script on a fresh Render Postgres:
$ bin/rails db:migrate
$ psql -h localhost -p 15432 -d render_single -c '\dt'
List of relations
Schema | Name | Type | Owner
--------+----------------------+-------+------------
public | ar_internal_metadata | table | mehdifarsi
public | schema_migrations | table | mehdifarsi
public | widgets | table | mehdifarsi
(3 rows)
db:migrate printed nothing at all and exited 0. The deploy is green, the health check passes, the
home page renders, and there is no solid_cache_entries, no solid_queue_jobs and no
solid_cable_messages. The application is configured to use all three: config.cache_store =
:solid_cache_store and config.active_job.queue_adapter = :solid_queue are in the generated
config/environments/production.rb.
The reason is four lines in activerecord-8.1.4/lib/active_record/tasks/database_tasks.rb:651:
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
migrate_all calls that once per configuration, in the order they appear in the file. The check is
"does this connection's database have a schema_migrations table", and all four connections are
looking at the same table. primary runs first, finds nothing, loads db/schema.rb, and creates
schema_migrations as a side effect. By the time cache is asked, database_already_initialized
is true, so db/cache_schema.rb is never read. Same for queue, same for cable.
There is a second shape of the same bug, which is what you get if db/schema.rb is not in the
repository. Then primary has no dump to load, cache is the first connection to find an
uninitialised database, and db/cache_schema.rb wins the race: you get solid_cache_entries and
still no queue and no cable. Which of the two you hit depends on whether somebody committed
db/schema.rb, which is not a fact anybody thinks about while reading a deploy log.
What it looks like afterwards is covered in detail in
deploying Rails 8 to Heroku, which hit the same wall on the same
generated config: the first Rails.cache.write answers ArgumentError: No unique index found for
key_hash rather than anything mentioning a missing table, and the first perform_later answers
SolidQueue::Job::EnqueueError. The Heroku page also shows why bin/rails db:schema:load:cache is
not the fix. It is refused by db:check_protected_environments with
ActiveRecord::ProtectedEnvironmentError, and forcing it with DISABLE_DATABASE_ENVIRONMENT_CHECK=1
turns every subsequent deploy destructive, because db/queue_schema.rb declares 13 tables with
force: :cascade. I ran that here to see it: a job counted 1 before a second db:schema:load:queue
and 0 after.
Render's own Rails guide answers this differently. It tells you to pass --skip-solid when you
generate the application, which sidesteps the whole thing by removing all three. That is a real
answer and it has a price the guide does not print: --skip-solid leaves both
config.cache_store and config.active_job.queue_adapter commented out in the generated
config/environments/production.rb, so the cache becomes [ :file_store, "#{root}/tmp/cache/" ]
(railties-8.1.4/lib/rails/application/configuration.rb:58) on an instance whose filesystem does
not survive a deploy, and the queue adapter becomes :async
(activejob-8.1.4/lib/active_job/queue_adapter.rb:35), which runs jobs in threads inside the web
process and loses them on restart. For an application that sends one welcome email, fine. For
anything with a job you would be annoyed to lose, that is not a deploy configuration, it is a
decision to not have background jobs.
The version I would ship: one connection
Render gives you one database. The honest response is to tell Rails that, rather than to keep four configurations alive and reconcile them at build time. Four files change:
### config/database.yml
production:
- primary: &primary_production
- <<: *default
- database: rails_deploy_render_production
- username: rails_deploy_render
- password: <%= ENV["RAILS_DEPLOY_RENDER_DATABASE_PASSWORD"] %>
- cache:
- <<: *primary_production
- database: rails_deploy_render_production_cache
- migrations_paths: db/cache_migrate
- queue:
- <<: *primary_production
- database: rails_deploy_render_production_queue
- migrations_paths: db/queue_migrate
- cable:
- <<: *primary_production
- database: rails_deploy_render_production_cable
- migrations_paths: db/cable_migrate
+ <<: *default
+ database: rails_deploy_render_production
+ username: rails_deploy_render
+ password: <%= ENV["RAILS_DEPLOY_RENDER_DATABASE_PASSWORD"] %>
### config/environments/production.rb
config.active_job.queue_adapter = :solid_queue
- config.solid_queue.connects_to = { database: { writing: :queue } }
### config/cable.yml
production:
adapter: solid_cable
- connects_to:
- database:
- writing: cable
### config/cache.yml
production:
- database: cache
<<: *default
Miss any one of the last three and the application does not boot. Removing only the database.yml
block gives you
ActiveRecord::AdapterNotSpecified: The 'cable' database is not configured for the 'production'
environment, raised from database_configurations.rb:231, which is a clear enough message that it
cost about thirty seconds, but it is four files and not one and the article that says "just use one
connection" without saying so is wasting your afternoon.
Then the three Solid schema files become ordinary migrations on that one connection. They are
ActiveRecord::Schema.define blocks, so the conversion is mechanical: drop the define wrapper,
drop force: :cascade, wrap the body in a migration class. db/cache_schema.rb becomes 14 lines:
class CreateSolidCacheTables < ActiveRecord::Migration[8.1]
def change
create_table "solid_cache_entries" do |t|
t.binary "key", limit: 1024, null: false
t.binary "value", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "key_hash", limit: 8, null: false
t.integer "byte_size", limit: 4, null: false
t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
end
end
end
db/queue_schema.rb is longer, 13 create_table calls and 8 add_foreign_key lines, and converts
the same way. db/cable_schema.rb is one table.
Now the unmodified bin/render-build.sh from Render's guide, with DATABASE_URL the only database
variable in the environment, against an empty database:
$ psql -h localhost -p 15432 -d render_single -tAc "select tablename from pg_tables where schemaname='public' order by 1"
ar_internal_metadata
schema_migrations
solid_cable_messages
solid_cache_entries
solid_queue_batch_executions
solid_queue_batches
solid_queue_blocked_executions
solid_queue_claimed_executions
solid_queue_failed_executions
solid_queue_jobs
solid_queue_pauses
solid_queue_processes
solid_queue_ready_executions
solid_queue_recurring_executions
solid_queue_recurring_tasks
solid_queue_scheduled_executions
solid_queue_semaphores
widgets
Eighteen tables, one command, no DISABLE_DATABASE_ENVIRONMENT_CHECK, no release-phase task, and
nothing in the build script to forget. Running the build script again with a job already queued
leaves the job there, which is the property the forced db:schema:load cannot have.
The cost, stated plainly: you have given up the ability to move the queue onto its own database
later without a migration and a config change, and you have taken three gem-owned schema files into
your own db/migrate, which means a Solid Queue release that adds a table is now your problem to
notice and port. Both of those are real. The trade is worth it at one database and stops being
worth it the moment there are two, which is also the moment you are paying for a second Postgres
and can afford to do it properly.
What would change the recommendation: a host that gives you more than one database, or an application already large enough that queue traffic is a reason to isolate. Neither describes a service you are putting on Render.
The connection budget
The four-connection shape is not only a schema problem. Each configuration gets its own pool in
every Puma worker, and a pool opens connections the first time a request uses it. I measured it with
a route that touches three of the four in one request (Widget.count, Rails.cache.write plus
read, SolidCable::Message.count), booted with WEB_CONCURRENCY=2 and the default
RAILS_MAX_THREADS of 3, loaded with /usr/sbin/ab -c 6 -n 1000 after a 300-request warm-up, then
grouped pg_stat_activity by application_name, because otherwise the psql doing the counting is
one of the numbers you report. Four connections:
$ psql -h localhost -p 15432 -d render_single -tAc "select application_name, count(*)
from pg_stat_activity where datname='render_single' and backend_type='client backend'
group by 1 order by 1"
bin/rails|18
psql|1
One connection, same route, same load, same query:
bin/rails|6
psql|1
Both numbers are multiplication and nothing else: 2 workers times 3 threads times the number of
pools the request actually opened. Three of them in the four-connection app, not four, because
queue is never touched by a web request that does not enqueue anything. Put one perform_later on
that path and it is 24.
Render documents that a Postgres instance with less than 8 GB of RAM allows
100 simultaneous connections. Eighteen per
web instance means the sixth one you scale to is the first that cannot open all of its connections,
and it arrives as PG::ConnectionBad in whichever request happened to be unlucky rather than as
anything that names the limit. Six per instance gets you to sixteen instances.
Two things I had wrong
I was sure WEB_CONCURRENCY was a no-op. config/puma.rb in a generated Rails 8.1 app mentions
the variable exactly once, inside a comment, and grep -n '^\s*workers' config/puma.rb returns
nothing at all. Then the boot banner printed Workers: 2. Puma reads the variable itself, in
puma-8.0.2/lib/puma/configuration.rb:248:
workers_env = env['WEB_CONCURRENCY']
workers = workers_env && workers_env.strip != "" ? parse_workers(workers_env.strip) : nil
So Render's documented WEB_CONCURRENCY entry does what it says, and the Rails config file that
looks like it should be responsible for it is not.
The second one is the throughput number, and it is the reason the section above reports a connection
count and no requests per second. The first ab run I did, cold, gave 325.92 req/s for the
four-connection app and 535.26 for
the one-connection app, and for about ten minutes I had a paragraph explaining why three extra
connection pools cost you 40% of your throughput. They do not. Warmed up and run again at -n 1000,
the same two applications gave 1045.40 and 951.45, with the four-connection app now ahead. Warmed up
and run a third time, they gave 832.56 and 1552.82, back the other way and further apart than either
earlier run. Then the one-connection app alone, same warm-up, same command, twice: 1552.82 and
1145.57.
Thirty-six percent between two runs of one unchanged binary is larger than any difference the configuration could be making, so the ordering I got in any single run was whichever way the noise fell. Numbers with that much spread do not average into a result, they say the instrument is too coarse for the question, and the honest output of that measurement is that I do not know which is faster and this laptop cannot tell me. The connection count is a different kind of number: it is 18 against 6 on every run because it is a product of two settings and a pool count, and a measurement you can derive from arithmetic is one you can check without running anything.
The free plan
Three facts from Render's free tier documentation, none of which I can verify from here: a free web service spins down after 15 minutes without inbound traffic and takes about a minute to come back; a free Postgres database expires 30 days after creation, with a 14-day grace period before deletion; and it is capped at 1 GB of storage. The 30-day expiry is the one that matters for a project you intend to keep, and it is not a warning that appears in a deploy log.
Render's Rails guide also notes that using preDeployCommand to run bin/rails db:migrate
requires a paid compute plan. On the free plan the migration has to happen in buildCommand, which
is exactly where the single-connection version above puts it and why that version does not need a
release phase to be correct.
What this page does not cover
I have not deployed this to Render, so there is nothing here about build times, what is in the
container image, which Ruby versions the runtime offers, or how the health check behaves during a
zero-downtime cutover. A generated Rails 8.1 app carries a .ruby-version of whatever generated it
(ruby-4.0.5 here) and whether Render honours it is a question for Render's own runtime docs, not
for this laptop. The arm64-darwin, x86_64-linux and aarch64-linux platform entries are already
in the Gemfile.lock that rails new produces, because the generator runs
bundle lock --add-platform twice, so the usual cross-platform bundle failure is one that Rails 8
has already closed.
Also absent: Active Storage, which on a Render web service without a persistent disk needs S3 or an
equivalent because config.active_storage.service = :local writes into a filesystem that does not
survive a deploy; pgbouncer, which Render offers as a connectionPool field on the database and
which changes the connection arithmetic above entirely; running Solid Queue as a second Render
service rather than inside Puma via SOLID_QUEUE_IN_PUMA; and custom domains and TLS, which are
dashboard work with no Rails-side consequence beyond config.force_ssl already being true in the
generated production environment.
The LaunchKit boilerplate does not ship a render.yaml. It ships a config/deploy.yml and a
Dockerfile, because it is built for the container path that
deploying Rails with Kamal describes, and it carries a
db:load_solid_schemas task called from bin/docker-entrypoint for the same trap this page is
about. If you are putting it on Render instead, the four config edits above are the port, and the
task becomes unnecessary once the schemas are migrations. The problems that survive the move are
the ones a starter kit is for: credentials that have to exist before the first build, a queue that
has to be durable before the first customer, and a health check endpoint that answers 200 whether
or not the database is reachable.
Comments
No comments yet. Be the first.