# 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. 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: ```toml 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 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: ```text 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 --wait`. The first deploy installs Ruby and every gem, so it takes a few minutes. If the health check fails, [Troubleshooting](https://console.t78.sorv.dev/docs/troubleshooting#health-check-failed) 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/.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 - [Set RAILS_MASTER_KEY and other variables](https://console.t78.sorv.dev/docs/operate/variables#set) from the dashboard or the CLI. - [Add your own domain with HTTPS](https://console.t78.sorv.dev/docs/operate/domains): one A record, and Caddy gets the certificate. - [Read and search the app's logs](https://console.t78.sorv.dev/docs/operate/logs), live or for a time range. - [Roll back a bad deploy](https://console.t78.sorv.dev/docs/operate/rollback) to a kept release without a rebuild. - [See the daily PostgreSQL backups](https://console.t78.sorv.dev/docs/operate/backups) and restore one. Last updated 2026-10-08. The page as HTML: https://console.t78.sorv.dev/docs/guides/rails