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
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.
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 puma -C config/puma.rb -b tcp://127.0.0.1:$PORTas the start command, 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.build-essentialalways comes 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 puma -C config/puma.rb -b tcp://127.0.0.1:$PORT"
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 writable folder that survives deploys, because a release's own files are read-only while it runs. 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, 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/, which is read-only in a release: add an ox.toml with [storage] keep = ["storage"]. With that line the database lives in a folder that survives deploys.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.
Jekyll sites are not supported yet, and ox says so instead of guessing.
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.