The Rails startup stack, counted
Somebody picking Rails for a startup is asking one question underneath all the others: how much of
the product is already written. The answer has moved a lot since 2021, mostly in the direction of
"more than you think", and the parts that are still missing are not the parts the marketing pages
talk about. So this page is a count. One rails new, one authentication generator, and then every
place the generated app stops short of something a paying customer would notice.
Everything below ran on this laptop against the lockfile the generator produced: rails 8.1.4, Ruby
4.0.5, puma 8.0.2, solid_queue 1.7.0, solid_cache 1.0.10, solid_cable 4.1.0, kamal 2.12.0,
thruster 0.1.26, PostgreSQL 17 on port 15432, Apple M2 Max with 12 cores, macOS arm64-darwin25. The
command was rails new rails-startup-stack --database=postgresql --skip-test.
What one rails new actually installs
The Gemfile has 16 gem lines. bundle install on it printed:
Bundle complete! 21 Gemfile dependencies, 114 gems now installed.
The gap between 16 and 21 is the group :development, :test block: debug, bundler-audit,
brakeman, rubocop-rails-omakase, and web-console in development alone. The 114 is the closure,
everything those 21 drag in.
One detail worth the ten seconds it takes to check. The generator banner said Rails 8.1.3.1, and the
Gemfile it wrote says gem "rails", "~> 8.1.3", ">= 8.1.3.1", which is a range. Gemfile.lock came
back with rails (8.1.4) and railties (8.1.4), because 8.1.4 had shipped by the time bundler
resolved. The version you are running is the one in the lockfile.
What is not there matters more than what is. No Redis:
puts "loaded_specs=#{Gem.loaded_specs.size}"
puts "redis=#{Gem.loaded_specs.keys.grep(/redis/).inspect}"
puts "node=#{File.exist?("package.json")}"
loaded_specs=118
redis=[]
node=false
Jobs, cache and Action Cable are all pointed at PostgreSQL. config/environments/production.rb
carries config.cache_store = :solid_cache_store on line 50 and
config.active_job.queue_adapter = :solid_queue on line 53; config/cable.yml has
adapter: solid_cable under production. That arrangement and its sharp edges are the subject of
running Rails 8 without Redis, and the question of whether to replace
the queue is Solid Queue against Sidekiq.
Deployment is generated too, and it is not a sample: Dockerfile, config/deploy.yml,
.kamal/secrets, .kamal/hooks, and bin/thrust. The deploy.yml names a volumes: entry for
/rails/storage and sets SOLID_QUEUE_IN_PUMA: true under env.clear, which is the whole
single-box architecture stated in two lines of YAML.
The authentication generator writes eleven files and no sign-up
bin/rails generate authentication is the biggest single thing in this list, and the one most
people have not run. Its source is
railties-8.1.4/lib/rails/generators/rails/authentication/authentication_generator.rb, 62 lines,
and the file list is not long:
create app/views/sessions/new.html.erb
create app/models/session.rb
create app/models/user.rb
create app/models/current.rb
create app/controllers/sessions_controller.rb
create app/controllers/concerns/authentication.rb
create app/controllers/passwords_controller.rb
create app/channels/application_cable/connection.rb
create app/mailers/passwords_mailer.rb
create app/views/passwords_mailer/reset.html.erb
create app/views/passwords_mailer/reset.text.erb
insert app/controllers/application_controller.rb
route resources :passwords, param: :token
route resource :session
gsub Gemfile
It adds bcrypt to the Gemfile, then generates two migrations:
CreateUsers email_address:string!:uniq password_digest:string! and
CreateSessions user:references ip_address:string user_agent:string.
What you get is good. SessionsController#create is four lines around
User.authenticate_by(params.permit(:email_address, :password)), which is the constant-time
comparison you would otherwise get wrong, and the controller ships with
rate_limit to: 10, within: 3.minutes, only: :create. Session is a row per browser with the
signed permanent cookie holding only its id, so revoking a session is destroy. PasswordsMailer
uses generates_token_for, and the token in the email I generated decoded to a purpose of
User\npassword_reset\n900.
Now print the routes it added, bin/rails routes -g "session|password":
Prefix Verb URI Pattern Controller#Action
new_session GET /session/new(.:format) sessions#new
edit_session GET /session/edit(.:format) sessions#edit
session GET /session(.:format) sessions#show
PATCH /session(.:format) sessions#update
PUT /session(.:format) sessions#update
DELETE /session(.:format) sessions#destroy
POST /session(.:format) sessions#create
passwords GET /passwords(.:format) passwords#index
POST /passwords(.:format) passwords#create
new_password GET /passwords/new(.:format) passwords#new
edit_password GET /passwords/:token/edit(.:format) passwords#edit
password GET /passwords/:token(.:format) passwords#show
PATCH /passwords/:token(.:format) passwords#update
PUT /passwords/:token(.:format) passwords#update
DELETE /passwords/:token(.:format) passwords#destroy
There is no users#new and no users#create. The generator writes a User model and never writes
a way to make one. Sign-in, sign-out and password reset, and the very first step of the funnel is
yours. That is a defensible line for the framework to draw, since registration is where email
confirmation, invitations, teams and plan selection all attach and none of those are generic. It is
also the thing to know before you plan a week around "Rails has authentication now".
Seven of those 15 routes point at actions nobody wrote, because resource :session and
resources :passwords mint the full seven each and the two generated controllers implement three
and four. GET /passwords answers 404, and the production log line under it is
AbstractController::ActionNotFound (The action 'index' could not be found for PasswordsController).
Worth pruning with only: before somebody finds GET /session in a crawl of your sitemap.
User is five lines, and two of them are the ones people misread:
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
end
normalizes strips and downcases. Nothing validates that the string is an email address at all.
User.create!(email_address: " NOT-AN-EMAIL ", password: "sekretsekret") persists, with
email_address equal to "not-an-email". That assertion is in the test file below because I did
not believe it the first time.
The first successful sign-in raises NameError
Generate the app, generate authentication, migrate, create a user, post the login form, and this comes back:
NameError: undefined local variable or method 'root_url' for an instance of SessionsController
app/controllers/concerns/authentication.rb:38:in 'Authentication#after_authentication_url'
app/controllers/sessions_controller.rb:11:in 'SessionsController#create'
Line 38 of the concern the generator just wrote:
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
And line 15 of the config/routes.rb that rails new wrote:
# Defines the root path route ("/")
# root "posts#index"
Two generators, each correct on its own. The authentication one assumes a root route; the
application one ships it commented. The failure only appears on the happy path, after a correct
password, which is the last place anyone looks. Uncommenting root fixes it and there is nothing
clever to say about the fix. It is in here because an hour disappeared into it and the traceback
names a method nobody wrote.
The first password reset fails where nothing is looking
Production email is the one that will actually cost a startup a customer, because it does not look
like a failure from either end. Boot the generated app with RAILS_ENV=production and ask Action
Mailer what it is configured to do:
delivery_method=:smtp
smtp_settings={address: "localhost", port: 25, domain: "localhost.localdomain", user_name: nil, password: nil, authentication: nil, enable_starttls_auto: true, open_timeout: 5, read_timeout: 5}
default_url_options={host: "example.com"}
raise_delivery_errors=true
Those are Action Mailer's own defaults showing through, because production.rb configures no
provider: line 64 is # config.action_mailer.smtp_settings = {, commented, and the only
uncommented mailer line is config.action_mailer.default_url_options = { host: "example.com" },
which is a real value and a wrong one. The email the generator produces says so out loud:
You can reset your password on
http://example.com/passwords/eyJfcmFpbHMiOnsiZGF0YSI6WzEsIlBHeUNidklSc08iXSwiZXhwIjoiMjAyNi0wOS0yN1QxNjowMjo0OS41ODNaIiwicHVyIjoiVXNlclxucGFzc3dvcmRfcmVzZXRcbjkwMCJ9fQ==--471481075328d3d8a450b5ac3aa91ea5111e392d/edit
This link will expire in 15 minutes.
Here is the part that matters. PasswordsController#create ends with
PasswordsMailer.reset(user).deliver_later, so the SMTP connection is not attempted inside the
request. I posted the real form through Puma in production, with a CSRF token pulled from
/passwords/new, and the server answered 302 and set the flash
"Password reset instructions sent (if user with that email address exists)." The user sees
success. Forty seconds later:
jobs=1
failed=1
{"exception_class" => "Errno::ECONNREFUSED", "message" => "Connection refused - connect(2) for \"localhost\" port 25", ...}
finished_at=nil
scheduled=0 ready=0
One row in solid_queue_failed_executions, finished_at still nil, no retry scheduled, and no
exception anywhere a person would see it. A generated app has no jobs dashboard: Solid Queue ships
without one, mission_control-jobs is not in the Gemfile, and nothing is watching that table. So
the default state of a freshly deployed Rails app is that password resets fail silently, and you
find out when a customer tells you.
Two lines of production.rb and a provider account fix the delivery. The example.com host is the
one to fix first anyway, because it is wrong even after SMTP works, and a reset link to
example.com is indistinguishable from a phishing test.
One process, three threads, 191 MB
config/puma.rb in a generated app has no workers line at all. It is
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) and threads threads_count, threads_count, and
the boot log confirms Puma starting in single mode... Min threads: 3. One process, three threads.
With SOLID_QUEUE_IN_PUMA=true, which is what config/deploy.yml sets, the Puma plugin forks a
Solid Queue supervisor which forks three more:
Started Supervisor(fork) pid: 38249
Started Dispatcher pid: 38251, polling_interval: 1, batch_size: 500
Started Worker pid: 38252, polling_interval: 1, queues: "*", pool_type: :thread, pool_size: 3
Started Scheduler pid: 38253, recurring_schedule: ["clear_solid_queue_finished_jobs"]
Five processes, one Postgres, one box. ps -o rss on the five, after a load run, summed to
191.5 MB: Puma 89.5, supervisor 17.3, dispatcher 31.1, worker 40.1, scheduler 13.5. That number is
macOS resident set size and it does not account shared pages the way Linux does, so treat it as an
order of magnitude and not a provisioning figure.
Throughput, ab -n 3000 -c 8 against /session/new in production with assets precompiled, which
renders a real ERB template through the layout and touches no row:
Requests per second: 1041.87 [#/sec] (mean) 99% 10
Requests per second: 967.82 [#/sec] (mean) 99% 16
Requests per second: 996.76 [#/sec] (mean) 99% 21
About a thousand requests a second from one process on a laptop, for a page that does no database work. Whether that is enough for your startup is not a Rails question, and the shape of a slow Rails request is almost always an N+1 rather than the runtime. Where these five processes are supposed to live is Ruby on Rails hosting, which measures the same generated app on metered CPU and finds the cold start is the interesting number, not the throughput.
What I could not measure
I expected to show that running jobs inside Puma costs the web side real throughput, so I enqueued
six jobs that spin on Process.clock_gettime for 45 seconds each and re-ran ab. Three CPU-bound
job threads made no difference I can defend: idle runs came back at 1041.87, 342.66 and 967.82, the
loaded runs at 294.82, 996.76 and 1026.78, and one run in three collapses to roughly 300 req/s with
a 99th percentile near 150 ms in both conditions. Twelve cores absorb three busy threads, and
the noise on this laptop is larger than the effect. The claim that SOLID_QUEUE_IN_PUMA costs you
latency may well be true on a 1-vCPU box. It is not true here and I have no 1-vCPU box to test it
on, so it is not in the summary.
What is actually yours to build
Nothing in the bundle takes money or reports an exception. Gem.loaded_specs matched no stripe,
braintree, sentry, bugsnag, honeybadger, appsignal or rollbar, and that is correct: none
of them belong in a framework default. The honest list of what a generated Rails app still needs
before it can charge somebody is short, and it is the same list it has been for years:
- Registration, which the authentication generator deliberately skips and the Rails MVP page writes the controller for.
- An email provider, plus the
hostthat the reset link is currently getting wrong. - Payments, webhooks, and the state machine that decides what an unpaid account can still see.
- Somewhere to see that a job failed.
Every one of those is a decision about your product rather than about Rails, which is why the
framework does not make it. It is also why the gap between rails new and a first paying customer
is still measured in weeks, and why this site sells a boilerplate that
has already made those four decisions.
What this page does not cover
No deployment happened. Kamal was never run against a server, so what config/deploy.yml does on a
real host, how long the first push takes and what the asset_path bridging does between releases
are all unmeasured here. The throughput figure is one laptop with one process and a page that
touches no row, which is the least interesting benchmark in the world and is here only to say that
the default configuration is not the bottleneck.
Also absent: anything about hiring, funding or how many startups use Rails, none of which I can
check; --api mode, which changes the authentication generator's output and is a different page;
the --devcontainer and --skip-solid paths; and any comparison against what the equivalent
Next.js or Django app costs to assemble, which would require assembling both honestly and is not
something to estimate.
The app, the burn job and the test file are a rails new away. The Minitest file is eight examples
and 34 assertions, and two runs of it here finished in 0.116s and 0.145s.
Comments
No comments yet. Be the first.