# 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:

```ruby
# 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:

```plaintext
rails s → Rails::Server → Rackup::Server → Rack::Handler::Puma → Puma::Launcher
```

Rails constructs a `server_options` hash that includes `Host` and `Port`:

```ruby
# 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`](https://github.com/puma/puma/blob/v7.2.0/lib/puma/configuration.rb#L55-L60))

Here's the key line in Puma's rack handler:

```ruby
# 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":

```ruby
# 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:

1.  `PORT` is defined in the environment (by any means)
    
2.  Rails sees `ENV["PORT"]` → adds `:Port` to `user_supplied_options`
    
3.  Puma handler puts `Port` in `user_config` with `clear_binds!`
    
4.  `user_config` binds only `tcp://localhost:<port>`
    
5.  Your `config/puma.rb` binds 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:

```ruby
# 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.1` for local access
    
*   Auto-detects the Docker bridge gateway IP (if Docker is installed) so containers using `host.docker.internal` or the bridge IP can reach your app
    
*   Adds IPv6 localhost if available
    
*   Uses `command -v docker` to skip the docker inspect when Docker isn't installed
    
*   Avoids `PORT` entirely, so Rails never promotes the port to `user_config` and never calls `clear_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](https://github.com/rails/rails/blob/v8.0.4/railties/lib/rails/commands/server/server_command.rb) (v8.0.4)
    
*   **Puma** — [rack/handler/puma.rb](https://github.com/puma/puma/blob/v7.2.0/lib/rack/handler/puma.rb), [dsl.rb](https://github.com/puma/puma/blob/v7.2.0/lib/puma/dsl.rb), [configuration.rb](https://github.com/puma/puma/blob/v7.2.0/lib/puma/configuration.rb) (v7.2.0)
