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.
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, therubyline in theGemfile, orRUBY VERSIONinGemfile.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 installas the install step. Gems go into the release's ownvendor/bundle, so two releases never share or break each other's gems.RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompileas a build command, when the app uses Propshaft, Sprockets, jsbundling, cssbundling or Vite Ruby.RAILS_ENV=production bundle exec rails db:prepareas 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.pidas the start command when the app hasbin/rails(Rails loads the app before Puma readsconfig/puma.rb, so apuma.rbthat calls the app works; withoutbin/railsox starts Puma directly). When a Ruby file underconfig/(application.rb,environments/production.rb, an initializer,puma.rb) readsRAILS_LOG_TO_STDOUTorRAILS_SERVE_STATIC_FILES, as apps made before Rails 7.1 do, the start sets that variable totrueunless you set it yourself, so the log reaches ox's log view and Puma serves the files inpublic/; a value you set, such as an empty one to turn a switch off, reaches the app unchanged. ox also proposes the folderstmp/cache,tmp/pidsandtmp/sockets, which each release gets whatever[build]says (Puma writes its pid file intotmp/pidswithout making it, and a repo that ignorestmpships none), and/upas the health check whenconfig/routes.rbroutes/uptorails/health#show, as Rails 7.1 and later generate.- PostgreSQL when
pgis in the lock, plus one more PostgreSQL database for each extra production database inconfig/database.yml(cache,queue,cable), and Redis whenredisorsidekiqis. - The apt packages that native gems build against, such as
libvips-devforruby-vipsorlibpq-devfor apgwithout a Linux build in the lock. The C compilers (gcc,make) always come with Ruby. RAILS_MASTER_KEYas a required variable whenconfig/credentials.yml.encis 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.
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.
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 newwrites 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. Whenconfig/database.ymlnames every production database asrails newwrites it, a plainstorage/<name>.sqlite3, ox keepsstorageon its own andox checkshowsstorage.keepfrom detection, so this shows only for a file it cannot read for certain (ERB, aurl, 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 whenconfig/puma.rbloadsplugin :solid_queuein every environment, since Puma then runs the jobs itself. Sidekiq and Resque get the same hint with their own command (resque-poolwhen the lock has it).
Next steps
- Set RAILS_MASTER_KEY and other variables from the dashboard or the CLI.
- Add your own domain with HTTPS: one A record, and Caddy gets the certificate.
- Read and search the app's logs, live or for a time range.
- Roll back a bad deploy to a kept release without a rebuild.
- See the daily PostgreSQL backups and restore one.
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.