The PORT Trap: Why rails s Silently Drops Your Puma Bind Directives
How Puma Reads Its Config
Puma's configuration lives in config/puma.rb. When Puma starts directly (via bundle exec puma), it reads this file and applies every directive — port, bind, threads, etc. — exactly as written.
The port directive sets a default bind to 0.0.0.0:<port>. The bind directive adds an explicit listener. You can call bind multiple times to listen on multiple addresses:
# config/puma.rb
bind "tcp://127.0.0.1:3000"
bind "tcp://172.17.0.1:3000" # Docker bridge
Puma will listen on both. Simple enough.
What about PORT? Puma's config commonly uses ENV.fetch("PORT", 3000) to read the port. When running Puma directly, PORT is just a value — Puma doesn't treat it specially. Whether PORT is set or not has no effect on how bind directives are processed. All binds are honored.
What rails s Does Differently
rails s does not run Puma directly. It goes through Rack's handler layer:
rails s → Rails::Server → Rackup::Server → Rack::Handler::Puma → Puma::Launcher
Rails constructs a server_options hash that includes Host and Port:
# https://github.com/rails/rails/blob/v8.0.4/railties/lib/rails/commands/server/server_command.rb#L153-L168
def server_options
{
Port: port,
Host: host, # "localhost" in development, "0.0.0.0" otherwise
user_supplied_options: user_supplied_options,
# ...
}
end
The critical piece is user_supplied_options — an array of option keys that the user explicitly set (via CLI flags or environment variables). Rails uses this to tell Puma which values are intentional overrides vs. framework defaults.
Puma's rack handler then splits options into three tiers:
| Tier | Source | Precedence |
|---|---|---|
user_config |
CLI flags, env vars the user set | Highest |
file_config |
config/puma.rb |
Middle |
default_config |
Framework defaults | Lowest |
Options in user_supplied_options go into user_config. Everything else goes into default_config. First tier with a :binds key wins — they don't merge. (UserFileDefaultOptions#fetch)
Here's the key line in Puma's rack handler:
# https://github.com/puma/puma/blob/v7.2.0/lib/rack/handler/puma.rb#L96-L116
# clear_binds!: https://github.com/puma/puma/blob/v7.2.0/lib/puma/dsl.rb#L294-L296
def set_host_port_to_config(host, port, config)
config.clear_binds! if host || port # ← wipes all existing binds
# ... then sets a single bind from host:port
end
When Host or Port lands in user_config, Puma calls clear_binds! on user_config, sets a single bind, and that tier wins — every bind in your config/puma.rb is ignored.
When they land in default_config (the normal case), file_config from config/puma.rb takes precedence and your binds survive.
What about PORT? This is where it gets dangerous. Rails inspects specific env vars to decide what counts as "user-supplied":
# https://github.com/rails/rails/blob/v8.0.4/railties/lib/rails/commands/server/server_command.rb#L206-L207
user_supplied_options << :Host if ENV["HOST"] || ENV["BINDING"]
user_supplied_options << :Port if ENV["PORT"]
If you have PORT defined — whether via shell export, process manager, or any other means — Rails treats :Port as user-supplied. It goes into user_config, which calls clear_binds!, and your config/puma.rb binds are wiped. The same applies to HOST and BINDING for the host side.
If PORT is not defined, the port value falls to default_config (lowest precedence), and your file_config binds from config/puma.rb win. Everything works.
Why can't we just use -b multiple times? You can't. Rails' -b / --binding flag maps to a single Host string — there's no array support. And even if you pass -b 127.0.0.1, it marks :Host as user-supplied, which calls clear_binds! and replaces everything with that one address. The user_supplied_options mechanism is all-or-nothing: the moment Rails sees an explicit Host or Port, Puma's handler wipes the slate and builds a single bind from that pair. There is no way to pass multiple bind addresses through Rails' server options — it's a single Host + single Port → single bind. Multi-bind only works through config/puma.rb, and only when user_config doesn't override it.
Why Process Managers Expose the Problem
Process managers like Overmind and Foreman run Procfile entries as managed subprocesses. Both set PORT by default — Foreman starting at 5000, Overmind at 5000 with a step of 100 per process. Even if you disable the process manager's own port assignment (e.g. overmind start -N), if you have PORT defined elsewhere in the environment, it still reaches the Rails process.
The chain:
PORTis defined in the environment (by any means)Rails sees
ENV["PORT"]→ adds:Porttouser_supplied_optionsPuma handler puts
Portinuser_configwithclear_binds!user_configbinds onlytcp://localhost:<port>Your
config/puma.rbbinds are ignored
What about PORT? Process managers add a second source of PORT on top of whatever you already have. Overmind sets it by default (disable with -N / --no-port). Foreman sets it too (override with -p 0 or --no-port). But even with those flags, if PORT is already defined in your environment, the damage is done.
When running rails s directly from your shell, PORT may or may not be defined depending on your shell configuration. That's why the behavior can differ between direct invocation and running through a process manager.
The Fix
Don't use PORT for your app's port. Use a custom env var that Rails won't intercept:
# config/puma.rb
app_port = ENV.fetch("MY_APP_PORT", 3000)
docker_bridge_ip = `command -v docker >/dev/null && docker network inspect bridge \
--format '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null`.strip.presence
[
"127.0.0.1",
docker_bridge_ip,
("::1" if `ip -6 addr show lo 2>/dev/null`[/inet6 ::1/]),
].compact.each { |ip| bind "tcp://#{ip.include?(':') ? "[#{ip}]" : ip}:#{app_port}" }
This:
Binds to
127.0.0.1for local accessAuto-detects the Docker bridge gateway IP (if Docker is installed) so containers using
host.docker.internalor the bridge IP can reach your appAdds IPv6 localhost if available
Uses
command -v dockerto skip the docker inspect when Docker isn't installedAvoids
PORTentirely, so Rails never promotes the port touser_configand never callsclear_binds!
To change the port: set MY_APP_PORT=3005 or pass it inline. All binds will use the new port.
Summary
| Scenario | PORT defined? |
Puma tier for binds | config/puma.rb binds |
Result |
|---|---|---|---|---|
rails s (no PORT) |
No | default_config |
Respected | All binds work |
rails s (PORT defined) |
Yes | user_config + clear_binds! |
Ignored | Only localhost |
bundle exec puma |
Irrelevant | file_config only |
Always respected | All binds work |
The safest path: use a custom env var for port configuration and let config/puma.rb own all bind directives.
Source References
Rails — server_command.rb (v8.0.4)
Puma — rack/handler/puma.rb, dsl.rb, configuration.rb (v7.2.0)