Caddy as a forward proxy post 2020

Hi everyone, I started using Caddy about 2 years ago ,and I love it. Easy to use, performant, the perfect server and reverse proxy. One things I think I’ve been lacking is the possibility to use Caddy as a forward proxy.

I’ve seen multiple discussions about Caddy FP, but non giving a real answer about the idea or the feasibility.

An old plugin exist, but it’s out of date and not up to the actual standards, it seems to only support HTTP and doesn’t allow authentication.

I don’t think there is, right now, a way to use Caddy as a forward proxy tho that would be a very nice addition. But I’m just curious if it’s something that could appear in the future, or if someone already has a working plugin to do so ?

A couple of days ago, I have tried the forward proxy plugin and it worked very well for my use case.

My complete sample Caddyfile is

:3128 {
        forward_proxy {
                ports 80 443
                acl {
                        allow *.google.com
                        allow echo.free.beeceptor.com
                        deny all
                }
        }
}

This configuration allows to use the proxy for http and https to any google.com site or echo.free.beeceptor.com.

Some curl tests:

$ https_proxy=localhost:3128 http_proxy=localhost:3128 curl -v https://echo.free.beeceptor.com
* Uses proxy env variable https_proxy == 'localhost:3128'
*   Trying 127.0.0.1:3128...
* Connected to localhost (127.0.0.1) port 3128 (#0)
* allocate connect buffer!
* Establish HTTP proxy tunnel to echo.free.beeceptor.com:443
> CONNECT echo.free.beeceptor.com:443 HTTP/1.1
> Host: echo.free.beeceptor.com:443
> User-Agent: curl/7.76.1
> Proxy-Connection: Keep-Alive
>
< HTTP/1.1 200 OK
< Server: Caddy
< Date: Sun, 15 Feb 2026 16:17:45 GMT
< Transfer-Encoding: chunked
* Ignoring Transfer-Encoding in CONNECT 200 response
<
* Proxy replied 200 to CONNECT request
* CONNECT phase completed!
* ALPN, offering h2
* ALPN, offering http/1.1
*  CAfile: /etc/pki/tls/certs/ca-bundle.crt
* TLSv1.0 (OUT), TLS header, Certificate Status (22):
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* CONNECT phase completed!
* CONNECT phase completed!
* TLSv1.2 (IN), TLS header, Certificate Status (22):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS header, Finished (20):
* TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (IN), TLS header, Unknown (23):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.2 (IN), TLS header, Unknown (23):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS header, Unknown (23):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.2 (IN), TLS header, Unknown (23):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.2 (OUT), TLS header, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS header, Unknown (23):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN, server accepted to use h2
* Server certificate:
*  subject: CN=echo.free.beeceptor.com
*  start date: Dec 31 02:39:53 2025 GMT
*  expire date: Mar 31 02:39:52 2026 GMT
*  subjectAltName: host "echo.free.beeceptor.com" matched cert's "echo.free.beeceptor.com"
*  issuer: C=US; O=Let's Encrypt; CN=E7
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* TLSv1.2 (OUT), TLS header, Unknown (23):
* TLSv1.2 (OUT), TLS header, Unknown (23):
* TLSv1.2 (OUT), TLS header, Unknown (23):
* Using Stream ID: 1 (easy handle 0x5647aff70e60)
* TLSv1.2 (OUT), TLS header, Unknown (23):
> GET / HTTP/2
> Host: echo.free.beeceptor.com
> user-agent: curl/7.76.1
> accept: */*
>
* TLSv1.2 (IN), TLS header, Unknown (23):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.2 (IN), TLS header, Unknown (23):
* Connection state changed (MAX_CONCURRENT_STREAMS == 250)!
* TLSv1.2 (OUT), TLS header, Unknown (23):
* TLSv1.2 (IN), TLS header, Unknown (23):
* TLSv1.2 (IN), TLS header, Unknown (23):
* TLSv1.2 (IN), TLS header, Unknown (23):
< HTTP/2 200
< access-control-allow-origin: *
< alt-svc: h3=":443"; ma=2592000
< content-type: application/json
< date: Sun, 15 Feb 2026 16:17:46 GMT
< vary: Accept-Encoding
< via: 1.1 Caddy
<
* TLSv1.2 (IN), TLS header, Unknown (23):
{
  "method": "GET",
  "protocol": "https",
  "host": "echo.free.beeceptor.com",
  "path": "/",
  "ip": "[2a01:239:326:1900::1]:37132",
  "headers": {
    "Host": "echo.free.beeceptor.com",
    "User-Agent": "curl/7.76.1",
    "Accept": "*/*",
    "Via": "2.0 Caddy",
    "Accept-Encoding": "gzip"
  },
  "parsedQueryParams": {}
* TLSv1.2 (IN), TLS header, Unknown (23):
* Connection #0 to host localhost left intact
}
$ https_proxy=localhost:3128 http_proxy=localhost:3128 curl -vI http://www.google.com
* Uses proxy env variable http_proxy == 'localhost:3128'
*   Trying 127.0.0.1:3128...
* Connected to localhost (127.0.0.1) port 3128 (#0)
> HEAD http://www.google.com/ HTTP/1.1
> Host: www.google.com
> User-Agent: curl/7.76.1
> Accept: */*
> Proxy-Connection: Keep-Alive
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< Cache-Control: private
Cache-Control: private
< Content-Security-Policy-Report-Only: object-src 'none';base-uri 'self';script-src 'nonce-qL4V8ezgdCPnWvLBkgFC_Q' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp
Content-Security-Policy-Report-Only: object-src 'none';base-uri 'self';script-src 'nonce-qL4V8ezgdCPnWvLBkgFC_Q' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/other-hp
< Content-Type: text/html; charset=ISO-8859-1
Content-Type: text/html; charset=ISO-8859-1
< Date: Sun, 15 Feb 2026 16:21:53 GMT
Date: Sun, 15 Feb 2026 16:21:53 GMT
< Expires: Sun, 15 Feb 2026 16:21:53 GMT
Expires: Sun, 15 Feb 2026 16:21:53 GMT
< Reporting-Endpoints: default="//www.google.com/httpservice/retry/jserror?ei=ofKRabuFJ6SFxc8PqoWIkAw&cad=crash&error=Page%20Crash&jsel=1"
Reporting-Endpoints: default="//www.google.com/httpservice/retry/jserror?ei=ofKRabuFJ6SFxc8PqoWIkAw&cad=crash&error=Page%20Crash&jsel=1"
< Server: gws
Server: gws
< Set-Cookie: AEC=AaJma5taH1R8-Q5EVaXkL8oVqesbXemkr3RSYiPeO_P97YIcXLKjrhoJNWA; expires=Fri, 14-Aug-2026 16:21:53 GMT; path=/; domain=.google.com; Secure; HttpOnly; SameSite=lax
Set-Cookie: AEC=AaJma5taH1R8-Q5EVaXkL8oVqesbXemkr3RSYiPeO_P97YIcXLKjrhoJNWA; expires=Fri, 14-Aug-2026 16:21:53 GMT; path=/; domain=.google.com; Secure; HttpOnly; SameSite=lax
< Via: 1.1 caddy
Via: 1.1 caddy
< X-Frame-Options: SAMEORIGIN
X-Frame-Options: SAMEORIGIN
< X-Xss-Protection: 0
X-Xss-Protection: 0

<
* Connection #0 to host localhost left intac
2 Likes

This looks interesting, and you’re using that same plugin that wasn’t updated since 2019 ? and does it support auth ?

2019 is the latest tagged release, but there have been code updates since then. The latest non bot commit is from June 2025, and the bot one is from October 2025. That does not seem very old to me :slight_smile:

Yes, it does:

3 Likes

The caddy binary I was using for my tests used the last commit from the forwardproxy repo:

$ ./caddy list-modules --versions | grep forward_proxy
http.handlers.forward_proxy v0.0.0-20251013200746-bb364cc53204

Regarding authentication, here is an important detail that can save you unnecessary time for troubleshooting:

Although this is briefly mentioned in the README security section, the password specified in basic_auth must be the plaintext password. It is not a bcrypt hash, unlike the basic_auth directive built into Caddy itself, which expects a bcrypt-hashed password.

1 Like