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:
-
config/database.yml: production is the primary alone, and the platform'sDATABASE_URLsays where it is:production: primary: <<: *default -
config/cache.yml: takedatabase: cacheout ofproduction. -
config/cable.yml: takeconnects_to:, with itsdatabase:andwriting: cable, out ofproduction. -
config/environments/production.rb: take outconfig.solid_queue.connects_to = { database: { writing: :queue } }. -
Their tables, by a migration of your own:
bin/rails generate migration CreateSolidTables, then copy into itschangewhat is inside thedefine … do … endofdb/cache_schema.rb,db/queue_schema.rbanddb/cable_schema.rb— thecreate_tableandadd_foreign_keylines — 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.