Getting real ip on docker for mac

1. The problem I’m having:

I am unable to read client ips. Instrad, I am getting docker’s network IPs.

I have a fairly common simply setup, with caddy running on Docker as reverse-proxy for all domains running inside other docker containers. Some of these domains are publicly accessible (via Cloudflared), whereas others can only be accessed locally (local DNS rewrites with AdGuard Home pointing to the right machine).

I set two “dummy” domains to test the readability of the IPs on both remote and local IPs. I assume that if I hit the Cloudflared domain, I should see my external IP; whereas if I hit the other, I should see my LAN IP.
However, in the first case I see Docker’s Cloudflared network IP (172.23.0.1), whereas in the second I see caddy’s (both retrieved with docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' caddy).
Both remote_ip and client_ip are the same, as you can see below.

2. Error messages and/or full log output:

{"level":"info","ts":1713640565.0951793,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.23.0.1","remote_port":"47428","client_ip":"172.23.0.1","proto":"HTTP/2.0","method":"GET","host":"ip.{$MY_DOMAIN}","uri":"/","headers":{"Upgrade-Insecure-Requests":["1"],"Sec-Fetch-Mode":["navigate"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"],"Cookie":[],"Accept-Encoding":["gzip, deflate, br"],"Sec-Fetch-Site":["none"],"Te":["trailers"],"User-Agent":["Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0"],"Accept-Language":["en-GB,en;q=0.5"],"Sec-Fetch-User":["?1"],"Sec-Gpc":["1"],"Dnt":["1"],"Sec-Fetch-Dest":["document"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"ip.{$MY_DOMAIN}"}},"bytes_read":0,"user_id":"","duration":0.000259251,"size":10,"status":200,"resp_headers":{"Server":["Caddy"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"Content-Type":["text/plain; charset=utf-8"],"Content-Length":["10"]}}

3. Caddy version:

v2.7.6 h1:w0NymbG2m9PcvKWsrXO6EEkY9Ru4FJK8uQbYcev1p3A=

4. How I installed and ran Caddy:

Here is my Dockerfile

FROM caddy:builder-alpine AS builder

RUN xcaddy build \
	--with github.com/caddy-dns/cloudflare \
	--with github.com/fvbommel/caddy-dns-ip-range

FROM caddy:alpine

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

a. System environment:

Apple M2 MacOS Sonoma 14.4.1
colima version 0.6.8

b. Command:

I run docker compose up

c. Service/unit/compose file:

This is my docker-compose.yml

x-default: &default
  environment:
    - PUID=${DOCKER_PUID}
    - PGID=${DOCKER_PGID}
    - UMASK=${DOCKER_UMASK}
    - TZ=${DOCKER_TZ}
    - LOG_LEVEL=ERROR
  security_opt:
    - no-new-privileges:true
  restart: unless-stopped

services:
  caddy:
    <<: *default
    container_name: caddy
    image: caddy:builder-alpine
    build:
      context: ${DOCKER_CONFIG_DIR}/caddy
      dockerfile: Dockerfile
    volumes:
      - ${DOCKER_CONFIG_DIR}/caddy:/data
      - ${DOCKER_CONFIG_DIR}/caddy/config/Caddyfile:/etc/caddy/Caddyfile:ro
    environment:
      - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
      - MY_DOMAIN=${MY_DOMAIN}
      - MY_EMAIL=${MY_EMAIL}
    ports:
      - 80:80
      - 443:443
    networks:
      - proxy

networks:
  proxy:
    driver: bridge

d. My complete Caddy config:

My Caddyfile:

{
	email {$MY_EMAIL}
	servers {
		trusted_proxies dns cloudflared
	}
}

(access_log) {
	log {
		output file /data/log/caddy.log
	}
}

(cloudflare) {
	tls {
		dns cloudflare {$CLOUDFLARE_API_TOKEN}
		resolvers 1.1.1.1
	}
}

cloudflared-ip.{$MY_DOMAIN} {
	templates
	respond "{{.RemoteIP}}"
	import access_log
	import cloudflare
}

ip.{$MY_DOMAIN} {
	templates
	respond "{{.RemoteIP}}"
	import access_log
	import cloudflare
}

5. Links to relevant resources:

@francislavoie mentioned this may be due the way Docker implements networking involves a TCP-layer proxy, so all connections’ IP addresses look like they’re coming from the Docker network itself.

Thinking about it more, you could try trusted_proxies static private_ranges instead which would trust any connections coming through other Docker containers. So it would read the headers from the request that Cloudflare adds. (You’ll probably also want to set client_ip_headers Cf-Connecting-Ip because Cloudflare’s X-Forwarded-For is still vulnerable to spoofing by default).

Heya thanks for getting back.

I edited my Caddyfile as follows:

{
	servers {
		trusted_proxies static private_ranges
		client_ip_headers Cf-Connecting-Ip
	}
}

cloudflared-ip.{$MY_DOMAIN} {
	templates
	respond "{{.RemoteIP}} - {http.request.header.CF-Connecting-IP}"
}

ip.{$MY_DOMAIN} {
	templates
	respond "{{.RemoteIP}} - {http.request.header.X-Forwarded-For}"
}

and can now see as response from cloudflared-ip.{$MY_DOMAIN} being:

172.18.0.2 - x.x.x.x

and this is the access log response:

{"level":"info","ts":1713708421.535578,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"172.18.0.2","remote_port":"39862","client_ip":"x.x.x.x","proto":"HTTP/1.1","method":"GET","host":"cloudflared-ip.{$MY_DOMAIN}","uri":"/","headers":{"X-Forwarded-For":["x.x.x.x"],"X-Forwarded-Proto":["https"],"User-Agent":["Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/125.1  Mobile/15E148 Safari/605.1.15"],"Accept-Language":["en-GB,en;q=0.9"],"Cf-Connecting-Ip":["x.x.x.x"],"Cf-Ipcountry":["GB"],"Cf-Warp-Tag-Id":["568e778c-6fe2-4416-b858-775b54ff9d9a"],"Sec-Fetch-Dest":["document"],"Accept":["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"],"Cdn-Loop":["cloudflare"],"Cf-Visitor":["{\"scheme\":\"https\"}"],"Pragma":["no-cache"],"Sec-Fetch-Site":["none"],"Accept-Encoding":["gzip, br"],"Cache-Control":["no-cache"],"Cf-Ray":["877df0225f4e891e-LHR"],"Connection":["keep-alive"],"Cookie":[],"Sec-Fetch-Mode":["navigate"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"","server_name":"cloudflared-ip.{$MY_DOMAIN}"}},"bytes_read":0,"user_id":"","duration":0.000508709,"size":42,"status":200,"resp_headers":{"Content-Type":["text/plain; charset=utf-8"],"Content-Length":["42"],"Server":["Caddy"],"Alt-Svc":["h3=\":443\"; ma=2592000"]}}

Is this how it’s supposed to work? I am only asking because on a local service (jellyfin) I can still see the wrong address being logged (that is the local one: 172.19.0.1 as opposed to the x.x.x.x one).

the ip.{$MY_DOMAIN} on the other hand responds with:

172.19.0.1 -

Then you need to configure Jellyfin to read from X-Forwarded-For, it doesn’t by default. See Networking | Jellyfin I think you’ll need to set the “known proxies” to your Caddy container’s name (i.e. caddy).

1 Like

I see. I’ll have to play a bit with this because Jellyfin is actually running natively (to benefit from transcoding) whereas all other services (including caddy) runs on docker. I guess I need to allocate to caddy an extrernally-accesible ip and use that as “known proxies”…?

Thanks for the support already!

Yeah you can assign containers static IPs. But that’s not really a Caddy config problem at this point, it’s a Docker config thing.

indeed, thanks.

Pardon the ignorant question, but why does caddy need to be trusted, if caddy itself is trusting cloudflare’s CF-Connecting-IP? Is there a way to just set that as client ip?

Also RE caddy being unable to read the local LAN ip due to Docker’s network setup - do you think the only way is to run caddy on the host machine (without Docker)?

Trust is a chain. Each party needs to be configured to trust whatever information they receive, otherwise they have to assume it came from an untrusted source.

Imagine Jellyfin didn’t require trust for reading from X-Forwarded-For. It’s possible to have Jellyfin be directly exposed to the internet. So that means if someone wanted to trick your server to think they come from a different IP than they actually do, they could simply change X-Forwarded-For to contain whatever they want and make a request to Jellyfin.

So to resolve that, users need to declare IPs that they know can be trusted, i.e. the IP of your webserver which you know properly handles X-Forwarded-For (to reject header manipulation from untrusted sources, and only accept it from trusted sources).

So the trust chain is User → Cloudflare → Caddy → Jellyfin, meaning that Jellyfin needs to trust Caddy, Caddy needs to trust Cloudflare, Cloudflare knows all requests it receives are untrusted so it provides the CF-Connecting-IP with the real IP. Unfortunately Cloudflare’s X-Forwarded-For can still be spoofed (IMO Cloudflare does this wrong but :man_shrugging: at least there’s an avenue for doing it right by using their custom header).

Hopefully that clarifies.

No, Caddy in Docker works fine. I’m not sure what exactly you’re referring to with this though.

1 Like

Thanks for taking the time to give such a clear explanation - it all makes perfect sense.

Let me rephrase that: when I connect to ip.{$MY_DOMAIN} (so not via Cloudflared) I see docker’s network ip. Shouldn’t I see my local machine ip? e.g. 192.168…? Because in that instance my local machine is simply connecting to Caddy, there’s nothing else in between to be trusted…?

Docker sometimes runs a userland proxy (TCP layer proxy) which makes TCP packets look like they’re coming from Docker itself. I don’t know how Docker networking on Mac works, but since it’s running inside a VM, it would not surprise me if it wasn’t possible to disable that behaviour. You’ll need to do research on that. Not a Caddy problem, anyway.

Obviously the reason it works with Cloudflare is becuase the original client IP is “smuggled” through using HTTP headers.

1 Like

Thank you! :pray:

and thanks for the amazing caddy

1 Like