Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

rails s: What the Development Server Does and Why Production Is Different

Sean

Platform Writer

Aug 08, 2026
8 min read

rails s is short for rails server, and it boots your application on port 3000 with a web server — Puma by default — configured for development: code reloads on every request, errors render full stack traces in the browser, and assets compile on demand. Every one of those behaviours is off in production, which is why a development server tells you almost nothing about how the app performs.

rails s: What the Development Server Does and Why Production Is Different

That gap causes a specific and recurring surprise: the app is responsive locally and slow when deployed, or works locally and fails to boot on the server. Understanding what rails s turns on explains most of both.

Table of contents

The flags worth knowing

The defaults are fine most of the time. Four options come up regularly:

rails s                       # port 3000, development, localhost only
rails s -p 4000               # different port
rails s -b 0.0.0.0            # listen on all interfaces
rails s -e production         # boot in production mode locally

The binding one matters more than it looks. By default Rails binds to localhost, so the server is unreachable from outside the machine. Inside a container or a VM, that means the port mapping is correct and nothing answers. Binding to 0.0.0.0 is what makes a containerised Rails app reachable, and forgetting it is one of the most common Docker-and-Rails problems there is.

Also worth knowing: in newer Rails versions, bin/rails is preferred over the bare rails command. bin/rails runs through the binstub, which loads your project’s exact gem versions. The bare rails command can resolve to a different Rails version installed globally, producing confusing errors that have nothing to do with your code.

What development mode is doing

The behaviours that make development pleasant and are all disabled in production:

  • Code reloading. Rails checks whether files changed and reloads them per request. Convenient, and it adds real per-request overhead and means your app is re-initialised constantly.
  • Eager loading off. Classes load lazily as they are referenced. Faster boot, and it means a missing constant in a rarely-used file goes unnoticed until production, where eager loading finds it at boot.
  • Full error pages. Stack traces, request parameters, and source excerpts rendered in the browser. Enormously useful and absolutely not something to expose publicly.
  • Assets compiled on demand, rather than precompiled and fingerprinted ahead of time.
  • Caching off by default, so you see every change immediately.

The eager loading difference is the one that bites hardest. An app that boots fine in development can fail to boot in production because eager loading hits a file with a syntax error or a mis-named class that lazy loading never touched. This is a good thing — it fails at boot rather than on a request — and it is a nasty surprise the first time.

Testing this before deploy is one command: rails s -e production locally, or more precisely RAILS_ENV=production bin/rails zeitwerk:check, which verifies every file can be eager loaded.

Why it is slower than production

People sometimes benchmark against rails s and conclude Rails is slow. The development server is roughly the worst-case configuration by design.

In production the same app has code reloading off, eager loading done once at boot, assets precompiled and served by the web server or a CDN, caching enabled, and multiple Puma workers rather than one process handling everything.

The difference is typically large — often several times the throughput. Any performance conclusion drawn from the development server is not measuring your application.

If you want a local number that means something, boot in production mode with precompiled assets:

RAILS_ENV=production bin/rails assets:precompile
RAILS_ENV=production SECRET_KEY_BASE=$(bin/rails secret) bin/rails s -e production

That is closer, though still a single machine with a local database. It is enough to catch the eager-loading and asset problems, which is the main reason to do it.

Puma configuration, and the number that matters

Puma runs multiple worker processes, each with a thread pool. The configuration lives in config/puma.rb:

workers Integer(ENV.fetch("WEB_CONCURRENCY", 2))
threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS", 5))
threads threads_count, threads_count
preload_app!

The number people get wrong is the database connection pool. Each thread in each worker can hold a connection, so the maximum concurrent connections from one instance is workers multiplied by threads. With 2 workers and 5 threads that is 10 connections — from one instance.

Your database pool setting in config/database.yml must be at least RAILS_MAX_THREADS, and your database’s own connection limit must accommodate that number multiplied by however many instances you run. Three instances of the above configuration is 30 connections before anything else connects.

Getting this wrong produces two distinct failures: ActiveRecord connection timeouts when the pool is too small for the threads, and the database refusing connections when the total across instances exceeds its limit. The second one is worse because it affects everything connecting to that database, not just the app that overshot.

Memory is the other constraint. Each worker is a separate process with its own copy of the application. preload_app! with copy-on-write helps, and a Rails app is still typically several hundred megabytes per worker. Setting WEB_CONCURRENCY higher than the memory allows is how a container gets killed by the runtime with no Ruby-level error.

When the server will not start

The recurring failures, with their actual causes:

  • A server is already running. Rails writes a pid file to tmp/pids/server.pid. If a previous process died without cleaning up, the new one refuses. Delete the file and start again.
  • Port already in use. Something else is on 3000. Find it with lsof -i :3000 and either stop it or use a different port.
  • Missing master key. config/master.key is not in version control by design. Without it Rails cannot decrypt credentials and refuses to boot. On a server this is an environment variable, RAILS_MASTER_KEY.
  • Pending migrations. Rails refuses to serve requests with unapplied migrations in development. Run them, or explicitly load the schema.
  • Gems out of date. A Gemfile.lock referencing gems that are not installed. bundle install.

The master key one is the most common deployment failure specifically, because it works locally — where the file exists and is gitignored — and fails on any machine that only has the repository.

Getting from rails s to a deployed app

The gap between running locally and running deployed is a short list, and it is the same list every time:

  1. Bind to 0.0.0.0 and respect the platform’s PORT variable, rather than hardcoding 3000.
  2. Precompile assets during the build, not at boot and certainly not on demand.
  3. Set RAILS_MASTER_KEY, DATABASE_URL, and SECRET_KEY_BASE as environment variables, never in files in the repository.
  4. Run migrations as a deliberate deploy step, not automatically on boot — a boot-time migration across several instances means several instances migrating simultaneously.
  5. Size the connection pool against workers times threads times instances, and check it against the database’s limit.
  6. Log to standard output, so the platform collects it, rather than to a file inside the container.

None of these is difficult. All of them are things development mode papers over, which is precisely why they surface together on the first deploy.

How this fits the rest of the stack

Nearly every item on that list is the same underlying thing: development mode hides a decision, and deployment makes you take it. The connection pool one is the most consequential, because it is the one whose failure affects other services sharing the database rather than just your own. Ruby services on RunxBuild deploy from a repository with the build log and the runtime log in one place, and managed Postgres and MySQL expose their connection limits as a visible number rather than something you discover under load. If you are sizing the app and the database together, the RunxBuild hosting calculator shows them as separate line items.

Useful related references:

FAQ

What does rails s actually do?

It is shorthand for rails server, booting your app on port 3000 with Puma configured for development — code reloading on, eager loading off, full error pages, and assets compiled on demand. All of those differ in production.

Why can I not reach my Rails server in Docker?

Rails binds to localhost by default, so nothing outside the container can reach it even with correct port mapping. Start it with rails s -b 0.0.0.0 to listen on all interfaces.

Why does my app boot locally but fail in production?

Usually eager loading. Development loads classes lazily, so a syntax error or misnamed class in a rarely-used file goes unnoticed. Production eager loads everything at boot and finds it. Test with RAILS_ENV=production bin/rails zeitwerk:check.

How many Puma workers and threads should I use?

Constrained by memory and database connections. Workers times threads is the maximum concurrent database connections per instance, and that multiplied by your instance count must fit within the database’s connection limit.

What is the server is already running error?

A stale tmp/pids/server.pid file left by a process that died without cleaning up. Delete the file and start the server again.

#rails s#rails server#puma#ruby on rails#development environment