<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ClearStack Engineering Blog]]></title><description><![CDATA[ClearStack Engineering Blog]]></description><link>https://blog.clearstack.io</link><generator>RSS for Node</generator><lastBuildDate>Fri, 24 Apr 2026 17:24:43 GMT</lastBuildDate><atom:link href="https://blog.clearstack.io/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The PORT Trap: Why rails s Silently Drops Your Puma Bind Directives]]></title><description><![CDATA[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. — e]]></description><link>https://blog.clearstack.io/the-port-trap-why-rails-s-silently-drops-your-puma-bind-directives</link><guid isPermaLink="true">https://blog.clearstack.io/the-port-trap-why-rails-s-silently-drops-your-puma-bind-directives</guid><category><![CDATA[Rails]]></category><category><![CDATA[Ruby]]></category><category><![CDATA[Puma]]></category><category><![CDATA[overmind]]></category><category><![CDATA[foreman]]></category><dc:creator><![CDATA[Ajaya Agrawalla]]></dc:creator><pubDate>Thu, 26 Mar 2026 18:58:52 GMT</pubDate><content:encoded><![CDATA[<h2>How Puma Reads Its Config</h2>
<p>Puma's configuration lives in <code>config/puma.rb</code>. When Puma starts directly (via <code>bundle exec puma</code>), it reads this file and applies every directive — <code>port</code>, <code>bind</code>, <code>threads</code>, etc. — exactly as written.</p>
<p>The <code>port</code> directive sets a default bind to <code>0.0.0.0:&lt;port&gt;</code>. The <code>bind</code> directive adds an explicit listener. You can call <code>bind</code> multiple times to listen on multiple addresses:</p>
<pre><code class="language-ruby"># config/puma.rb
bind "tcp://127.0.0.1:3000"
bind "tcp://172.17.0.1:3000"  # Docker bridge
</code></pre>
<p>Puma will listen on both. Simple enough.</p>
<p><strong>What about</strong> <code>PORT</code><strong>?</strong> Puma's config commonly uses <code>ENV.fetch("PORT", 3000)</code> to read the port. When running Puma directly, <code>PORT</code> is just a value — Puma doesn't treat it specially. Whether <code>PORT</code> is set or not has no effect on how <code>bind</code> directives are processed. All binds are honored.</p>
<h2>What <code>rails s</code> Does Differently</h2>
<p><code>rails s</code> does <strong>not</strong> run Puma directly. It goes through Rack's handler layer:</p>
<pre><code class="language-plaintext">rails s → Rails::Server → Rackup::Server → Rack::Handler::Puma → Puma::Launcher
</code></pre>
<p>Rails constructs a <code>server_options</code> hash that includes <code>Host</code> and <code>Port</code>:</p>
<pre><code class="language-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
</code></pre>
<p>The critical piece is <code>user_supplied_options</code> — an array of option keys that the user <strong>explicitly</strong> set (via CLI flags or environment variables). Rails uses this to tell Puma which values are intentional overrides vs. framework defaults.</p>
<p>Puma's rack handler then splits options into three tiers:</p>
<table>
<thead>
<tr>
<th>Tier</th>
<th>Source</th>
<th>Precedence</th>
</tr>
</thead>
<tbody><tr>
<td><code>user_config</code></td>
<td>CLI flags, env vars the user set</td>
<td>Highest</td>
</tr>
<tr>
<td><code>file_config</code></td>
<td><code>config/puma.rb</code></td>
<td>Middle</td>
</tr>
<tr>
<td><code>default_config</code></td>
<td>Framework defaults</td>
<td>Lowest</td>
</tr>
</tbody></table>
<p>Options in <code>user_supplied_options</code> go into <code>user_config</code>. Everything else goes into <code>default_config</code>. <strong>First tier with a</strong> <code>:binds</code> <strong>key wins</strong> — they don't merge. (<a href="https://github.com/puma/puma/blob/v7.2.0/lib/puma/configuration.rb#L55-L60"><code>UserFileDefaultOptions#fetch</code></a>)</p>
<p>Here's the key line in Puma's rack handler:</p>
<pre><code class="language-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
</code></pre>
<p>When <code>Host</code> or <code>Port</code> lands in <code>user_config</code>, Puma calls <code>clear_binds!</code> on <code>user_config</code>, sets a single bind, and that tier wins — <strong>every</strong> <code>bind</code> <strong>in your</strong> <code>config/puma.rb</code> <strong>is ignored</strong>.</p>
<p>When they land in <code>default_config</code> (the normal case), <code>file_config</code> from <code>config/puma.rb</code> takes precedence and your binds survive.</p>
<p><strong>What about</strong> <code>PORT</code><strong>?</strong> This is where it gets dangerous. Rails inspects specific env vars to decide what counts as "user-supplied":</p>
<pre><code class="language-ruby"># https://github.com/rails/rails/blob/v8.0.4/railties/lib/rails/commands/server/server_command.rb#L206-L207
user_supplied_options &lt;&lt; :Host if ENV["HOST"] || ENV["BINDING"]
user_supplied_options &lt;&lt; :Port if ENV["PORT"]
</code></pre>
<p>If you have <code>PORT</code> defined — whether via shell export, process manager, or any other means — Rails treats <code>:Port</code> as user-supplied. It goes into <code>user_config</code>, which calls <code>clear_binds!</code>, and your <code>config/puma.rb</code> binds are wiped. The same applies to <code>HOST</code> and <code>BINDING</code> for the host side.</p>
<p>If <code>PORT</code> is <strong>not</strong> defined, the port value falls to <code>default_config</code> (lowest precedence), and your <code>file_config</code> binds from <code>config/puma.rb</code> win. Everything works.</p>
<p><strong>Why can't we just use</strong> <code>-b</code> <strong>multiple times?</strong> You can't. Rails' <code>-b</code> / <code>--binding</code> flag maps to a single <code>Host</code> string — there's no array support. And even if you pass <code>-b 127.0.0.1</code>, it marks <code>:Host</code> as user-supplied, which calls <code>clear_binds!</code> and replaces everything with that one address. The <code>user_supplied_options</code> mechanism is all-or-nothing: the moment Rails sees an explicit <code>Host</code> or <code>Port</code>, 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 <code>Host</code> + single <code>Port</code> → single bind. Multi-bind only works through <code>config/puma.rb</code>, and only when <code>user_config</code> doesn't override it.</p>
<h2>Why Process Managers Expose the Problem</h2>
<p>Process managers like Overmind and Foreman run Procfile entries as managed subprocesses. Both set <code>PORT</code> 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. <code>overmind start -N</code>), if you have <code>PORT</code> defined elsewhere in the environment, it still reaches the Rails process.</p>
<p>The chain:</p>
<ol>
<li><p><code>PORT</code> is defined in the environment (by any means)</p>
</li>
<li><p>Rails sees <code>ENV["PORT"]</code> → adds <code>:Port</code> to <code>user_supplied_options</code></p>
</li>
<li><p>Puma handler puts <code>Port</code> in <code>user_config</code> with <code>clear_binds!</code></p>
</li>
<li><p><code>user_config</code> binds only <code>tcp://localhost:&lt;port&gt;</code></p>
</li>
<li><p>Your <code>config/puma.rb</code> binds are ignored</p>
</li>
</ol>
<p><strong>What about</strong> <code>PORT</code><strong>?</strong> Process managers add a second source of <code>PORT</code> on top of whatever you already have. Overmind sets it by default (disable with <code>-N</code> / <code>--no-port</code>). Foreman sets it too (override with <code>-p 0</code> or <code>--no-port</code>). But even with those flags, if <code>PORT</code> is already defined in your environment, the damage is done.</p>
<p>When running <code>rails s</code> directly from your shell, <code>PORT</code> 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.</p>
<h2>The Fix</h2>
<p><strong>Don't use</strong> <code>PORT</code> <strong>for your app's port.</strong> Use a custom env var that Rails won't intercept:</p>
<pre><code class="language-ruby"># config/puma.rb
app_port = ENV.fetch("MY_APP_PORT", 3000)

docker_bridge_ip = `command -v docker &gt;/dev/null &amp;&amp; docker network inspect bridge \
  --format '{{(index .IPAM.Config 0).Gateway}}' 2&gt;/dev/null`.strip.presence

[
  "127.0.0.1",
  docker_bridge_ip,
  ("::1" if `ip -6 addr show lo 2&gt;/dev/null`[/inet6 ::1/]),
].compact.each { |ip| bind "tcp://#{ip.include?(':') ? "[#{ip}]" : ip}:#{app_port}" }
</code></pre>
<p>This:</p>
<ul>
<li><p>Binds to <code>127.0.0.1</code> for local access</p>
</li>
<li><p>Auto-detects the Docker bridge gateway IP (if Docker is installed) so containers using <code>host.docker.internal</code> or the bridge IP can reach your app</p>
</li>
<li><p>Adds IPv6 localhost if available</p>
</li>
<li><p>Uses <code>command -v docker</code> to skip the docker inspect when Docker isn't installed</p>
</li>
<li><p>Avoids <code>PORT</code> entirely, so Rails never promotes the port to <code>user_config</code> and never calls <code>clear_binds!</code></p>
</li>
</ul>
<p>To change the port: set <code>MY_APP_PORT=3005</code> or pass it inline. All binds will use the new port.</p>
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Scenario</th>
<th><code>PORT</code> defined?</th>
<th>Puma tier for binds</th>
<th><code>config/puma.rb</code> binds</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td><code>rails s</code> (no <code>PORT</code>)</td>
<td>No</td>
<td><code>default_config</code></td>
<td>Respected</td>
<td>All binds work</td>
</tr>
<tr>
<td><code>rails s</code> (<code>PORT</code> defined)</td>
<td>Yes</td>
<td><code>user_config</code> + <code>clear_binds!</code></td>
<td>Ignored</td>
<td>Only localhost</td>
</tr>
<tr>
<td><code>bundle exec puma</code></td>
<td>Irrelevant</td>
<td><code>file_config</code> only</td>
<td>Always respected</td>
<td>All binds work</td>
</tr>
</tbody></table>
<p>The safest path: use a custom env var for port configuration and let <code>config/puma.rb</code> own all bind directives.</p>
<h2>Source References</h2>
<ul>
<li><p><strong>Rails</strong> — <a href="https://github.com/rails/rails/blob/v8.0.4/railties/lib/rails/commands/server/server_command.rb">server_command.rb</a> (v8.0.4)</p>
</li>
<li><p><strong>Puma</strong> — <a href="https://github.com/puma/puma/blob/v7.2.0/lib/rack/handler/puma.rb">rack/handler/puma.rb</a>, <a href="https://github.com/puma/puma/blob/v7.2.0/lib/puma/dsl.rb">dsl.rb</a>, <a href="https://github.com/puma/puma/blob/v7.2.0/lib/puma/configuration.rb">configuration.rb</a> (v7.2.0)</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Why rails s Ignores Your Puma Binds (The Hidden PORT Trap)]]></title><description><![CDATA[TL;DR
- ENV["PORT"] → treated as user-supplied by Rails
- Puma prioritizes user config → calls clear_binds!
- All binds in config/puma.rb are ignored
- Result: single bind only
- Fix: use MY_APP_PORT ]]></description><link>https://blog.clearstack.io/why-rails-s-ignores-your-puma-binds-the-hidden-port-trap</link><guid isPermaLink="true">https://blog.clearstack.io/why-rails-s-ignores-your-puma-binds-the-hidden-port-trap</guid><category><![CDATA[Rails]]></category><category><![CDATA[Puma]]></category><category><![CDATA[Ruby]]></category><category><![CDATA[platform]]></category><dc:creator><![CDATA[Ajaya Agrawalla]]></dc:creator><pubDate>Tue, 24 Mar 2026 18:21:51 GMT</pubDate><content:encoded><![CDATA[<h2>TL;DR</h2>
<pre><code class="language-plaintext">- ENV["PORT"] → treated as user-supplied by Rails
- Puma prioritizes user config → calls clear_binds!
- All binds in config/puma.rb are ignored
- Result: single bind only
- Fix: use MY_APP_PORT instead
</code></pre>
<h2>Problem</h2>
<p><code>config/puma.rb</code> defines multiple binds, but <code>rails s</code> results in a single bind.</p>
<h2>Expected</h2>
<pre><code class="language-plaintext">bind "tcp://127.0.0.1:3000"
bind "tcp://172.17.0.1:3000"
</code></pre>
<pre><code class="language-plaintext">bundle exec puma
</code></pre>
<p>→ both binds active</p>
<h2>Actual</h2>
<pre><code class="language-plaintext">rails s
</code></pre>
<p>→ single bind (<code>localhost:PORT</code>)</p>
<h2>Execution Path</h2>
<pre><code class="language-plaintext">rails s → Rails::Server → Rack → Puma
</code></pre>
<pre><code class="language-plaintext"># https://github.com/rails/rails/blob/v8.0.4/railties/lib/rails/commands/server/server_command.rb
user_supplied_options &lt;&lt; :Port if ENV["PORT"]
</code></pre>
<h2>Root Cause</h2>
<table>
<thead>
<tr>
<th>Tier</th>
<th>Source</th>
</tr>
</thead>
<tbody><tr>
<td>user_config</td>
<td>env / CLI</td>
</tr>
<tr>
<td>file_config</td>
<td>puma.rb</td>
</tr>
<tr>
<td>default_config</td>
<td>internal</td>
</tr>
</tbody></table>
<blockquote>
<p>First tier defining <code>:binds</code> wins (no merge)</p>
</blockquote>
<pre><code class="language-plaintext"># https://github.com/puma/puma/blob/v7.2.0/lib/rack/handler/puma.rb#L96-L116
config.clear_binds! if host || port
</code></pre>
<pre><code class="language-plaintext"># https://github.com/puma/puma/blob/v7.2.0/lib/puma/dsl.rb#L294-L296
def clear_binds!; @options[:binds]=[]; end
</code></pre>
<p>Flow: PORT → user-supplied → user<em>config → clear</em>binds! → file binds ignored</p>
<h2>Observed</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>PORT</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>rails s</td>
<td>no</td>
<td>all binds</td>
</tr>
<tr>
<td>rails s</td>
<td>yes</td>
<td>single bind</td>
</tr>
<tr>
<td>puma</td>
<td>any</td>
<td>all binds</td>
</tr>
</tbody></table>
<h2>Fix</h2>
<pre><code class="language-plaintext">app_port = ENV.fetch("MY_APP_PORT",3000)
bind "tcp://127.0.0.1:#{app_port}"
bind "tcp://172.17.0.1:#{app_port}"
</code></pre>
<pre><code class="language-plaintext">MY_APP_PORT=3000 rails s
</code></pre>
<h2>Summary</h2>
<ul>
<li><p>Rails promotes PORT → user config</p>
</li>
<li><p>Puma replaces binds (no merge)</p>
</li>
<li><p>Multi-bind requires PORT unset</p>
</li>
</ul>
]]></content:encoded></item></channel></rss>