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:
Client → Heroku Router → Rails 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:
Client → Cloudflare → Render proxy → Rails 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.