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

3 min read Original article ↗

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.