DEV Community

Mike Rispoli
Mike Rispoli

Posted on

Setting Up Solid Cache on Heroku with a Single Database

I recently wanted to use Solid Cache for a new MVP I'm building out in Ruby on Rails. One thing I wanted was for this to all be deployed to a PaaS and in this case I was using Heroku. While I know Rails 8 pushes hard for Kamal and rolling your own deployment, for a lot of early projects it's nice to just have all the DevOps and CI/CD taken care of for you.

This creates a problem when it comes to Solid Cache. Rails recommends running these with SQLite and in fact I have a production application using SQLite for everything that works amazing. However, Heroku is an ephemeral application server and as such, wipes out your SQLite stores on every deployment.

Since this was an MVP I really just wanted to manage one database rather than introduce Redis or another instance of Postgres. After a lot of failed attempts and much googling, this was the solution I came up with.

The Problem

After deploying the Rails 8 application to Heroku, I encountered this error when trying to use rate limiting:

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

This occurred because Rails 8's rate limiting feature depends on Rails.cache, which in production is configured to use Solid Cache by default. However, the solid_cache_entries table didn't exist in our database.

This worked locally for me because in development Rails uses an in memory store so no database was required. It wasn't until deployment that I was able to see the error.

Understanding Solid Cache

Rails 8 introduces the "Solid" stack as default infrastructure:

  • Solid Cache - Database-backed cache store (replaces Redis/Memcached)
  • Solid Queue - Database-backed job queue (replaces Sidekiq/Resque)
  • Solid Cable - Database-backed Action Cable (replaces Redis for WebSockets)

By default, Rails 8 expects these to use separate databases. The solid_cache:install generator creates:

  • config/cache.yml - Cache configuration
  • db/cache_schema.rb - Schema file (NOT a migration)
  • Configuration pointing to a separate cache database

Why Use a Single Database on Heroku?

For our MVP, I chose to use a single PostgreSQL database for several reasons:

Cost and Simplicity

  • Heroku provides one DATABASE_URL - Additional databases cost extra
  • Simpler architecture - Fewer moving parts during initial development
  • Easier to manage - Single database connection, single backup strategy

Future Scalability Options

When you outgrow this setup, you have clear upgrade paths:

  • Redis - Better performance for high-traffic apps, separate caching layer
  • Separate PostgreSQL database - Isolate cache from primary data
  • Managed cache service - Heroku Redis, AWS ElastiCache, etc.

Why Not SQLite for Cache on Heroku?

While Solid Cache supports SQLite, Heroku's filesystem is ephemeral:

  • Files are wiped on dyno restart (at least once per 24 hours)
  • Deployments create new dynos with fresh filesystems
  • You'd lose all cached data frequently

SQLite-backed Solid Cache works great for:

  • Single-server VPS deployments (Kamal, Hetzner, DigitalOcean Droplets)
  • Containerized apps with persistent volumes
  • Development/staging environments

But for Heroku and similar PaaS platforms, use PostgreSQL or Redis for caching.

What I Tried (And What Didn't Work)

Attempt 1: Running the Generator

bin/rails solid_cache:install
Enter fullscreen mode Exit fullscreen mode

Result: Created cache_schema.rb but no migration file. Changed cache.yml to point to a separate cache database that doesn't exist on Heroku.

Attempt 2: Official Multi-Database Setup

Following the official Rails guides, I configured database.yml with separate database entries:

production:
  primary:
    url: <%= ENV["DATABASE_URL"] %>
  cache:
    url: <%= ENV["DATABASE_URL"] %>  # Same database, different connection
Enter fullscreen mode Exit fullscreen mode

And cache.yml:

production:
  database: cache
Enter fullscreen mode Exit fullscreen mode

Result: The cache_schema.rb file wasn't loaded by db:migrate or db:prepare. Rails expected separate databases with separate schema files.

Attempt 3: Using db:prepare

Ran bin/rails db:prepare hoping it would load all schema files.

Result: Only loaded db/schema.rb (main migrations), ignored db/cache_schema.rb.

The Solution: Migration-Based Approach

After researching (including this Reddit thread), I found the working solution for single-database Heroku deployments.

Step 1: Configure cache.yml for Single Database

Remove the database: configuration from production in config/cache.yml:

# config/cache.yml
default: &default
  store_options:
    max_size: <%= 256.megabytes %>
    namespace: <%= Rails.env %>

development:
  <<: *default

test:
  <<: *default

production:
  <<: *default  # No database: specified - uses primary connection
Enter fullscreen mode Exit fullscreen mode

Important: According to the Solid Cache README, when you omit database, databases, or connects_to settings, Solid Cache automatically uses the ActiveRecord::Base connection pool (your primary database).

Step 2: Create a Migration for the Cache Table

Generate a migration to create the solid_cache_entries table:

bin/rails generate migration CreateSolidCacheEntries --database=cache
Enter fullscreen mode Exit fullscreen mode

The --database=cache flag keeps the migration organized (though it still runs against the primary database in our single-DB setup).

Step 3: Copy Table Definition from cache_schema.rb

Update the generated migration with the exact table structure from db/cache_schema.rb:

# db/migrate/YYYYMMDDHHMMSS_create_solid_cache_entries.rb
class CreateSolidCacheEntries < 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
      t.index [:key_hash, :byte_size]
      t.index :key_hash, unique: true
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 4: Run Migration Locally

bin/rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Verify the table was created:

bin/rails runner "puts ActiveRecord::Base.connection.table_exists?('solid_cache_entries')"
# Should output: true
Enter fullscreen mode Exit fullscreen mode

Step 5: Keep Development Simple

Leave development environment using :memory_store in config/environments/development.rb:

config.cache_store = :memory_store
Enter fullscreen mode Exit fullscreen mode

This is the Rails convention and keeps development simple. Production uses Solid Cache, development uses in-memory caching.

Step 6: Deploy to Heroku

Your existing Procfile with the release phase will handle the migration:

release: bundle exec rails db:migrate
web: bundle exec puma -C config/puma.rb
Enter fullscreen mode Exit fullscreen mode

Deploy and the migration runs automatically during the release phase.

Verification

After deployment, verify Solid Cache is working:

  1. Check Heroku logs during release phase - should see migration run
  2. Test rate limiting - Try to trigger rate limits on login endpoints
  3. Check cache in Rails console:
   # On Heroku
   heroku run rails console

   # Test cache
   Rails.cache.write('test_key', 'test_value')
   Rails.cache.read('test_key')  # Should return 'test_value'

   # Check table
   SolidCache::Entry.count  # Should be > 0 if cache is working
Enter fullscreen mode Exit fullscreen mode

Cache Expiration and Cleanup

Solid Cache includes automatic cleanup to prevent indefinite growth. Our configuration uses both size and age-based expiration:

# config/cache.yml
default: &default
  store_options:
    max_age: <%= 30.days.to_i %>     # Delete entries older than 30 days
    max_size: <%= 256.megabytes %>   # Delete oldest when exceeds 256MB
    namespace: <%= Rails.env %>
Enter fullscreen mode Exit fullscreen mode

How Automatic Cleanup Works

Solid Cache uses a write-triggered background thread (not a separate job system):

  1. Write tracking - Every cache write increments an internal counter
  2. Automatic activation - After ~50 writes, cleanup runs on a background thread
  3. Cleanup logic:
    • If max_size exceeded → Delete oldest entries (LRU eviction)
    • If max_age set and size OK → Delete entries older than max_age
    • Deletes in batches of 100 entries (expiry_batch_size)
  4. SQL-based deletion - Runs standard SQL DELETE queries:
   DELETE FROM solid_cache_entries
   WHERE created_at < NOW() - INTERVAL '30 days'
   LIMIT 100
Enter fullscreen mode Exit fullscreen mode

Important Characteristics

  • No Solid Queue required - Uses built-in Ruby threads, not Active Job
  • No cron jobs needed - Self-managing and automatic
  • Database-agnostic - Pure SQL, works with any ActiveRecord adapter
  • Efficient - Background thread idles when cache isn't being written to
  • ⚠️ Write-dependent - Cleanup only triggers when cache receives writes

For rate limiting (which writes on every login attempt), this mechanism works perfectly and requires no additional infrastructure.

Why 30 Days for Rate Limiting?

Rate limiting data is inherently short-lived:

  • Rate limit windows are 3 minutes
  • Session data expires in days, not months
  • Old cache entries from expired sessions serve no purpose

30 days is generous for this use case and prevents cache bloat while maintaining safety margins.

Key Takeaways

  1. Rails 8's default Solid Stack assumes multi-database setup - This doesn't match Heroku's single DATABASE_URL model
  2. Schema files aren't migrations - db/cache_schema.rb won't be loaded by db:migrate
  3. Omitting database: in cache.yml uses the primary connection - This is the key for single-database setups
  4. Create a regular migration - Convert the schema file to a migration for single-database deployments
  5. SQLite doesn't work on ephemeral filesystems - Use PostgreSQL or Redis for caching on Heroku
  6. Development can use :memory_store - No need to complicate local development
  7. Automatic cleanup is built-in - Solid Cache handles expiration via background threads, no Solid Queue or cron jobs required

Future Migration Path

When your app scales and you need better cache performance:

  1. Add Heroku Redis (~$15/month for hobby tier)
  2. Update production.rb:
   config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }
Enter fullscreen mode Exit fullscreen mode
  1. Remove Solid Cache dependency if desired, or keep for other purposes

The migration is straightforward and won't require code changes beyond configuration.

Additional Resources

Top comments (0)