Why does request.remote_ip work on Heroku but not Render?

We recently migrated CodeCrafters from Heroku to Render. An hour later we had customers complaining that our PPP discounts weren’t showing. Turns out this was because Rails’s request.remote_ip behaves differently on Heroku vs. Render and returns a proxy’s IP instead of the client’s IP.

If you’re using Rails on Render, read on to understand why this happens and how to fix it.

How request.remote_ip works

When a request arrives at your Rails app, it might have passed through multiple proxies. Each proxy typically appends its IP to the X-Forwarded-For header, building a chain like this:

X-Forwarded-For: <client>, <proxy1>, <proxy2>

Rails’ request.remote_ip walks this chain right-to-left, skipping any IPs it considers “trusted proxies” (private/reserved IP ranges by default). The first non-trusted IP it encounters is what it returns as the client’s IP.

This is a sensible default. Private IPs like 10.x.x.x or 172.16.x.x are almost certainly load balancers or internal proxies, not real clients.

The Heroku setup

On Heroku, the request path looks like this:

ClientHeroku RouterRails app

Heroku’s router sets X-Forwarded-For to the client’s IP and connects to your app from a private IP. So Rails sees something like:

X-Forwarded-For: 99.61.165.29
REMOTE_ADDR: 10.1.45.23

Rails builds a list by combining X-Forwarded-For with REMOTE_ADDR, giving it [99.61.165.29, 10.1.45.23]. It walks this list right-to-left: 10.1.45.23 is private, skip it. 99.61.165.29 is public — that’s the client. Correct!

The Render setup

Render uses Cloudflare in front of all its services. So the request path is different:

ClientCloudflareRender proxyRails app

Now the headers look like this:

X-Forwarded-For: 99.61.165.29, 104.22.17.40
REMOTE_ADDR: 10.21.157.68

104.22.17.40 is a Cloudflare edge IP. It’s a real, public IP address.

Rails walks right-to-left: 10.21.157.68 is private, skip it. 104.22.17.40 is public — Rails thinks this is the client. Wrong.

The client’s actual IP (99.61.165.29) gets ignored.

Cloudflare does set headers with the real client IP — True-Client-Ip and Cf-Connecting-Ip both contain 99.61.165.29 — but Rails’ request.remote_ip doesn’t look at those headers.

The fix

Since Cloudflare always sits in front of Render, you can read the True-Client-Ip header that Cloudflare sets. This header contains the real client IP as determined by Cloudflare’s edge — it can’t be spoofed by the client.

def client_ip_from_rails_request(rails_request)
  rails_request.headers["True-Client-Ip"] ||
    rails_request.headers["Cf-Connecting-Ip"] ||
    rails_request.remote_ip
end

This is what Render’s own support team recommends. They also note that Render’s proxy does not strip incoming X-Forwarded-For headers — it just appends to them, making X-Forwarded-For trivially spoofable.

The fallback to remote_ip means this works in development too, where Cloudflare isn’t in the picture.