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

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.

#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.