LaunchKit
← All posts
· 11 min read · by The LaunchKit team · 0 views

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 host that 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.

#rails #infrastructure #saas

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.