← All posts

Deploying a Ruby on Rails App from git: What to Check First

October 27, 2026

TL;DR: Most failed first Rails deploys come down to a handful of things: a Gemfile.lock without a Linux platform, a missing RAILS_ENV or SECRET_KEY_BASE, Puma not listening on PORT, or migrations nobody ran. Check those before your first push and the rest is usually routine.

This guide assumes a Rails 7 or 8 app with Postgres and Puma, deployed from a git repository to a container-based host. Where Candor Deploy behaves in a specific way, we link the doc that says so.

Does your Gemfile.lock include a Linux platform?

If you develop on a Mac, your lockfile may only list arm64-darwin or x86_64-darwin. Gems with native extensions, like nokogiri or pg, then fail to resolve on a Linux build machine, and the error buried in the build log looks unrelated to the real cause.

Fix it once, locally:

bundle lock --add-platform x86_64-linux
git add Gemfile.lock && git commit -m "Add Linux platform to lockfile"

Commit both Gemfile and Gemfile.lock. On Candor Deploy, a Gemfile at the root of the directory being built marks the app as Ruby, and the build runs on Nixpacks without any configuration (builds and configuration docs). If the Rails app lives in a subdirectory of a monorepo, set that directory as the app’s root instead of moving files.

Is RAILS_ENV set to production?

Set RAILS_ENV=production in the app’s environment, in the portal’s Environment tab or in a .candorfile. Do this whichever secret option below you pick, and don’t count on the build setting it for you. Without it, Rails runs in development: requests to your app’s URL are rejected as a blocked host, and anything that does get through shows detailed error pages.

Where does the production secret come from?

Rails refuses to boot in production without a secret key base. You have two options:

  • Set RAILS_MASTER_KEY to the contents of config/master.key (or the per-environment key) so Rails can decrypt your committed credentials file.
  • Set SECRET_KEY_BASE directly, and keep other secrets as plain environment variables.

Never commit master.key. Rails puts it in .gitignore for you, so check that nobody removed the line.

If you don’t use encrypted credentials at all, the second option is the simplest. In a .candorfile on Candor Deploy you can ask the platform to generate a random secret, so the value never sits in your repo or your clipboard (.candorfile docs):

services:
  web:
    env:
      RAILS_ENV: production
      SECRET_KEY_BASE: { generate: hex32 }

Keep in mind that changing SECRET_KEY_BASE later signs everyone out and invalidates signed cookies, so set it once and leave it.

Is Puma listening on the right port and address?

This is the most common first-deploy failure in every language, not just Ruby. The default config/puma.rb already reads the port from the environment:

port ENV.fetch("PORT", 3000)

Puma’s port directive binds to all interfaces, which is what you want inside a container. Problems start when someone replaces it with bind "tcp://127.0.0.1:3000" or starts the app with a hard-coded -p flag. Then the server runs happily but nothing outside the container can reach it.

Candor Deploy always sets PORT (8080 by default) and health-checks the app by connecting to it (builds and configuration docs). An app that listens anywhere else builds fine but never goes live. Whether the old version keeps serving while that happens depends on your plan and setup; see when migrations run below. We covered the general version of this in the $PORT and 0.0.0.0 fix.

How should database.yml find the database?

Rails merges DATABASE_URL into the settings for the current environment automatically. On Rails 7, a production block this small is enough:

production:
  <<: *default
  url: <%= ENV["DATABASE_URL"] %>

Leave out hard-coded hosts, usernames and passwords. Also check the pool setting: it should be at least the number of Puma threads, or requests will wait for a connection under load. The generated default ties it to RAILS_MAX_THREADS, which is the right idea. If you also run Solid Queue, its worker threads, dispatcher and supervisor need connections too, and by default they run as separate processes, each with its own pool. Size pool so each process has enough for its own threads: Puma’s threads in the web process, and the threads setting in config/queue.yml plus a couple to spare in each Solid Queue process.

On Candor Deploy, attaching a Postgres database to an app writes its connection string into the app’s environment as DATABASE_URL. The database is never exposed to the internet, so only your own services can reach it, and like any environment change the new value reaches the app on its next deploy (databases docs).

Rails 8 apps generated with the Solid gems have a nested production block instead, with primary, cache, queue and cable entries, and production.rb, config/cache.yml and config/cable.yml refer to those entries by name. Don’t replace it with the flat block above, or boot fails on a missing database configuration. Set url: <%= ENV["DATABASE_URL"] %> on primary, then choose one of two setups for the rest:

  • Separate databases. Give cache, queue and cable their own database and URL each. With primary, that’s four databases, so you need a plan that allows four; Solo allows one.
  • One database for everything. Remove the extra entries and the settings that point at them (config.solid_queue.connects_to in production.rb, database: cache in config/cache.yml, connects_to in config/cable.yml), then copy the contents of db/queue_schema.rb, db/cache_schema.rb and db/cable_schema.rb into normal migrations. Just pointing every entry at DATABASE_URL isn’t enough: those tables are defined only in the separate schema files, so they may never be created in your one database.

Either way, don’t leave any of them pointing at SQLite files that vanish when the container is replaced.

Are assets precompiled and actually served?

Run bin/rails assets:precompile locally with RAILS_ENV=production before your first deploy. If it fails on your machine, it’ll fail in the build too, and the local error is easier to read.

A local success doesn’t prove the build will pass, though. On your machine config/master.key exists, but it’s gitignored, so it isn’t there when the build runs, and RAILS_MASTER_KEY or SECRET_KEY_BASE may not be either. An initializer that reads Rails.application.credentials (Devise, Stripe keys and the like) then fails the build with a missing secret_key_base or a decryption error. Two habits avoid this:

  • Precompile with SECRET_KEY_BASE_DUMMY=1 (Rails 7.1 and newer), which gives Rails a throwaway secret for the build. It belongs to the build step only (the Rails 8 Dockerfile sets it on its precompile line). Never set it in the app’s Environment tab or .candorfile env: the running app gets those too, and with the dummy variable set Rails ignores your real SECRET_KEY_BASE and signs everyone out on every restart. To test the build case locally, move config/master.key aside and run SECRET_KEY_BASE_DUMMY=1 RAILS_ENV=production bin/rails assets:precompile.
  • Don’t read credentials when an initializer loads. Read them lazily, when a request or job first needs them.

Then look at config/environments/production.rb. Newer apps serve files from public/ by default. Older ones gate it on an environment variable:

config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?

If that’s what your file says and nothing sits in front of Rails to serve static files, set RAILS_SERVE_STATIC_FILES=1, or your pages will load without CSS or JavaScript.

When do migrations run?

Somebody has to run them, and on many hosts that somebody is you. Candor Deploy has no separate release-phase hook: schema migrations are the app’s job, run from its start command or Dockerfile entrypoint (databases docs). For Rails, put the steps in a small script:

#!/bin/sh
# scripts/start.sh
set -e

bin/rails db:prepare

exec bundle exec puma -C config/puma.rb

Then point the start command at it:

services:
  web:
    start: sh scripts/start.sh

db:prepare creates the database if it doesn’t exist and runs pending migrations otherwise. On Candor Deploy the attached database already exists but is empty on the first deploy. On Rails 7.1 and newer, db:prepare loads the schema into an empty database and then runs db/seeds.rb, so check what’s in it before your first push: dev fixtures or demo users with known passwords don’t belong in production. On Rails 7.0 it runs every migration from scratch instead, so make sure your old migrations still run cleanly. set -e stops at the first failure, so a broken migration never leads to a half-started app, and exec hands the process to Puma so it receives shutdown signals directly. Keep migrations quick, since Puma only starts listening once they finish.

Whether the old version keeps serving during a deploy depends on two things. If the app has a volume attached, the old version always stops before the new one starts. Without a volume, the new version starts while the old one is still serving, as long as your memory pool has room for a second copy of the app. On Solo with a 512 MB app and a 512 MB database the pool is full, so the old version stops first. Whenever the old version stops first, the site is down while db:prepare, any migrations and the Rails boot run, and a failed deploy means downtime until you roll back (v1.2.48 changelog). Either way, a rollback runs old code against the new schema, so write migrations that the old code can still run against: add first, remove later. Our guide to running migrations on deploy safely walks through the expand-then-contract pattern.

Where do the logs go?

To stdout. Rails 7.1 and newer log to standard output in production by default. Older apps often check RAILS_LOG_TO_STDOUT, so set it if your production.rb mentions it. Logs written to log/production.log inside a container are hard to read and disappear on the next deploy.

On Candor Deploy, build output is on the Deployments tab and everything your app prints after it starts is on the Logs tab (troubleshooting docs). A boot error about a missing secret or a failed database connection shows up there as one clear line.

What about background jobs?

Sidekiq usually runs as its own process (Sidekiq 7 and newer also have an embedded mode that runs inside another process). Solid Queue and GoodJob can run either separately or inside the web server: Rails 8’s generated config/puma.rb includes plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"], and GoodJob has an async mode that runs jobs in the web process. What you shouldn’t do is start a separate worker in the same container with &: if it crashes, nothing notices, and a deploy can cut off jobs mid-run.

Candor Deploy runs workers (kind: worker: a background process with no port, no URL and no port health check), but the portal can’t create one yet (how Candor Deploy works). Setting kind: worker in the .candorfile of an existing app doesn’t turn it into a worker either: a change of kind isn’t applied on deploy (v1.2.48 changelog). Until the portal can create workers, we recommend running Solid Queue inside Puma by setting SOLID_QUEUE_IN_PUMA=1.

Mind the plan limits, too. Solo allows one database and a 1 GB memory pool, and each service takes 512 MB by default, so a web app, a worker, Postgres and Redis won’t fit. On Solo, keep the queue in Postgres with Solid Queue in Puma. On larger plans, attaching a Redis database writes its address as REDIS_URL (databases docs).

Should you use the Rails-generated Dockerfile?

New Rails apps ship with a production Dockerfile, and it’s a good one. But you don’t need it to deploy, and many teams keep a Dockerfile around that was really meant for local work.

On Candor Deploy, a Dockerfile in your repo is ignored unless you opt in with builder: dockerfile (or a dockerfile: path) in your .candorfile (builds and configuration docs). Opt in when you need system packages the auto-detected build doesn’t include, or when you want production to match your CI image exactly.

If you opt in with the Rails 8 Dockerfile, check the port first. It starts the app with ./bin/thrust ./bin/rails server, and Thruster listens on HTTP_PORT (80 by default) while it runs Puma on port 3000 behind it, so nothing listens on the PORT Candor Deploy health-checks. Set HTTP_PORT=8080 in the app’s environment so Thruster listens on the default PORT.

The checklist

Before your first push, confirm each of these:

  • Gemfile.lock lists x86_64-linux and is committed.
  • RAILS_ENV=production is set in the app’s environment or .candorfile.
  • SECRET_KEY_BASE or RAILS_MASTER_KEY is set, and master.key is not in git.
  • Puma uses port ENV.fetch("PORT", ...), with no 127.0.0.1 bind.
  • database.yml reads DATABASE_URL (on Rails 8, keeping the Solid entries consistent), and pool covers the threads of each process that uses it (Puma, and each Solid Queue process).
  • assets:precompile succeeds locally in production mode without master.key (use SECRET_KEY_BASE_DUMMY=1), and static files are served.
  • Migrations run from the start command and follow expand, then contract, and db/seeds.rb is safe to run in production.
  • If you build from the Rails 8 Dockerfile, the port Thruster listens on matches the app’s port.
  • Logs go to stdout.
  • Background jobs have a home: for now, Solid Queue in Puma.

Ready to ship it?

If your app passes the list above, the Candor Deploy quickstart walks you from connecting the repo to a live URL.