Deploy Ruby on Rails to a VPS with Puma and Postgres

Deploy Rails 8 to your own VPS without Docker or Kamal: precompiled Ruby, gems, assets, db:prepare and Solid Cache, Queue and Cable on PostgreSQL.

Last updated 2026-10-08

On this page

To deploy Ruby on Rails to a VPS with ox, add the repo as a project, let ox detect Puma, the gems, the asset build and db:prepare, set RAILS_MASTER_KEY, and deploy. ox installs a precompiled Ruby, runs Puma as a systemd service behind Caddy, with no Docker, no Kamal and no nginx to configure, and gives each of Rails 8's databases its own PostgreSQL database. Migrations run after a snapshot of every database, and traffic moves to the new release only after /up answers.

ox reads the ox.toml on this page, makes the plan on the card, and starts bundle exec rails server -b 127.0.0.1 -p $PORT -P /tmp/server.pid on your server. The cylinders are 2 of the 4 services the file asks for: postgres and cable.

What ox detects in a Rails repo

An app made with rails new --database=postgresql deploys with no ox.toml at all. From Gemfile.lock, config/ and the Ruby version file, ox proposes:

  • Ruby from .ruby-version, the ruby line in the Gemfile, or RUBY VERSION in Gemfile.lock, installed as a precompiled build, so nothing compiles Ruby on your server.
  • bundle config set --local deployment true && bundle config set --local without development:test && bundle install as the install step. Gems go into the release's own vendor/bundle, so two releases never share or break each other's gems.
  • RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile as a build command, when the app uses Propshaft, Sprockets, jsbundling, cssbundling or Vite Ruby.
  • RAILS_ENV=production bundle exec rails db:prepare as the migrate step. It runs pending migrations and loads the schema of a database that is still empty, which the Solid Cache, Queue and Cable databases need on the first deploy.
  • RAILS_ENV=production bundle exec rails server -b 127.0.0.1 -p $PORT -P /tmp/server.pid as the start command when the app has bin/rails (Rails loads the app before Puma reads config/puma.rb, so a puma.rb that calls the app works; without bin/rails ox starts Puma directly). When a Ruby file under config/ (application.rb, environments/production.rb, an initializer, puma.rb) reads RAILS_LOG_TO_STDOUT or RAILS_SERVE_STATIC_FILES, as apps made before Rails 7.1 do, the start sets that variable to true unless you set it yourself, so the log reaches ox's log view and Puma serves the files in public/; a value you set, such as an empty one to turn a switch off, reaches the app unchanged. ox also proposes the folders tmp/cache, tmp/pids and tmp/sockets, which each release gets whatever [build] says (Puma writes its pid file into tmp/pids without making it, and a repo that ignores tmp ships none), and /up as the health check when config/routes.rb routes /up to rails/health#show, as Rails 7.1 and later generate.
  • PostgreSQL when pg is in the lock, plus one more PostgreSQL database for each extra production database in config/database.yml (cache, queue, cable), and Redis when redis or sidekiq is.
  • The apt packages that native gems build against, such as libvips-dev for ruby-vips or libpq-dev for a pg without a Linux build in the lock. The C compilers (gcc, make) always come with Ruby.
  • RAILS_MASTER_KEY as a required variable when config/credentials.yml.enc is in the repo.

The ox.toml for Rails

Write one when you want a domain, a Solid Queue worker or uploads that survive deploys. Once a repo has an ox.toml, its services are exactly the ones it lists, so the four databases are declared here. ox names each database URL after its entry, so cache gives CACHE_DATABASE_URL, which Rails reads for its cache database.

ox.toml for a Rails 8 app
domains = ["example.com"]

[app]
start  = "RAILS_ENV=production bundle exec rails server -b 127.0.0.1 -p $PORT -P /tmp/server.pid"
health = "/up"

[build]
install  = "bundle config set --local deployment true && bundle config set --local without development:test && bundle install"
commands = ["RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile"]
migrate  = "RAILS_ENV=production bundle exec rails db:prepare"

[workers]
jobs = "RAILS_ENV=production bin/jobs"

[services]
postgres = {}
cache    = { type = "postgres" }
queue    = { type = "postgres" }
cable    = { type = "postgres" }

[storage]
keep = ["storage"]

ox sets no framework variables of its own, so each command carries RAILS_ENV=production. The -b flag makes Puma listen only on the port ox gives it, behind Caddy. [workers] jobs runs Solid Queue as its own service, and [storage] keep links storage to a folder that survives deploys, because each deploy starts a new release from the commit, without what the app wrote in the last one. Ruby and the packages still come from detection, and ox check shows them.

Rails credentials and variables for production

ox provides DATABASE_URL, one *_DATABASE_URL per extra database, PUBLIC_URL and PUBLIC_HOST. Rails reads DATABASE_URL for the primary database and CACHE_DATABASE_URL, QUEUE_DATABASE_URL and CABLE_DATABASE_URL for the others, and they win over the names and passwords in config/database.yml, so the file rails new wrote works unchanged.

Set RAILS_MASTER_KEY to the contents of config/master.key on the dashboard or with ox vars set <project> RAILS_MASTER_KEY, which asks for the value. Never commit master.key. Until the key is set, a deploy stops before it builds and names the missing key.

Check and deploy the Rails app

Run ox check in the repo. For a rails new app with the ox.toml above it prints the plan, then the variables, then Ready to deploy.

ox check, last lines
  Provided by ox: PORT, HOST, OX_ENV, OX_PROJECT, OX_RELEASE, OX_DATA_DIR, PUBLIC_URL, PUBLIC_HOST, PUBLIC_SCHEME, PUBLIC_PORT, CABLE_DATABASE_URL, CACHE_DATABASE_URL, DATABASE_URL, QUEUE_DATABASE_URL
  Set on the dashboard before the first deploy: RAILS_MASTER_KEY

Ready to deploy.

Then deploy with ox deploy <project> --wait. The first deploy installs Ruby and every gem, so it takes a few minutes. If the health check fails, Troubleshooting explains the message.

If ox check says no

These are the messages ox check prints for a Rails repo it cannot deploy yet, and what to do about each.

  • Gemfile.lock is missing: run bundle install and commit it (ox installs gems in deployment mode, which needs the lock). Commit the lock.
  • a Ruby app needs a pinned Ruby, and ox never guesses one: add .ruby-version (or [tools] ruby = "3.4"). Commit a .ruby-version; rails new writes one.
  • Gemfile.lock has no Linux platform, so bundle install will refuse it on the server: run bundle lock --add-platform x86_64-linux and commit the lock. This happens when the lock was made on a Mac.
  • Rails with SQLite keeps its database in storage/, and the next release starts from the commit without it: add an ox.toml with [storage] keep = ["storage"]. With that line the database lives in a folder that survives deploys. When config/database.yml names every production database as rails new writes it, a plain storage/<name>.sqlite3, ox keeps storage on its own and ox check shows storage.keep from detection, so this shows only for a file it cannot read for certain (ERB, a url, a database elsewhere).
  • Solid Queue runs jobs only in a worker: add [workers] jobs = "RAILS_ENV=production bin/jobs". This is a hint: jobs wait in the queue until a worker runs. It does not show when config/puma.rb loads plugin :solid_queue in every environment, since Puma then runs the jobs itself. Sidekiq and Resque get the same hint with their own command (resque-pool when the lock has it).

Next steps

New to running your own server? How ox self-hosts your apps gives the overview, and the blog's deploy guides walk through a setup by hand.