DEV Community

Cover image for Deploying Rails 8's Solid Trio to Production with a Single Database
Sebastian Jimenez
Sebastian Jimenez

Posted on

Deploying Rails 8's Solid Trio to Production with a Single Database

Deploying Rails 8's Solid Trio to Production with a Single Database

Rails 8 ships with the Solid stack — Solid Cache, Solid Cable, and Solid Queue — replacing Redis for caching, WebSockets, and background jobs. Database-backed infrastructure with zero external dependencies.

Development was smooth. Production wasn't. This post documents the issues we ran into deploying the Solid trio to a Hatchbox-hosted app with a single PostgreSQL database, the mistakes we made debugging them, and what ultimately fixed things.


The Setup

  • Rails 8 with Hotwire (Turbo + Stimulus)
  • PostgreSQL 18 in production, 17 locally
  • Hatchbox for deployment (single provisioned database)
  • Solid Queue, Solid Cache, and Solid Cable

The app is a booking system. Admins manage bookings, and Turbo broadcasts push real-time status updates when bookings are confirmed or cancelled.


Problem 1: Missing Tables

The first error appeared on the first request that touched caching:

PG::UndefinedTable: ERROR: relation "solid_cache_entries" does not exist
Enter fullscreen mode Exit fullscreen mode

What happened

When you run rails solid_cache:install, it generates:

  1. config/cache.yml — the configuration
  2. db/cache_schema.rb — the table definition

There's no migration file. The official approach is to configure a separate cache database in database.yml and run db:prepare in production, which loads schema files for secondary databases.

Our deploy platform, Hatchbox, runs db:migrate — not db:prepare. And db:migrate only runs migration files from db/migrate/. It doesn't load schema files.

The same was true for Solid Cable and its db/cable_schema.rb.

Why it worked locally

In development, the databases had been set up with db:prepare when the project was first created. The tables existed. We never noticed they weren't created through migrations.


Problem 2: One Database, Four Configs

Our database.yml in production had separate entries for each Solid gem:

production:
  primary:
    url: <%= ENV["DATABASE_URL"] %>
  cache:
    url: <%= ENV["DATABASE_URL"] %>
    migrations_paths: db/cache_migrate
  cable:
    url: <%= ENV["DATABASE_URL"] %>
    migrations_paths: db/cable_migrate
  queue:
    url: <%= ENV["DATABASE_URL"] %>
    migrations_paths: db/queue_migrate
Enter fullscreen mode Exit fullscreen mode

This looks like four databases, but they all point to the same DATABASE_URL. Hatchbox provisions one database. We weren't running separate databases — we were running separate configurations pointing to the same database.

The migrations_paths entries pointed to folders like db/cache_migrate/ and db/cable_migrate/. These folders were empty. The actual table definitions lived in schema files, which db:migrate ignores.


What We Did

The official separate-database approach

The Solid Cache and Solid Cable READMEs recommend separate databases with db:prepare. That's the standard approach and it works well when your hosting allows provisioning multiple databases.

We couldn't do that. Hatchbox gives us one database and runs db:migrate.

The single-database workaround

Both READMEs document what to do in this case. From the Solid Cache README:

"When you omit database, databases, or connects_to settings, Solid Cache automatically uses the ActiveRecord::Base connection pool."

Solid Cable's README is more explicit:

"Copy the contents of db/cable_schema.rb into a normal migration and delete db/cable_schema.rb"

This isn't the primary recommended approach — it's a workaround for single-database setups. But it's documented and it works.

Here's what we did:

1. Created regular migrations in db/migrate/:

# db/migrate/20260306054425_create_solid_cache_entries.rb
class CreateSolidCacheEntries < ActiveRecord::Migration[8.0]
  def up
    load Rails.root.join("db/cache_schema.rb")
  end

  def down
    drop_table :solid_cache_entries
  end
end
Enter fullscreen mode Exit fullscreen mode
# db/migrate/20260306062125_create_solid_cable_messages.rb
class CreateSolidCableMessages < ActiveRecord::Migration[8.0]
  def up
    load Rails.root.join("db/cable_schema.rb")
  end

  def down
    drop_table :solid_cable_messages
  end
end
Enter fullscreen mode Exit fullscreen mode

A note on this approach: loading the schema files uses force: :cascade, which drops and recreates the table if it already exists. For a first-time setup this is fine, but for an established database you may want to copy the table definition explicitly instead. The Solid Cable README recommends the explicit copy approach.

2. Removed separate database configs from production database.yml:

production:
  primary:
    <<: *default
    url: <%= ENV["DATABASE_URL"] %>
  queue:
    <<: *default
    url: <%= ENV["DATABASE_URL"] %>
    migrations_paths: db/queue_migrate
Enter fullscreen mode Exit fullscreen mode

We kept queue because Solid Queue ships with actual migration files in db/queue_migrate/, unlike Cache and Cable which only have schema files.

3. Removed connection directives:

# cable.yml — removed connects_to block
production:
  adapter: solid_cable
  polling_interval: 0.1.seconds
  message_retention: 1.day
Enter fullscreen mode Exit fullscreen mode
# cache.yml — removed database: cache
production:
  <<: *default
Enter fullscreen mode Exit fullscreen mode

With these changes, db:migrate creates the tables and both gems fall back to the primary connection pool.


Problem 3: A Misleading Error

After fixing the cache table, we hit a new error when cancelling bookings from the admin panel:

ArgumentError: No unique index found for id
Enter fullscreen mode Exit fullscreen mode

The top of the stack trace pointed to our Turbo broadcast code:

def broadcast_status_update
  broadcast_replace_to(
    self, "status",
    target: "booking_status",
    partial: "bookings/status",
    locals: { booking: self }
  )
end
Enter fullscreen mode Exit fullscreen mode

Our bookings table uses UUID primary keys. We assumed the problem was that ActiveRecord couldn't find a unique index on the UUID id column. We spent time trying various workarounds:

  • Switching to Turbo::StreamsChannel.broadcast_replace_to with string-based channel names
  • Rendering HTML with ApplicationController.render instead of passing partial:
  • Adding an explicit unique index on bookings.id

None of it worked.

What actually happened

We eventually pulled the full stack trace from Bugsnag:

activerecord/insert_all.rb:165:in `find_unique_index_for'
  ...
solid_cable/message.rb:15:in `broadcast'
solid_cable/lib/action_cable/subscription_adapter/solid_cable.rb:19:in `broadcast'
  ...
turbo-rails/app/channels/turbo/streams/broadcasts.rb:13:in `broadcast_replace_to'
app/models/booking.rb:174:in `broadcast_status_update'
Enter fullscreen mode Exit fullscreen mode

The error wasn't in our code. It was in Solid Cable. When our broadcast fired, Turbo handed the message to Action Cable, which handed it to Solid Cable, which tried to insert into the solid_cable_messages table. That table didn't exist yet — the Solid Cable migration hadn't been deployed at that point.

We're not 100% certain about the exact mechanism — whether the error was because the table was missing entirely or because the table existed without proper indexes. The error message ("No unique index found for id") suggests an index issue, but a missing table could also surface differently depending on how ActiveRecord checks for indexes. What we know for certain is that once the migration ran and the table was properly created, the error went away.

What we learned

Read the full stack trace. The error message and top frame made it look like a UUID primary key problem on our bookings table. We spent time adding indexes and rewriting broadcast code. The actual cause was several frames deep in a gem we hadn't thought to check.

It's also a reminder that errors don't always mean what they say. "No unique index found for id" led us to chase index problems when the underlying issue was a missing table.


A note on db:prepare and schema files

During debugging, we also tried switching our deploy command to db:prepare, thinking it would load the schema files. It's worth noting that there are known inconsistencies with how db:prepare handles secondary database schema files. It may not work reliably in all scenarios. The migration approach we ended up with, while not the primary recommended path, turned out to be more predictable.

There's also a known Rails issue where running db:migrate can erroneously empty secondary schema files like queue_schema.rb. Something to watch out for if you're keeping Solid Queue on a separate database config.


Verifying Everything Works

After deploying, we verified each component via the Rails console:

# SolidCache
Rails.cache.write("test_key", "hello")
Rails.cache.read("test_key")
# => "hello"

# SolidCable
SolidCable::Message.count
# => 0

# The original failing action — cancelling a booking
booking = Booking.confirmed.last
booking.soft_delete!(AdminUser.first)
# => No error
Enter fullscreen mode Exit fullscreen mode

Summary

If you're deploying Rails 8's Solid trio to a single-database environment where db:migrate is your deploy command:

  1. Check your deploy command. If it runs db:migrate (not db:prepare), schema files for Solid Cache and Solid Cable won't be loaded. You need regular migrations.

  2. Create migrations for Solid Cache and Solid Cable. The Solid Cable README documents this: copy the table definitions from db/cable_schema.rb into a standard migration. Same for Solid Cache. This is a documented workaround for single-database setups, not the primary recommended approach.

  3. Remove separate database configs from database.yml if you're using one database. Remove connects_to from cable.yml and database from cache.yml. Both gems will fall back to the primary connection pool.

  4. Keep Solid Queue's separate config. Solid Queue ships with actual migrations in db/queue_migrate/ that db:migrate can find.

  5. Read the full stack trace. When debugging production errors with the Solid stack, the error origin might be several frames deep in a gem you didn't expect. Don't assume the top frame tells the whole story.


Final Thoughts

The Solid trio works well once properly set up. The default Rails 8 configuration assumes you can provision multiple databases and run db:prepare — a reasonable assumption, but not universal. If your hosting doesn't support that, the workaround is straightforward and documented, though not prominently.

We made our debugging harder by not reading the full stack trace early enough. The lesson there is universal, not specific to the Solid stack.

Top comments (0)