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.
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, therubyline in theGemfile, orRUBY VERSIONinGemfile.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 ownvendor/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 hasbin/rails. Rails loads the app before Puma readsconfig/puma.rb, so apuma.rbthat calls the app works. Withoutbin/rails, ox starts Puma directly. - The health check
/up, whenconfig/routes.rbroutes/uptorails/health#show, as Rails 7.1 and later generate. - The folders
tmp/cache,tmp/pidsandtmp/socketsin every release, whatever[build]says. Puma writes its pid file intotmp/pidswithout making it, and a repo that ignorestmpships none. - Services: PostgreSQL when
pgis in the lock, plus one more PostgreSQL database for each extra production database inconfig/database.yml(cache,queue,cable). Redis whenredisorsidekiqis in the lock. - 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.
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.
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.
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 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. 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 readconfig/database.ymlfor certain, such as with ERB, aurl, or a database elsewhere. When the file names every production database as a plainstorage/<name>.sqlite3, asrails newwrites it, ox keepsstorageon its own, andox checkshowsstorage.keepfrom 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 whenconfig/puma.rbloadsplugin :solid_queuein every environment, because Puma then runs the jobs itself. Sidekiq and Resque get the same hint with their own command, orresque-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 runs your apps gives the overview, and the blog's deploy guides walk through a setup by hand.