all posts

How to deploy a Rails app without Docker

Ajay Kumar··9 min read

Rails 8 ships with a Dockerfile in the generator output, and for a lot of teams that Dockerfile is the only container they will ever write. Read it: it installs a Ruby version your .ruby-version file already declared, runs bundle install against the Gemfile.lock you already committed, precompiles assets, and ends with a bin/rails server command. It is a transcription of facts the repo already states. That is fine when you need it, and pure ceremony when you don't.

This is the deploy without one: a git repo, a build command, a start command, and the six decisions that actually decide whether a Rails app comes up healthy on a machine that isn't your laptop. Commands are PandaStack's; the decisions are identical on any platform with Ruby build detection.

1. Pin Ruby in the repo, not in a dashboard

Rails apps are unusually sensitive to the patch version of Ruby, mostly because native gem extensions get compiled against it. Put the version in a file so it resolves the same way locally, in CI, and in production, and so a bump shows up in a pull request instead of in someone's browser history.

The PandaStack `base` template is Ubuntu 24.04 with mise, which reads the idiomatic files directly. `.ruby-version` is what rbenv and rvm users already have. `.tool-versions` is the asdf/mise format and is better if you also need a Node version for the asset pipeline.

# Option A — the file you probably already have
echo "3.4.2" > .ruby-version

# Option B — .tool-versions, when the asset pipeline needs Node too
cat > .tool-versions <<'EOF'
ruby 3.4.2
nodejs 22.14.0
EOF

# Both are read by mise at build time. Node, Python, Go and Bun are
# pre-warmed in the base image; Ruby is installed on demand from this
# pin, which costs a minute on the first build and nothing after that.
Commit Gemfile.lock. It is not optional for a deployed app. Without it the platform resolves gem versions at build time, which means the dependency graph that shipped on Tuesday is not necessarily the one that ships on Wednesday, and the diff that broke you contains no code.

2. Install gems into the app, deterministically

The deployment-shaped bundle install does three things worth having: it refuses to run if Gemfile.lock is out of date with the Gemfile, it vendors gems into a path inside the app directory, and it skips the development and test groups so you aren't compiling rspec and pry into a production image.

bundle config set --local deployment true
bundle config set --local path vendor/bundle
bundle config set --local without development:test
bundle install --jobs 4 --retry 3

That `deployment true` flag is the useful one. If someone adds a gem to the Gemfile and forgets to commit the updated lockfile, the build fails with a clear message instead of quietly resolving something new. A build that fails loudly on a lockfile mismatch has saved me more time than any amount of caching.

One practical note: build and start commands run as non-login `sh -c`, so nothing sources your shell profile. Bundler config written by `bundle config set --local` lives in `.bundle/config` inside the app directory and survives into the start command, which is exactly why the local flag matters here.

3. Assets precompile — and it boots your app to do it

This is the trap. `rails assets:precompile` is not a static asset bundler that happens to live in Rails. It is a Rake task, and Rake tasks in Rails load the environment. Propshaft in Rails 8 does far less work than Sprockets did, but it still boots the app to build the asset map, and booting the app means initializers run, which means credentials get read.

So if your build environment has no `RAILS_MASTER_KEY` and no `SECRET_KEY_BASE`, precompile fails with an error about a missing secret — during an asset step, which reads like a JavaScript problem and is not one. People then spend an hour on esbuild.

RAILS_MASTER_KEY (or SECRET_KEY_BASE) must be present at BUILD time, not just at runtime. Any platform that injects secrets only into the running process will fail asset precompilation with a message that mentions credentials, not assets. Set the env var on the app so it is visible to both phases.

There is a dummy-value escape hatch — `SECRET_KEY_BASE_DUMMY=1` tells Rails 8 to generate a throwaway key just for the build — and it is the right call when your build genuinely does not need to decrypt credentials. It is the wrong call if an initializer reads a real credential to configure something at boot, because then you have swapped a build failure for a runtime one. My default is to give the build the real master key and stop thinking about it.

4. Bind Puma to 0.0.0.0 and the port you were given

Puma's default in development binds to localhost. On a platform, your app is one process inside a machine and the health check arrives from outside that process — usually across a network interface. A loopback bind means the app starts perfectly, logs "Listening on http://127.0.0.1:3000", passes no health check, and gets marked unhealthy with completely clean logs. It is the single most common reason a first Rails deploy fails.

# config/puma.rb
port ENV.fetch("PORT", 3000)
bind "tcp://0.0.0.0:#{ENV.fetch('PORT', 3000)}"

# Threads first, workers second. A Rails 8 app with a connection pool
# sized to match handles a lot on threads alone.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
threads threads_count, threads_count

# Each worker is a full copy of your app in memory. Derive the count from
# measured RSS, not from a CPU formula — the OOM killer does not read blog
# posts about 2×CPU+1.
workers ENV.fetch("WEB_CONCURRENCY", 2).to_i
preload_app!

environment ENV.fetch("RAILS_ENV", "production")

# Logs go to stdout. The platform captures the process output; writing to
# log/production.log on an ephemeral disk means the logs die with the VM.
stdout_redirect nil, nil, true if ENV["RAILS_LOG_TO_STDOUT"].nil?

On the logging point: set `RAILS_LOG_TO_STDOUT=1` and let the platform own log collection. Rails writes to `log/production.log` by default, which on a machine with an ephemeral filesystem is a file nobody will ever read, growing until it isn't. On PandaStack, app stdout and stderr land in `/var/log/pandastack-app.log` inside the VM and stream out of the runtime-logs endpoint, so a `Rails.logger.info` is visible from the dashboard within a second.

5. Migrations: db:prepare is the friendlier default

Migrations do not belong in the start command. If `db:migrate` runs on boot, every replica races every other replica on every restart, and a migration that fails converts a blocked deploy into a crash-looping production app. Run it once, as its own step, after the build and before traffic shifts.

Between the two candidates: `db:migrate` fails on a database that does not exist yet, which is annoying on a first deploy and on every new preview environment. `db:prepare` creates the database if it is missing, loads the schema if there are no migrations applied, and otherwise runs pending migrations. It is idempotent and it is what I reach for.

# Creates-if-missing, then migrates. Safe to run on every deploy.
bin/rails db:prepare

# What it does NOT do is make a destructive migration safe. Adding a NOT
# NULL column with a default still rewrites the table on old Postgres, and
# dropping a column the currently-running code still selects will 500 the
# old version during a blue-green overlap. Ship those in two deploys.

# Check what would run before it runs:
bin/rails db:migrate:status | grep down
Blue-green deploys mean old and new code are briefly alive at the same time. Every migration must therefore be compatible with the version currently in production, not just the version you are shipping. Add columns before you read them; stop writing a column in one deploy and drop it in the next.

6. Active Storage on a filesystem that forgets

Rails' default production storage service in a fresh app is often still `:local`, which writes uploads to `storage/` on disk. On any platform that replaces the machine on deploy — which is every platform worth using, including this one — those uploads are gone the next time you ship. Nothing errors. The records still exist, the blobs do not, and you find out when a customer asks where their invoice PDF went.

# config/storage.yml
amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
  region: us-east-1
  bucket: acme-uploads

# config/environments/production.rb
#   config.active_storage.service = :amazon
# Anything object-storage-backed is fine. :local in production is not,
# unless the storage path is a durable volume you explicitly attached.

The same reasoning applies to anything else you were tempted to keep on disk: cached generated files, SQLite databases used as a side store, uploaded CSVs waiting to be processed. If it must survive a deploy, it goes in Postgres or object storage.

7. Background jobs: same VM or its own?

Rails 8's Solid Queue changed the calculus here. It backs the queue with your database rather than Redis, which removes an entire piece of infrastructure from a small app's dependency list. And it can run inside the Puma process via the built-in plugin, which is genuinely the right answer for a lot of apps.

The trade-off is concrete, so decide it deliberately rather than by default:

  • Same process (Solid Queue's Puma plugin) — one app to deploy, one bill, jobs share the web process's memory. Correct for low job volume where a slow job doesn't need to be isolated from request latency. A memory-hungry job will get your web workers OOM-killed.
  • Same VM, separate process — start Puma and a worker together under one start command. Simpler than two apps, but a crashed worker and a crashed web server are now the same incident, and you can't scale them independently.
  • Separate app, same repo — deploy the repository twice with different start commands. The queue is shared via the database, the failure domains are not. This is what I run once job volume is more than incidental.
  • Sidekiq — still excellent, still needs Redis, still the best choice if you rely on its ecosystem or need its throughput. The deployment shape is identical: a second app from the same repo with a `bundle exec sidekiq` start command.

Whichever you choose: if you use recurring jobs, exactly one scheduler runs. Two schedulers means every nightly email goes out twice, and that is discovered by a customer, not by a monitor.

Putting it together on PandaStack

Connect the GitHub repo, PandaStack detects a Rails app from the Gemfile, and the build and start commands are the defaults you would have written into that Dockerfile anyway. The parts that matter are that the Ruby pin comes from your repo and the secrets are visible at build time.

pandastack apps create --name web \
  --git-url https://github.com/acme/storefront \
  --build-cmd 'bundle config set --local deployment true \
    && bundle config set --local without development:test \
    && bundle install --jobs 4 --retry 3 \
    && bin/rails assets:precompile' \
  --start-cmd 'bundle exec puma -C config/puma.rb' \
  --env RAILS_ENV=production \
  --env RAILS_LOG_TO_STDOUT=1 \
  --env RAILS_MASTER_KEY=... # present for BUILD and runtime

# Managed Postgres, attached by env var
pandastack db create --name storefront-db
pandastack apps env set web DATABASE_URL="$(pandastack db url storefront-db)"

# Migrations as their own step, before traffic moves
pandastack apps exec web -- bin/rails db:prepare

# Jobs as a second app from the same repo
pandastack apps create --name jobs \
  --git-url https://github.com/acme/storefront \
  --start-cmd 'bundle exec rake solid_queue:start'

A managed Postgres takes 30–90 seconds to create, because it is a real PostgreSQL 16 instance in its own Firecracker microVM with a durable volume rather than a schema in a shared cluster. After that, `DATABASE_URL` is just an environment variable and Rails neither knows nor cares.

The deploy itself is blue-green. A fresh microVM is provisioned, the repo is cloned at the exact commit, gems install, assets precompile, Puma starts, and only once the health check passes does traffic flip to the new VM and the old one get torn down. If the build fails or the app never becomes healthy, the old VM keeps serving and you get logs, which is the behaviour you want from a deploy at 6pm on a Friday you did not plan to be working.

Two properties fall out of the microVM substrate and are worth knowing. Snapshot-restore means a sandbox comes back in about 179ms at p50, so scale-to-zero is viable — an app with no traffic can idle at zero and still wake fast enough that the first request doesn't time out. And each VM is a real kernel boundary, not a shared-kernel container, which matters if you are running customer code or just prefer your blast radius small.

Before you send real traffic

  1. Confirm the app is reachable from outside its own process — curl the assigned URL, not localhost inside the VM. Loopback binds are invisible until they aren't.
  2. Load a page and check CSS renders. Missing styles means assets:precompile did not run or the manifest is stale.
  3. Upload a file through Active Storage, deploy again, then go find the file. Missing means your storage service is :local.
  4. Trigger an exception and confirm you get the generic 500 page, not a full backtrace. `config.consider_all_requests_local` must be false in production.
  5. Watch RSS per Puma worker under real load for ten minutes, then set WEB_CONCURRENCY from that number rather than from a formula.
  6. Deploy a deliberately broken migration to a staging app and confirm it blocks the release instead of crash-looping the running one.

The short version

Pin Ruby in `.ruby-version` or `.tool-versions`, commit the lockfile, install with deployment mode on, give the build the master key so precompile can boot the app, bind Puma to 0.0.0.0 and `$PORT`, log to stdout, run `db:prepare` as a discrete pre-traffic step, put uploads in object storage, and decide consciously where jobs run. That is a complete Rails 8 deployment with no Dockerfile and no registry.

Write the Dockerfile when it earns its place — a system library the build image lacks, a byte-identical image across CI and production, an ops team that already standardised on images. Not because the generator put one there.

Frequently asked questions

Can I deploy a Rails 8 app without a Dockerfile?

Yes. Any platform with Ruby build detection reads your Gemfile and Gemfile.lock, installs the gems, precompiles assets, and runs the start command you give it — usually `bundle exec puma -C config/puma.rb`. Pin the Ruby version in `.ruby-version` or `.tool-versions` so the build resolves the same interpreter your lockfile and native gem extensions assume. A Dockerfile earns its place when you need system libraries the build image does not include, when you want a byte-identical image in CI and production, or when your organisation already standardises on images. For a standard Rails app with Postgres, the generated Dockerfile mostly restates facts your repository already declares.

Why does rails assets:precompile fail with a missing secret error?

Because `assets:precompile` is a Rake task and Rake tasks load the Rails environment. Booting the app runs your initializers, and initializers read encrypted credentials, which requires `RAILS_MASTER_KEY` or `SECRET_KEY_BASE` to be present. If your platform injects secrets only into the running process and not into the build, precompile fails during what looks like an asset step with an error about credentials, which sends people off debugging their JavaScript bundler for an hour. Set the master key as an app-level environment variable so it is visible at build time as well as runtime. Rails 8 also supports `SECRET_KEY_BASE_DUMMY=1`, which generates a throwaway key for the build — safe only if no initializer needs to decrypt a real credential to boot.

Why does my Rails app deploy successfully but return 502 or fail its health check?

Almost always because Puma bound to localhost. The development default binds to 127.0.0.1, and a health check arriving from outside the process — across the VM's network interface — can never reach a loopback socket. The app starts cleanly, logs that it is listening, and is unreachable, which is why the logs look perfect while the deploy fails. Bind to `0.0.0.0` and read the port from the `PORT` environment variable rather than hard-coding 3000. The second most common cause is the app listening on a different port than the platform is probing, which the same fix resolves.

Should I run db:migrate or db:prepare on deploy?

`db:prepare` is the friendlier default. It creates the database if it does not exist, loads the schema when no migrations have been applied, and otherwise runs pending migrations — so it works on a first deploy, on a fresh preview environment, and on an established production database without branching logic. `db:migrate` fails outright against a database that does not exist yet. Either way, run it as a discrete step after the build and before traffic shifts, never inside the start command, where every replica would race on every restart and a failed migration would crash-loop production instead of blocking a release. With blue-green deploys, also make each migration compatible with the code currently running, since both versions are briefly live.

Where do Active Storage uploads go on a platform with an ephemeral filesystem?

Nowhere durable, if you leave the service set to `:local`. Rails writes the blobs into `storage/` on the machine's disk, and on any platform that provisions a fresh machine per deploy those files disappear the next time you ship. The database records survive, the blobs do not, and nothing raises an error until a user requests a file. Configure an object-storage service in `config/storage.yml` — S3, GCS, Azure, or any S3-compatible endpoint — and point `config.active_storage.service` at it in your production environment file before you accept a single upload. The same rule covers anything else you were tempted to keep on disk, including SQLite side stores and generated file caches.

Should Solid Queue run in the same process as Puma or separately?

It depends on job volume and how much you care about isolating failures. Solid Queue's Puma plugin runs workers inside the web process, which is the simplest possible deployment and a fine default for low, light job volume — one app, one deploy, no extra infrastructure since the queue lives in your existing database. The cost is a shared memory budget and a shared failure domain: a memory-hungry job can get your web workers OOM-killed, and a worker crash is a web outage. Once jobs are more than incidental, deploy the same repository a second time with a worker start command, so the two scale and fail independently. Sidekiq follows exactly the same pattern and still needs Redis. In every configuration, make sure exactly one recurring-job scheduler is running, or every scheduled email goes out twice.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.