Skip to content

Use IPv6 (::) default for production#3847

Merged
nateberkopec merged 3 commits intomainfrom
schneems/ipv6
Mar 26, 2026
Merged

Use IPv6 (::) default for production#3847
nateberkopec merged 3 commits intomainfrom
schneems/ipv6

Conversation

@schneems
Copy link
Copy Markdown
Member

@schneems schneems commented Dec 27, 2025

IPv4 addresses like 0.0.0.0 are easy and familiar, but they're increasingly more expensive with provider such as AWS (https://aws.amazon.com/blogs/aws/new-aws-public-ipv4-address-charge-public-ip-insights/). IPv6 addresses are much more plentiful, and therefore cheaper (and represent the future).

This update switches the default production address to IPv6 ::. Here's the table of the URLs that supports:

Host localhost:3000 127.0.0.1:3000 [::1]:3000 0.0.0.0:3000 [::]:3000 Network Open
localhost ❌ No
127.0.0.1 ❌ No
::1 ❌ No
0.0.0.0 ✅ Yes
:: ✅ Yes

Generated with https://gist.github.com/schneems/0e3e5fb61b6e4128499af01c9cf7ac1c.

Puma has no concept of a "development" default host, so we don't need to use ::1 instead of 127.0.0.1 or localhost. Also due to the behavior of ::1 not falling back to IPv4 the same way that :: does, it seems a higher-cost, lower benefit update, so I'm not recomending it for other defaults such as Rails rails/rails#56470.

Close #3812

  • I have reviewed the guidelines for contributing to this repository.
  • I have added (or updated) appropriate tests if this PR fixes a bug or adds a feature.
  • My pull request is 100 lines added/removed or less so that it can be easily reviewed.
  • [] If this PR doesn't need tests (docs change), I added [ci skip] to the title of the PR.
  • If this closes any issues, I have added "Closes #issue" to the PR description or my commit messages.
  • I have updated the documentation accordingly.
  • All new and existing tests passed, including Rubocop.

@schneems schneems force-pushed the schneems/ipv6 branch 3 times, most recently from 6a04515 to acdb5ce Compare December 28, 2025 03:08
@schneems schneems mentioned this pull request Dec 28, 2025
@schneems schneems marked this pull request as ready for review December 28, 2025 17:21
@github-actions github-actions Bot added the waiting-for-review Waiting on review from anyone label Dec 28, 2025
@dentarg
Copy link
Copy Markdown
Member

dentarg commented Jan 9, 2026

@schneems I think you should make tests fixes in its own branch/PR, so we can merge that independent of this PR!

@schneems schneems force-pushed the schneems/ipv6 branch 2 times, most recently from d5e315a to 1493422 Compare January 13, 2026 00:01
@schneems
Copy link
Copy Markdown
Member Author

I think you should make tests fixes in its own branch/PR, so we can merge that independent of this PR!

Done 💚

@schneems
Copy link
Copy Markdown
Member Author

I'm not actually sure how large this change is. I had to adjust the tests to get it to pass for windows (switching from 127.0.0.1 to localhost) so it is a breaking change for some users, for sure, but I believe it's a small breaking change (if there is such a thing). To be on the safe side, this would be a minor at least (7.1 -> 7.2). If we were doing strict semver (we aren't) this would need to be (7.1 -> 8.0).

@nateberkopec
Copy link
Copy Markdown
Member

I'm not actually sure how large this change is.

Because it's a prod change, I'd prefer to mark this as a full breaking change.

Copy link
Copy Markdown
Member

@nateberkopec nateberkopec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. What do we do if binding to :: fails? Currently this raises and dies with non-zero exit. Should we do something else?
  2. The Rails issue notes there are ipv6-only hosts b/c it can be disabled at the kernel level. That definitely warrants breaking change to me, do we have any idea how often this occurs?

For me this is kind of a no as-is. Breaks hard with not a lot of user benefit.

@github-actions github-actions Bot added waiting-for-changes Waiting on changes from the requestor and removed waiting-for-review Waiting on review from anyone labels Jan 23, 2026
@schneems
Copy link
Copy Markdown
Member Author

he Rails issue notes there are ipv6-only hosts b/c it can be disabled at the kernel level. That definitely warrants breaking change to me, do we have any idea how often this occurs?

I've yet to find any sort of docker setup that reproduces that behavior. I believe it may exists, but given the difficulty to induce it makes me think the actual occurrence is small.

Currently this raises and dies with non-zero exit. Should we do something else?

Without a system to test against it’s hard to say. In the scenario where IPv6 isn’t available I’m unclear if it still binds to IPv4 and behaves like 0.0.0.0 or if it fully fails. It might also be OS dependent.

We could catch and retry with 0.0.0.0 as an option. I would ideally not want to do that unless the error was clear and we aren’t retrying for all socket binding failures.

As a parallel thought: We could also allow setting tcp host via env var PUMA_TCP_HOST or just PUMA_HOST. Though it would only help people using something like the buildpack and not others who are running on a machine that doesn't have an IPv4 network.

Maybe adding env var support for config makes it less risky as someone could put the old 0.0.0.0 in their dot files if it failed for them.

@schneems
Copy link
Copy Markdown
Member Author

Adding a datapoint: I changed the Heroku docs for recommended puma config to use IPv6 November 22 of 2024. People leave feedback on articles, and I've not had any issues, granted these people are also likely using Heroku for prod, but I also know these docs show up in general search, and people sometimes copy and use them on other infrastructure. It's not an overwhelming signal that no one has complained, but it is a signal.

@stadniklksndr
Copy link
Copy Markdown
Contributor

stadniklksndr commented Feb 4, 2026

Tested how this change behaves in environments where IPv6 is partially or fully disabled:

  1. IPv6 disabled partially (sysctl net.ipv6.conf.all.disable_ipv6 return 1)
    In this case, TCPServer.new('::', 9292) does not raise an error.
    The socket is created with AF_INET6 family. However, it only accepts IPv4 traffic.

The issue: Puma will log Listening on http://[::]:9292, but IPv6 connections will fail with "Can’t be reached". The logs will be misleading for users who expect real IPv6 connectivity.

In this case table for :: looks like this:

Host localhost:9292 127.0.0.1:9292 [::1]:9292 0.0.0.0:9292 [::]:9292 Network Open
:: ✅ Yes

  1. When the IPv6 kernel module is not loaded at all
    IPv6 disabled (sudo vim /etc/default/grub; GRUB_CMDLINE_LINUX_DEFAULT="... ipv6.disable=1")
    In this case, TCPServer.new('::', 9292) raises a fatal error:
    Errno::EAFNOSUPPORT (Address family not supported by protocol - socket(2) for "::" port 9292)
    This will break Puma startup for any user who relies on the default binding if their kernel has IPv6 disabled.

Suggestion: Perhaps checking for available IPv6 interfaces before deciding which address to bind to would help. For example:

has_real_ipv6 = Socket.getifaddrs.any? do |ifaddr|
  ifaddr.addr&.ipv6? && !ifaddr.addr&.ipv6_loopback?
end

Using this check (or a similar one) would allow Puma to:

Bind to 0.0.0.0 automatically if IPv6 is missing or disabled at the kernel level (partially or fully disabled), preventing startup crashes. Provide more accurate logging about which protocol is actually being used.

IPv4 addresses like 0.0.0.0 are easy and familiar, but they're increasingly more expensive with providers such as AWS (https://aws.amazon.com/blogs/aws/new-aws-public-ipv4-address-charge-public-ip-insights/). IPv6 addresses are much more plentiful, and therefore cheaper (and represent the future).

This update switches the default production address to IPv6 `::`. Here's the table of the URLs that each host supports:

| Host | localhost:3000 | 127.0.0.1:3000 | \[::1\]:3000 | 0.0.0.0:3000 | \[::\]:3000 | Network Open |
|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `localhost` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ No |
| `127.0.0.1` | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ No |
| `::1` | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ No |
| `0.0.0.0` | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ Yes |
| `::` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ Yes |

Generated with https://gist.github.com/schneems/0e3e5fb61b6e4128499af01c9cf7ac1c.

Puma has no concept of a "development" default host, so we don't need to use `::1` instead of `127.0.0.1` or `localhost`. Also due to the behavior of `::1` not falling back to IPv4 the same way that `::` does, it seems a higher-cost, lower benefit update, so I'm not recomending it for other defaults such as Rails rails/rails#56470.

Close #3812
@nateberkopec nateberkopec force-pushed the schneems/ipv6 branch 2 times, most recently from dfa8dfb to b2c901a Compare March 26, 2026 02:06
@nateberkopec nateberkopec dismissed their stale review March 26, 2026 02:13

Changed my mind now that the next release is a major

@nateberkopec nateberkopec force-pushed the schneems/ipv6 branch 3 times, most recently from 28512ba to 3f2d92c Compare March 26, 2026 02:31
@nateberkopec
Copy link
Copy Markdown
Member

nateberkopec commented Mar 26, 2026

Codex speaking below.

Puma uses TCPServer.new(host, port) and does not set IPV6_V6ONLY, so behavior is inherited from the OS/kernel default for IPv6 sockets.

bind "tcp://[::]:PORT" can be either:

  1. dual-stack (:: accepts both IPv6 and IPv4-mapped traffic) or
  2. v6-only (:: accepts IPv6 only)

depending on platform/runtime configuration.

A few concrete data points:

I also reproduced the behavior with Ruby sockets:

  • v6-only socket bound to :: rejects 127.0.0.1 with ECONNREFUSED
  • same socket accepts ::1
  • connecting to localhost succeeds (resolver picks a compatible address)

User impact

  • Users who explicitly set bind/host are unaffected.
  • Users relying on defaults may see tooling drift if local scripts/healthchecks hardcode 127.0.0.1.
    • e.g. curl http://127.0.0.1:$PORT may fail in v6-only environments
    • curl http://localhost:$PORT or curl http://[::1]:$PORT will still work

The new fallback in this PR (no non-loopback IPv6 interface => rewrite to 0.0.0.0) helps with the "IPv6 absent" case, but not with the dual-stack-vs-v6-only semantic differences when IPv6 exists.

This is manageable as a breaking-change note: if someone depends on IPv4 loopback checks, they should either use localhost/[::1] or set an explicit IPv4 bind.

@github-actions github-actions Bot added waiting-for-review Waiting on review from anyone and removed waiting-for-changes Waiting on changes from the requestor labels Mar 26, 2026
@nateberkopec nateberkopec merged commit d7a907f into main Mar 26, 2026
97 of 218 checks passed
@nateberkopec nateberkopec deleted the schneems/ipv6 branch March 26, 2026 03:14
dentarg added a commit to dentarg/puma that referenced this pull request Mar 31, 2026
Strip the ::ffff: prefix from IPv4-mapped IPv6 addresses returned by
dual-stack sockets so that REMOTE_ADDR and common_logger output show
the original IPv4 address (e.g. 127.0.0.1 instead of ::ffff:127.0.0.1).

This is a side-effect of puma#3847 which changed the default bind to :: on
hosts with IPv6 interfaces.
dentarg added a commit to dentarg/puma that referenced this pull request Mar 31, 2026
Strip the ::ffff: prefix from IPv4-mapped IPv6 addresses returned by
dual-stack sockets so that REMOTE_ADDR and common_logger output show
the original IPv4 address (e.g. 127.0.0.1 instead of ::ffff:127.0.0.1).

This is a side-effect of puma#3847 which changed the default bind to :: on
hosts with IPv6 interfaces.
dentarg added a commit to dentarg/puma that referenced this pull request Mar 31, 2026
Strip the ::ffff: prefix from IPv4-mapped IPv6 addresses returned by
dual-stack sockets so that REMOTE_ADDR and CommonLogger output show
the original IPv4 address (e.g. 127.0.0.1 instead of ::ffff:127.0.0.1).

This is a side-effect of puma#3847 which changed the default bind to :: on
hosts with IPv6 interfaces.
nateberkopec added a commit that referenced this pull request Apr 7, 2026
* Fix IPv4-mapped IPv6 addresses in REMOTE_ADDR and request logs

Strip the ::ffff: prefix from IPv4-mapped IPv6 addresses returned by
dual-stack sockets so that REMOTE_ADDR and CommonLogger output show
the original IPv4 address (e.g. 127.0.0.1 instead of ::ffff:127.0.0.1).

This is a side-effect of #3847 which changed the default bind to :: on
hosts with IPv6 interfaces.

* Address review feedback on IPv4-mapped IPv6 fix

---------

Co-authored-by: Nate Berkopec <[email protected]>
nateberkopec added a commit that referenced this pull request Apr 7, 2026
* Fix IPv4-mapped IPv6 addresses in REMOTE_ADDR and request logs

Strip the ::ffff: prefix from IPv4-mapped IPv6 addresses returned by
dual-stack sockets so that REMOTE_ADDR and CommonLogger output show
the original IPv4 address (e.g. 127.0.0.1 instead of ::ffff:127.0.0.1).

This is a side-effect of #3847 which changed the default bind to :: on
hosts with IPv6 interfaces.

* Address review feedback on IPv4-mapped IPv6 fix

---------

Co-authored-by: Nate Berkopec <[email protected]>
(cherry picked from commit 7406cc1)
nateberkopec added a commit that referenced this pull request Apr 7, 2026
* Fix IPv4-mapped IPv6 addresses in REMOTE_ADDR and request logs

Strip the ::ffff: prefix from IPv4-mapped IPv6 addresses returned by
dual-stack sockets so that REMOTE_ADDR and CommonLogger output show
the original IPv4 address (e.g. 127.0.0.1 instead of ::ffff:127.0.0.1).

This is a side-effect of #3847 which changed the default bind to :: on
hosts with IPv6 interfaces.

* Address review feedback on IPv4-mapped IPv6 fix

---------

Co-authored-by: Nate Berkopec <[email protected]>
(cherry picked from commit 7406cc1)
@glaszig
Copy link
Copy Markdown

glaszig commented Apr 9, 2026

i fail to see the purpose here. are people using puma on public addresses directly instead of behind proxies?

@hakusaro
Copy link
Copy Markdown

@glaszig

are people using puma on public addresses directly instead of behind proxies?

README.md:

With SSL support, zero-downtime rolling restarts and a built-in request bufferer, you can deploy Puma without any reverse proxy.

I believe it is an intended use case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change waiting-for-review Waiting on review from anyone

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IPv6 addresses as default

6 participants