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, set RAILS_MASTER_KEY, and deploy. ox detects Puma, the gems, the asset build and db:prepare. It installs a precompiled Ruby and runs Puma as a systemd service behind Caddy, with no Docker, Kamal or nginx to configure. Each of Rails 8's databases gets 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. ox reads Gemfile.lock, config/ and the Ruby version file, and proposes:

  • Ruby, from .ruby-version, the ruby line in the Gemfile, or RUBY VERSION in Gemfile.lock. ox installs a precompiled build, so nothing compiles Ruby on your server.
  • The install step bundle config set --local deployment true && bundle config set --local without development:test && bundle install. Gems go into the release's own vendor/bundle, so two releases never share or break each other's gems.
  • The build command RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile, when the app uses Propshaft, Sprockets, jsbundling, cssbundling or Vite Ruby.
  • The migrate step RAILS_ENV=production bundle exec rails db:prepare. It runs pending migrations and loads the schema of a database that is still empty. The Solid Cache, Queue and Cable databases need that on the first deploy.
  • The start command RAILS_ENV=production bundle exec rails server -b 127.0.0.1 -p $PORT -P /tmp/server.pid, 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.
  • The health check /up, when config/routes.rb routes /up to rails/health#show, as Rails 7.1 and later generate.
  • The folders tmp/cache, tmp/pids and tmp/sockets in every release, whatever [build] says. Puma writes its pid file into tmp/pids without making it, and a repo that ignores tmp ships none.
  • Services: PostgreSQL when pg is in the lock, plus one more PostgreSQL database for each extra production database in config/database.yml (cache, queue, cable). Redis when redis or sidekiq is in the lock.
  • 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.

Apps made before Rails 7.1 read RAILS_LOG_TO_STDOUT or RAILS_SERVE_STATIC_FILES in a Ruby file under config/, such as application.rb, environments/production.rb, an initializer or puma.rb. For such an app, the start sets that variable to true unless you set it yourself. The log then 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.

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, ox runs only the services it lists, so this file declares all four databases. 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 127.0.0.1, behind Caddy. [workers] jobs runs Solid Queue as its own service. Each deploy starts a new release from the commit, without what the app wrote in the last one. [storage] keep links storage to a folder that survives deploys. 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. These URLs 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

ox check prints these messages for a Rails repo it cannot deploy yet. Each one says what to do.

  • 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. Run the command it names and commit the lock.
  • 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. You see this only when ox cannot read config/database.yml for certain, such as with ERB, a url, or a database elsewhere. When the file names every production database as a plain storage/<name>.sqlite3, as rails new writes it, ox keeps storage on its own, and ox check shows storage.keep from detection.
  • 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, because Puma then runs the jobs itself. Sidekiq and Resque get the same hint with their own command, or resque-pool when the lock has it.

Next steps

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