Skip to main content

Rails

Running Rails here — the database, the secret key, assets in the build, db:prepare at deploy, uploads and SQLite kept.

Rails runs on the Ruby image, served by Puma, with no nginx in front: the platform's proxy terminates TLS and passes requests to it. What Rails needs from the platform — the database, a secret key base, the hostnames — is in the environment under the names Rails already reads, so a new application runs with nothing added to it.

The manifest

version: 1
type: rails

runtime:
  ruby: '4.0'

services:
  - postgres: '18'

build:
  steps:
    - name: Gems
      run: bundle install
    - name: Assets
      run: SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile
  cache:
    - vendor/bundle

deploy:
  steps:
    - bin/rails db:prepare
  on_failure: rollback

workers:
  - name: jobs
    command: bin/jobs

health:
  path: /up

type: rails is a Ruby project; ruby says the same. Puma serves the application from the release with no start written, reading config/puma.rb, and /up is the health check Rails 7.1 and later answer once the application has booted. On PostgreSQL, Rails 8's database.yml needs one change before Solid Queue's bin/jobs runs: see Solid Cache, Queue and Cable.

Assets are compiled in the build, which can write, and served by Rails from the release — fingerprinted, so the CDN keeps them. A build is given no environment, and SECRET_KEY_BASE_DUMMY lets Rails 7.1 and later start without the key.

JavaScript bundled with jsbundling-rails or cssbundling-rails is built with Node, which the Ruby image does not have. Name Node.js beside your services, and build in its image first, before Rails compiles what it wrote:

services:
  - postgres: '18'
  - node: '26'

build:
  steps:
    - name: Gems
      run: bundle install
    - name: JavaScript and CSS
      image: node
      run: npm ci && npm run build --if-present && npm run build:css --if-present
    - name: Assets
      run: SKIP_JS_BUILD=1 SKIP_CSS_BUILD=1 SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile
  cache:
    - vendor/bundle

Import maps and Propshaft, Rails's own defaults, and tailwindcss-rails need neither. See Node.js as a build tool.

The database

PostgreSQL is what the platform sets DATABASE_URL for, and Rails reads that over database.yml's production entry: nothing in the file has to change. MariaDB works the same way with the mysql2 gem, on Rails 7.2 and later, which read the URL's mysql:// as mysql2; with Trilogy, add ActiveRecord.protocol_adapters.mysql = "trilogy" to config/application.rb. The first deploy takes the database the file names, or keeps the environment's where services names none, and after it the database is fixed.

SQLite, Rails 8's default, runs on an environment of one machine. Keep its files under storage/, which is linked out of every release to a directory that outlives them all — a new application made without Kamal leaves the paths for you to fill in:

production:
  primary:
    <<: *default
    database: storage/production.sqlite3

Where several machines run the application, storage/ is shared over the network, and SQLite's files must not be: run PostgreSQL or MariaDB there. Run one of them for anything you could not lose, too — a database service is backed up as a database, and SQLite's files are copied as files.

Solid Cache, Queue and Cable

Rails 8 keeps each in a database of its own: cache, queue and cable in database.yml, files beside the primary's with SQLite. On PostgreSQL the environment's user may create databases and the backup takes every database on the server, so give each a name beside your own, from the parts the platform sets:

production:
  primary: &primary_production
    <<: *default
    host: <%= ENV["DB_HOST"] %>
    port: <%= ENV["DB_PORT"] %>
    username: <%= ENV["DB_USER"] %>
    password: <%= ENV["DB_PASSWORD"] %>
    database: <%= ENV["DB_NAME"] %>
  cache:
    <<: *primary_production
    database: <%= ENV["DB_NAME"] %>_cache
    migrations_paths: db/cache_migrate
  queue:
    <<: *primary_production
    database: <%= ENV["DB_NAME"] %>_queue
    migrations_paths: db/queue_migrate
  cable:
    <<: *primary_production
    database: <%= ENV["DB_NAME"] %>_cable
    migrations_paths: db/cable_migrate

db:prepare creates them and loads their schemas on the first deploy.

On MariaDB, in the one database

On MariaDB the environment's user has one database and may not create another, so the three live in it, as each gem's README describes for the application's own database:

  1. config/database.yml: production is the primary alone, and the platform's DATABASE_URL says where it is:

    production:
      primary:
        <<: *default
    
  2. config/cache.yml: take database: cache out of production.

  3. config/cable.yml: take connects_to:, with its database: and writing: cable, out of production.

  4. config/environments/production.rb: take out config.solid_queue.connects_to = { database: { writing: :queue } }.

  5. Their tables, by a migration of your own: bin/rails generate migration CreateSolidTables, then copy into its change what is inside the define … do … end of db/cache_schema.rb, db/queue_schema.rb and db/cable_schema.rb — the create_table and add_foreign_key lines — and delete the three files.

db:prepare migrates them into the one database on the first deploy, and the cache, bin/jobs and Action Cable all use it from then on. Or move the cache and Action Cable to the stack's Valkey, and keep only Solid Queue in the database.

Migrations

bin/rails db:prepare runs as a deploy step, once the release is live and before the health check asks. On an empty database it loads the schema and the seeds; on one with tables it migrates — one command for both, and it runs on the first release too. on_failure: rollback puts the previous release back if a migration fails.

The secret key base

SECRET_KEY_BASE is set for every Ruby environment, derived from the environment's entropy and never changed, and Rails reads it over the one in config/credentials.yml.enc. A new application needs nothing more; set RAILS_MASTER_KEY as a secret variable only when your credentials hold something else the application reads.

Moving a site whose sessions and signed links you want to keep? Set its key as a secret variable named SECRET_KEY_BASE, which replaces the platform's.

Hosts and HTTPS

The proxy terminates TLS and sets X-Forwarded-Proto, so config.assume_ssl and config.force_ssl, on in a new application's production.rb, work as they are. To hold Rails to the environment's own hostnames:

config.hosts = ENV.fetch("VALLIC_HOSTNAMES", "").split(",")

The health check is sent with the primary hostname, so it passes.

Uploads

Active Storage's local service keeps files under storage/, linked to a directory that outlives every release and is in every backup, and Rails serves them through its own controllers. For a site whose traffic is its uploads, attach a bucket to the environment and use Active Storage's S3 service, with the aws-sdk-s3 gem, from the S3_FILES_… variables — see Storage:

# config/storage.yml
bucket:
  service: S3
  endpoint: <%= ENV["S3_FILES_ENDPOINT"] %>
  region: <%= ENV["S3_FILES_REGION"] %>
  bucket: <%= ENV["S3_FILES_BUCKET"] %>
  access_key_id: <%= ENV["S3_FILES_ACCESS_KEY"] %>
  secret_access_key: <%= ENV["S3_FILES_SECRET_KEY"] %>
  force_path_style: <%= ENV["S3_FILES_PATH_STYLE"] == "1" %>

log/ and tmp/ are linked out of the release the same way, so the file cache store — the cache where config.cache_store names none — and anything a gem writes there keep working in a release that is read-only. What is in tmp/ is left out of backups, as Rails means it to be temporary; to leave out more, such as exports under storage/, see Files left out.

Workers, jobs and cron

Solid Queue, Rails 8's default, runs as a worker — bin/jobs, in the application's own image and release, supervised, with no port — and its recurring tasks in config/recurring.yml run inside it. On one machine it can run inside Puma instead: set SOLID_QUEUE_IN_PUMA to 1 as a variable and declare no worker.

Sidekiq is a worker too, bundle exec sidekiq, on the stack's Valkey. Give it a database of its own, apart from any cache: the platform clears the cache after a restore, and clearing a Redis cache empties its whole database, queue and all.

# config/initializers/sidekiq.rb
redis = { url: "redis://#{ENV.fetch("REDIS_HOST", "localhost")}:6379/1" }
Sidekiq.configure_server { |config| config.redis = redis }
Sidekiq.configure_client { |config| config.redis = redis }

Rails has no scheduler of its own, and nothing is scheduled by default. Declare what you need:

cron:
  - name: unattached-uploads
    schedule: '0 4 * * *'
    command: "bin/rails runner 'ActiveStorage::Blob.unattached.where(created_at: ..2.days.ago).find_each(&:purge)'"

After a restore, the platform runs Rails.cache.clear.

The CDN

Rails names its session cookie after the application — _shop_session for one called Shop — so the platform cannot know it: name it on the CDN tab, and pages are kept from the CDN for a visitor carrying it. Rails marks its pages private unless an action says otherwise, with expires_in or http_cache_forever, and serves the fingerprinted assets to be cached for a year.