Trusted_proxies module

1. The problem I’m having:

I’m building a caddy server (first time) to run behind cloudflare using lets encrypt.
I’m building a custom version of caddy in a docker file (launched using docker compose) using xcaddy (see below).
We are also using cloudflare’s dns.

My problem is that I’m looking to add a trusted proxies section for the cloud flare servers.

I found this module:

but I’m getting an error saying trusted_proxies is an unknown directive.

2. Error messages and/or full log output:

2025-04-02T03:37:32.984661000Z {"level":"info","ts":1743565052.9842877,"msg":"using config from file","file":"/etc/caddy/Caddyfile"}
2025-04-02T03:37:32.985783000Z Error: adapting config using caddyfile: /etc/caddy/Caddyfile:95: unrecognized directive: trusted_proxies

3. Caddy version:

2.9.1

4. How I installed and ran Caddy:

Using docker build (see below) with docker-compose.

a. System environment:

ubuntu 24.10
docker --version
Docker version 28.0.1, build 068a01e

b. Command:

PASTE OVER THIS, BETWEEN THE ``` LINES.
Please use the preview pane to ensure it looks nice.

c. Service/unit/compose file:

# we build our own caddy file as we need the cloudflare module.
FROM caddy:2.9.1-builder AS builder

# https://caddyserver.com/docs/modules/dns.providers.cloudflare
RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    --with github.com/WeidiDeng/caddy-cloudflare-ip

FROM caddy:2.9.1

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

docker-compose.yaml

volumes:
  caddy_data:
  caddy_config:


services:
caddy:
    container_name: caddy
    image: onepub/onepub-caddy:${ONEPUB_VERSION}
    restart: always
    network_mode: "host"
    cap_add:
      - NET_ADMIN
    environment:
      ACME_AGREE: "true"
      ACME_URL: ${ACME_URL} # staging or production
      EMAIL: ${AUTH_PROVIDER_EMAIL_ADDRESS}
      CLOUDFLARE_API_TOKEN: ${AUTH_PROVIDER_TOKEN}
      DEBUG: "false"

    volumes:
      # Persist certificates
      - caddy_data:/data
      - caddy_config:/config

      # Mount your Caddyfile (this should contain your complete caddy config)
      - /opt/onepub/config/caddy:/etc/caddy

      # JSON error files
      # - /opt/onepub/config/caddy/json:/etc/caddy/json

      # Logs
      - /tmp/caddy:/var/log/caddy

    logging:
      driver: "journald"

d. My complete Caddy config:

{
    # Email for Let's Encrypt notifications
    email {$EMAIL}
    # ACME CA URL allows us to flip between production and staging.
    # we default to staging.
    acme_ca ${ACME_URL:https://acme-staging-v02.api.letsencrypt.org/directory}

    log { 
        output file /var/log/caddy/access.log 
    }

}

# Main Domain Configuration
*.onepub.dev, onepub.dev {
    

    tls {
        # API Token required for Wild card certs
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }


    # Remove duplicate slashes from the URI
    # (e.g., /path//to///resource) but avoids the query string
    @multiple_slashes path_regexp multipleSlashes ^(.*)//+(.*)$
    redir @multiple_slashes {scheme}://{host}{re.multipleSlashes.1}/{re.multipleSlashes.2}{query} permanent


    # Block common AI crawler User-Agents
    @block_ai header_regexp User-Agent "(?i)(GPTBot|ChatGPT|Google-Extended|Claude-Web|Anthropic|Amazonbot|FacebookBot|cohere-ai|Bytespider|YouBot)"
    respond @block_ai 403

    # Block all PHP file requests, returning no response (similar to nginx 444)
    @block_php path *.php
    abort @block_php

    # Redirect all .mp4 requests permanently (HTTP 301) to video subdomain
    @mp4_redirect path *.mp4
    redir @mp4_redirect https://video.{host}{uri} permanent



    handle /mailhog/* {
        basic_auth {
            bsutton XXXX
        }
        reverse_proxy 127.0.0.1:8025
    }

    handle /api/* {
        reverse_proxy 127.0.0.1:8080 {
            header_up Host {host}
            header_up X-Real-IP {remote_host}
            transport http { 
                read_timeout 300s 
                }
    
            # Required for WebSocket support
            header_up Connection {header.Connection}
            header_up Upgrade {header.Upgrade}

        }
    }

     # Global error handling, applied only for API endpoints
    handle_errors {
        @api path /api/*
        handle @api {
            root * /etc/caddy/json
            rewrite 502 /500.json
            rewrite 404 /404.json
            file_server
        }
    }

    reverse_proxy 127.0.0.1:8080 {
        header_up Host {host}
        header_up X-Real-IP {remote_host}
        transport http { 
            read_timeout 300s
        }
    
       # Required for WebSocket support
        header_up Connection {header.Connection}
        header_up Upgrade {header.Upgrade}        
    }

    trusted_proxies cloudflare {
    interval 12h
    timeout 15s
}


    encode gzip
}

# Combined Video Domains Configuration
# we serve video on the video subdomain, because it would violate
# cloudflare TOS to proxy video through cloudflare
video.onepub.dev, video.beta.onepub.dev {
    tls {
        # API Token required for Wild card certs
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }


    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
    }

    handle *.mp4 {
        reverse_proxy 127.0.0.1:8080 {
            header_up Host {host}
            header_up X-Real-IP {remote_host}
            header_up X-Forwarded-Proto https
            transport http { 
                read_timeout 300s 
            }
            # Web sc
            header_up Connection {header.Connection}
            header_up Upgrade {header.Upgrade}
        }
    }

    # Domain-based redirection (fallback)
    @prod host video.onepub.dev
    redir @prod https://onepub.dev{uri} permanent
    redir https://beta.onepub.dev{uri} permanent

    encode gzip
}

5. Links to relevant resources:

You images don’t have the custom build in them

https://hub.docker.com/layers/onepub/onepub-caddy/4.63.117/images/sha256-3ae88656019deeea714c0cda1dccc58b3e0b8cfea1fd5dd5f308cabde599d702

https://hub.docker.com/layers/onepub/onepub-caddy/4.63.117/images/sha256-3ae88656019deeea714c0cda1dccc58b3e0b8cfea1fd5dd5f308cabde599d702

thanks for the help.

However, I’m not certain what is going on.

I can see the build steps running (see below).
And I know that its publishing this build as the timestamps on docker hub line up.

Is the problem with the copy command?
Do I need to copy the resulting build from a different location?

# we build our own caddy file as we need the cloudflare module.
FROM caddy:2.9.1-builder AS builder

# https://caddyserver.com/docs/modules/dns.providers.cloudflare
RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    --with github.com/WeidiDeng/caddy-cloudflare-ip

FROM caddy:2.9.1

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

CMD ["caddy" "run" "--config" "/etc/caddy/Caddyfile" "--adapter" "caddyfile"]

#0 building with "default" instance using docker driver

#1 [internal] load build definition from caddy.dockerfile
#1 transferring dockerfile: 1.01kB done
#1 DONE 0.0s

#2 [internal] load metadata for docker.io/library/caddy:2.9.1-builder
#2 ...

#3 [auth] library/caddy:pull token for registry-1.docker.io
#3 DONE 0.0s

#4 [internal] load metadata for docker.io/library/caddy:2.9.1
#4 ...

#2 [internal] load metadata for docker.io/library/caddy:2.9.1-builder
#2 DONE 1.7s

#4 [internal] load metadata for docker.io/library/caddy:2.9.1
#4 DONE 1.7s

#5 [internal] load .dockerignore
#5 transferring context: 2B done
#5 DONE 0.0s

#6 [builder 1/2] FROM docker.io/library/caddy:2.9.1-builder@sha256:2223a2b14c52cd3d6054cbb97b14d57e9ae6b06d5fe3f72102bd50be01adca88
#6 DONE 0.0s

#7 [stage-1 1/2] FROM docker.io/library/caddy:2.9.1@sha256:cd261fc62394f1ff0b44f16eb1d202b4e71d5365c9ec866a4f1a9c5a52da9352
#7 DONE 0.0s

#8 [builder 2/2] RUN xcaddy build     --with github.com/caddy-dns/cloudflare     --with github.com/WeidiDeng/caddy-cloudflare-ip
#8 CACHED

#9 [stage-1 2/2] COPY --from=builder /usr/bin/caddy /usr/bin/caddy
#9 CACHED

#10 exporting to image
#10 exporting layers done
#10 writing image sha256:3aa99a677c5c086a5765e5d02191345311b59c45d35b0366cd0698d3d90176d0 done
#10 naming to docker.io/onepub/onepub-caddy:4.63.117 done
#10 DONE 0.0s
Publishing onepub-caddy docker image: onepub/onepub-caddy:4.63.117...

Sorry, I might’ve misread the Docker Hub layer description.

Anyway, I pulled your image to check, and the custom modules are there. Are you passing the correct version in ONEPUB_VERSION?

/srv # caddy list-modules
admin.api.load
admin.api.metrics
admin.api.pki
admin.api.reverse_proxy
caddy.adapters.caddyfile
caddy.config_loaders.http
caddy.filesystems
caddy.listeners.http_redirect
caddy.listeners.proxy_protocol
caddy.listeners.tls
caddy.logging.cores.mock
caddy.logging.encoders.append
caddy.logging.encoders.console
caddy.logging.encoders.filter
caddy.logging.encoders.filter.cookie
caddy.logging.encoders.filter.delete
caddy.logging.encoders.filter.hash
caddy.logging.encoders.filter.ip_mask
caddy.logging.encoders.filter.query
caddy.logging.encoders.filter.regexp
caddy.logging.encoders.filter.rename
caddy.logging.encoders.filter.replace
caddy.logging.encoders.json
caddy.logging.writers.discard
caddy.logging.writers.file
caddy.logging.writers.net
caddy.logging.writers.stderr
caddy.logging.writers.stdout
caddy.storage.file_system
events
http
http.authentication.hashes.bcrypt
http.authentication.providers.http_basic
http.encoders.gzip
http.encoders.zstd
http.handlers.acme_server
http.handlers.authentication
http.handlers.copy_response
http.handlers.copy_response_headers
http.handlers.encode
http.handlers.error
http.handlers.file_server
http.handlers.headers
http.handlers.intercept
http.handlers.invoke
http.handlers.log_append
http.handlers.map
http.handlers.metrics
http.handlers.push
http.handlers.request_body
http.handlers.reverse_proxy
http.handlers.rewrite
http.handlers.static_response
http.handlers.subroute
http.handlers.templates
http.handlers.tracing
http.handlers.vars
http.ip_sources.static
http.matchers.client_ip
http.matchers.expression
http.matchers.file
http.matchers.header
http.matchers.header_regexp
http.matchers.host
http.matchers.method
http.matchers.not
http.matchers.path
http.matchers.path_regexp
http.matchers.protocol
http.matchers.query
http.matchers.remote_ip
http.matchers.tls
http.matchers.vars
http.matchers.vars_regexp
http.precompressed.br
http.precompressed.gzip
http.precompressed.zstd
http.reverse_proxy.selection_policies.client_ip_hash
http.reverse_proxy.selection_policies.cookie
http.reverse_proxy.selection_policies.first
http.reverse_proxy.selection_policies.header
http.reverse_proxy.selection_policies.ip_hash
http.reverse_proxy.selection_policies.least_conn
http.reverse_proxy.selection_policies.query
http.reverse_proxy.selection_policies.random
http.reverse_proxy.selection_policies.random_choose
http.reverse_proxy.selection_policies.round_robin
http.reverse_proxy.selection_policies.uri_hash
http.reverse_proxy.selection_policies.weighted_round_robin
http.reverse_proxy.transport.fastcgi
http.reverse_proxy.transport.http
http.reverse_proxy.upstreams.a
http.reverse_proxy.upstreams.multi
http.reverse_proxy.upstreams.srv
pki
tls
tls.ca_pool.source.file
tls.ca_pool.source.http
tls.ca_pool.source.inline
tls.ca_pool.source.pki_intermediate
tls.ca_pool.source.pki_root
tls.ca_pool.source.storage
tls.certificates.automate
tls.certificates.load_files
tls.certificates.load_folders
tls.certificates.load_pem
tls.certificates.load_storage
tls.client_auth.verifier.leaf
tls.get_certificate.http
tls.get_certificate.tailscale
tls.handshake_match.local_ip
tls.handshake_match.remote_ip
tls.handshake_match.sni
tls.handshake_match.sni_regexp
tls.issuance.acme
tls.issuance.internal
tls.issuance.zerossl
tls.leaf_cert_loader.file
tls.leaf_cert_loader.folder
tls.leaf_cert_loader.pem
tls.leaf_cert_loader.storage
tls.permission.http
tls.stek.distributed
tls.stek.standard

  Standard modules: 124

dns.providers.cloudflare
http.ip_sources.cloudflare

  Non-standard modules: 2

  Unknown modules: 0
/srv # 

Your trusted_proxies needs to be within the reverse_proxy block I think. reverse_proxy (Caddyfile directive) — Caddy Documentation

Once again thanks for the help.

You can see below what I ended up with (it now correctly validates).

The key change was as you suggested, putting the trusted_proxies in the global servers section.

{
	# Email for Let's Encrypt notifications
	email {$EMAIL}
	# ACME CA URL allows us to flip between production and staging.
	# we default to staging.
	acme_ca ${ACME_URL:https://acme-staging-v02.api.letsencrypt.org/directory}

	log {
		output file /var/log/caddy/access.log
	}

	servers {
		trusted_proxies cloudflare {
			interval 12h
			timeout 15s
		}
	}
}

# Main Domain Configuration
*.onepub.dev, onepub.dev {
	tls {
		# API Token required for Wild card certs
		dns cloudflare {env.CLOUDFLARE_API_TOKEN}
	}

	# Remove duplicate slashes from the URI
	# (e.g., /path//to///resource) but avoids the query string
	@multiple_slashes path_regexp multipleSlashes ^(.*)//+(.*)$
	redir @multiple_slashes {scheme}://{host}{re.multipleSlashes.1}/{re.multipleSlashes.2}{query} permanent

	# Block common AI crawler User-Agents
	@block_ai header_regexp User-Agent "(?i)(GPTBot|ChatGPT|Google-Extended|Claude-Web|Anthropic|Amazonbot|FacebookBot|cohere-ai|Bytespider|YouBot)"
	respond @block_ai 403

	# Block all PHP file requests, returning no response (similar to nginx 444)
	@block_php path *.php
	abort @block_php

	# Redirect all .mp4 requests permanently (HTTP 301) to video subdomain
	@mp4_redirect path *.mp4
	redir @mp4_redirect https://video.{host}{uri} permanent

	handle /mailhog/* {
		basic_auth {
			# password is in lastpass under 'mailhog on production'
			# hash is generated by running caddy hash-password.
			bsutton 		}
		reverse_proxy 127.0.0.1:8025
	}

	handle /api/* {
		reverse_proxy 127.0.0.1:8080 {
			header_up Host {host}
			header_up X-Real-IP {remote_host}
			transport http {
				read_timeout 300s
			}

			# Required for WebSocket support
			header_up Connection {header.Connection}
			header_up Upgrade {header.Upgrade}
		}
	}

	# Global error handling, applied only for API endpoints
	handle_errors {
		@api path /api/*
		handle @api {
			root * /etc/caddy/json
			rewrite 502 /500.json
			rewrite 404 /404.json
			file_server
		}
	}

	reverse_proxy 127.0.0.1:8080 {
		header_up Host {host}
		header_up X-Real-IP {remote_host}
		transport http {
			read_timeout 300s
		}

		# Required for WebSocket support
		header_up Connection {header.Connection}
		header_up Upgrade {header.Upgrade}
	}

	# The install script (op-build.dart) will insert a set of
	# trusted_proxies statements. One for each Cloudflaer IP
	# listed at https://www.cloudflare.com/ips/
	# THE FOLLOWING LINE WILL BE REPLACED BY THE BUILD SCRiPTS
	# <!IPS FOR CLOUDFLARE>


	encode gzip
}

# Combined Video Domains Configuration
# we serve video on the video subdomain, because it would violate
# cloudflare TOS to proxy video through cloudflare
video.onepub.dev, video.beta.onepub.dev {
	tls {
		# API Token required for Wild card certs
		dns cloudflare {env.CLOUDFLARE_API_TOKEN}
	}

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Frame-Options "SAMEORIGIN"
		X-Content-Type-Options "nosniff"
	}

	handle /*.mp4 {
		reverse_proxy 127.0.0.1:8080 {
			header_up Host {host}
			header_up X-Real-IP {remote_host}
			header_up X-Forwarded-Proto https
			transport http {
				read_timeout 300s
			}
			# Web sc
			header_up Connection {header.Connection}
			header_up Upgrade {header.Upgrade}
		}
	}

	# Domain-based redirection (fallback)
	@prod host video.onepub.dev
	redir @prod https://onepub.dev{uri} permanent
	redir https://beta.onepub.dev{uri} permanent

	encode gzip
}

You mean you never used it in the config before?! I thought your complaint was that it doesn’t exist in the build, thus errors when used in config,

both where issues.
I wasn’t certain it was in the build and I didn’t know where to place it.

Again thanks for the help. I wouldn’t have found the solution without your assistance.