Guides · Deployment

Ruby on Rails hosting: deploy Rails 8 with PostgreSQL

What a Rails 8 app needs in production, the database.yml that works with a single PostgreSQL server, and the Dockerfile Staxa builds.

By Jonathan Pitter ·

The short answer

A Rails 8 app made with rails new -d postgresql needs four things from its host:

  1. SECRET_KEY_BASE (or RAILS_MASTER_KEY, if you use encrypted credentials) in the environment.
  2. A database.yml that points at the host's database, for the primary database and for the Solid Cache, Solid Queue and Solid Cable databases Rails 8 adds.
  3. bin/rails db:prepare before the new version serves traffic.
  4. A readiness check on /up, the health route Rails generates.

We tested everything below on Rails 8.1 and Ruby 3.4 with a single PostgreSQL 16 server: the build, db:prepare creating all four databases, a scaffold page reading and writing, the cache, and Solid Queue's workers running inside Puma.

Secrets

Production Rails refuses to boot without a secret key base. Generate one and set it in your host's environment:

bin/rails secret

Set it as SECRET_KEY_BASE. If your app keeps other secrets in config/credentials.yml.enc, set RAILS_MASTER_KEY to the contents of config/master.key instead, and Rails reads the key base from the credentials. Never commit master.key; rails new adds it to .gitignore.

database.yml for one PostgreSQL server

Rails 8 production uses four databases: your app's, plus one each for Solid Cache, Solid Queue and Solid Cable. The generated database.yml assumes they live on a server reachable through a local socket, with a password in APP_DATABASE_PASSWORD. Point all four at the server in the environment instead:

production:
  primary: &primary_production
    <<: *default
    host: <%= ENV["DB_HOST"] %>
    port: <%= ENV["DB_PORT"] %>
    database: <%= ENV["DB_NAME"] %>
    username: <%= ENV["DB_USER"] %>
    password: <%= ENV["DB_PASSWORD"] %>
  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 the three extra databases on first run, which needs a database user allowed to create databases. On Staxa it is: each tenant's PostgreSQL user is the superuser of the tenant's own server. Rails also reads DATABASE_URL for the primary database when it's set, which Staxa does, with the same values.

If you'd rather keep everything in one database, Solid Queue and Solid Cache can share the primary; their READMEs describe the single-database setup, which loads their schemas through ordinary migrations.

Migrations: db:prepare

bin/rails db:prepare creates any missing database, loads its schema, and runs pending migrations on the rest. Run it once per deploy, before the new version takes traffic. The Dockerfile rails new generates does this in bin/docker-entrypoint whenever the container starts the server, which is the simplest option with one instance.

Background jobs

Solid Queue runs jobs from a separate supervisor process. With one small instance, run it inside Puma by setting:

SOLID_QUEUE_IN_PUMA=1

The generated config/puma.rb loads Solid Queue's plugin when that's set, which starts a supervisor, a dispatcher, a worker and the scheduler alongside the web server. We checked all four register on startup. When jobs outgrow that, run bin/jobs as its own service.

SSL and hosts

Rails 8 production sets config.assume_ssl = true and config.force_ssl = true. Behind a proxy that terminates TLS, which is how almost every host works, keep both: assume_ssl makes Rails treat the proxy's plain-HTTP requests as the HTTPS requests they were, and force_ssl sets secure cookies and HSTS. It also means the platform's readiness check, which is plain HTTP, isn't redirected.

config.hosts is commented out in production, so Rails answers any host. If you turn it on, keep the line below it that excludes /up. Staxa's readiness check sends your service's address as the Host, so it passes when that address is in config.hosts, but on platforms whose checks arrive with the pod's IP address, the exclusion is what lets them through.

Deploying Rails on Staxa

If your repository has a Dockerfile, Staxa builds it. rails new generates one, so most Rails 8 apps have one already, with bin/docker-entrypoint running db:prepare. Our Rails and PostgreSQL demo deploys that way.

If there's no Dockerfile (an app made with --skip-docker, or an older one), Staxa detects Rails from bin/rails or the rails gem and generates this one (port 3000, Ruby 3.4):

FROM ruby:3.4-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev libyaml-dev nodejs npm git && rm -rf /var/lib/apt/lists/*
# As env, not bundle config: the ruby image keeps bundle config in
# /usr/local/bundle, which the runtime stage copies along with the gems.
ENV BUNDLE_DEPLOYMENT=1
ENV BUNDLE_PATH=/usr/local/bundle
ENV BUNDLE_WITHOUT=development:test
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
ENV RAILS_ENV=production
ENV SECRET_KEY_BASE=placeholder_for_build
# Only apps with an asset pipeline have assets:precompile (an API-only app has
# none). Where it exists, a failure fails the build.
RUN if grep -qE '^ *gem .(propshaft|sprockets)' Gemfile; then bundle exec rails assets:precompile; fi

FROM ruby:3.4-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 libyaml-0-2 nodejs && rm -rf /var/lib/apt/lists/*
ENV BUNDLE_DEPLOYMENT=1
ENV BUNDLE_PATH=/usr/local/bundle
ENV BUNDLE_WITHOUT=development:test
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /app /app
# Rails writes pids and the bootsnap cache under tmp, and SQLite and Active
# Storage files under db and storage.
RUN useradd --system --create-home app && mkdir -p tmp log storage db && chown -R app tmp log storage db
USER app
ENV RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=true
ENV PORT=3000
EXPOSE 3000
CMD ["sh", "-c", "exec bundle exec rails server -b 0.0.0.0 -p $PORT"]

What this means for your app:

  • It starts Puma and nothing else. It doesn't run db:prepare; add a Dockerfile (the one rails new generates is a good start) when you need migrations on deploy.
  • Assets are precompiled during the build with a placeholder key base, and a failure fails the build rather than shipping an app without its CSS.
  • Commit Gemfile.lock, with the Linux platform in it. If you develop on a Mac, run bundle lock --add-platform aarch64-linux x86_64-linux once.
  • Set SECRET_KEY_BASE (or RAILS_MASTER_KEY) in the service's environment. PORT, DATABASE_URL and the DB_* variables are set for you.
  • Set the health check path to /up. Staxa checks readiness with GET / unless you choose a path, and a production app with no root route returns 404 there.
  • No libvips. Active Storage image variants need it; use your own Dockerfile if you process images.

Common problems

ActiveRecord::ConnectionNotEstablished, connection to server on socket

A database in database.yml has no host, so Rails looks for a local socket. Usually it's the cache, queue or cable database; add host to the shared primary_production block as above.

Missing secret_key_base for 'production' environment

Set SECRET_KEY_BASE or RAILS_MASTER_KEY.

Your bundle only supports platforms ["arm64-darwin"]

Gemfile.lock has only your laptop's platform. Add the Linux ones with bundle lock --add-platform, commit, and rebuild.

The page is unstyled

Assets weren't precompiled, or the app is serving them from a path the build didn't write to. Check the build log for assets:precompile, and that config.public_file_server.enabled isn't turned off.

Frequently asked questions

Do I need Kamal or Thruster?

No. Kamal deploys Rails to your own servers; on a platform, the platform does that job. Thruster is an HTTP proxy for compression and caching in front of Puma; the Dockerfile rails new generates uses it, and the app runs fine without it.

SQLite or PostgreSQL?

SQLite in production needs a persistent disk and one instance. If your host gives each deploy a fresh container, as most do, use PostgreSQL.

Where do Active Storage uploads go?

To a cloud service (S3, GCS or a compatible store) configured in config/storage.yml. The container's disk is replaced on every deploy.

Give every customer their own stack

Staxa provisions an isolated app, database, subdomain and TLS per customer from one API call. The Free plan includes 2 tenants.