Having trouble with rewrite and reverse proxy: adding `admin` to beginning of path

1. The problem I’m having:

I’m trying to create a reverse proxy to my Pi-Hole instance so I can type pihole.internal into my browser and end up at 192.168.1.218/admin (where 192.168.1.218 is the IP where Pi-Hole is located). The specific part I’m having trouble with is attaching /admin to the path.

2. Error messages and/or full log output:

admin@caddy:~$ curl -vk https://pihole.internal
* Host pihole.internal:443 was resolved.
* IPv6: fe80::be24:11ff:feaa:86a5
* IPv4: 192.168.1.218
*   Trying 192.168.1.218:443...
* Connected to pihole.internal (192.168.1.218) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / id-ecPublicKey
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: CN=pi.hole
*  start date: Jul 26 03:08:32 2025 GMT
*  expire date: Jul 26 03:08:32 2055 GMT
*  issuer: CN=pi.hole; O=Pi-hole; C=DE
*  SSL certificate verify result: self-signed certificate in certificate chain (19), continuing anyway.
*   Certificate level 0: Public key type EC/secp384r1 (384/192 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/secp384r1 (384/192 Bits/secBits), signed using ecdsa-with-SHA256
* using HTTP/1.x
> GET / HTTP/1.1
> Host: pihole.internal
> User-Agent: curl/8.9.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 403 Forbidden
< Cache-Control: no-cache, no-store, must-revalidate, private, max-age=0
< Expires: 0
< Pragma: no-cache
< X-DNS-Prefetch-Control: off
< Content-Security-Policy: default-src 'self' 'unsafe-inline';
< X-Frame-Options: DENY
< X-XSS-Protection: 0
< X-Content-Type-Options: nosniff
< Referrer-Policy: strict-origin-when-cross-origin
< Content-Type: text/html; charset=utf-8
< Date: Sat, 26 Jul 2025 07:27:40 GMT
< Connection: close
< 
<!DOCTYPE html>
<!--
*  Pi-hole: A black hole for Internet advertisements
*  (c) 2017 Pi-hole, LLC (https://pi-hole.net)
*  Network-wide ad blocking via your own hardware.
*
*  This file is copyright under the latest version of the EUPL.
*  Please see LICENSE file for your rights under this license.
-->
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Pi-hole pihole</title>
    <meta name="csrf-token" content="">

    <link rel="apple-touch-icon" href="/admin/img/favicons/apple-touch-icon.png" sizes="180x180">
    <link rel="icon" href="/admin/img/favicons/favicon-32x32.png" sizes="32x32" type="image/png">
    <link rel="icon" href="/admin/img/favicons/favicon-16x16.png" sizes="16x16" type="image/png">
    <link rel="manifest" href="/admin/img/favicons/manifest.json">
    <link rel="mask-icon" href="/admin/img/favicons/safari-pinned-tab.svg" color="#367fa9">
    <link rel="shortcut icon" href="/admin/img/favicons/favicon.ico">
    <meta name="msapplication-TileColor" content="#367fa9">
    <meta name="msapplication-TileImage" content="/admin/img/favicons/mstile-150x150.png">
    <meta name="theme-color" content="#367fa9">

    <!-- Theme fonts -->
    <link rel="stylesheet" href="/admin/vendor/fonts/source-sans-pro/source-sans-pro.css?v=1753499303">

    <style>
        html { background-color: #000; }
    </style>

    <!-- Common styles -->
    <link rel="stylesheet" href="/admin/vendor/bootstrap/css/bootstrap.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/icheck/icheck-bootstrap.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/animate/animate.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/bstreeview/bstreeview.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/font-awesome/css/all.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/nprogress/nprogress.min.css?v=1753499303">

    <link rel="stylesheet" href="/admin/vendor/datatables/datatables.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/datatables-buttons/datatables.buttons.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/datatables-select/datatables.select.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/daterangepicker/daterangepicker.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/bootstrap-toggle/bootstrap-toggle.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/waitMe-js/waitMe.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/select2/select2.min.css?v=1753499303">
    <link rel="stylesheet" href="/admin/vendor/adminLTE/AdminLTE.min.css?v=1753499303">

    <!-- Theme styles (default-auto) -->
    <link rel="stylesheet" href="/admin/style/pi-hole.css?v=1753499303">
    <link rel="stylesheet" href="/admin/style/themes/default-dark.css?v=1753499303" media="(prefers-color-scheme: dark)">
    <link rel="stylesheet" href="/admin/style/themes/default-light.css?v=1753499303" media="not (prefers-color-scheme: dark)">

    <noscript><link rel="stylesheet" href="/admin/vendor/js-warn/js-warn.css?v=1753499303"></noscript>

    <!-- scripts -->
    <script src="/admin/vendor/jquery/jquery.min.js?v=1753499303"></script>
    <script src="/admin/vendor/bootstrap/js/bootstrap.min.js?v=1753499303"></script>
    <script src="/admin/vendor/adminLTE/adminlte.min.js?v=1753499303"></script>
    <script src="/admin/vendor/bootstrap-notify/bootstrap-notify.min.js?v=1753499303"></script>
    <script src="/admin/vendor/waitMe-js/modernized-waitme-min.js?v=1753499303"></script>
    <script src="/admin/vendor/nprogress/nprogress.min.js?v=1753499303"></script>
    <script src="/admin/scripts/js/utils.js?v=1753499303"></script>
<body class="hold-transition layout-boxed login-page page--">
    <div class="box login-box">
        <section style="padding: 15px;">
                <h2 class="error-headline text-danger">403</h2>
                <div class="error-content">
                    <h3><i class="fa fa-times-circle text-danger"></i> Oops! Access denied.</h3>
                    <p>
                        You don't have permission to access <code>-</code> on this server.<br>
                        Did you mean to go to <a href="/admin/">your Pi-hole's dashboard</a> instead?
                    </p>
            </div>
        </section>
    </div>
</body>
</html>
* TLSv1.3 (IN), TLS alert, close notify (256):
* shutting down connection #0
* TLSv1.3 (OUT), TLS alert, close notify (256):

3. Caddy version:

admin@caddy:~$ caddy version
v2.10.0 h1:fonubSaQKF1YANl8TXqGcn4IbIRUDdfAkpcsfI/vX5U=

4. How I installed and ran Caddy:

a. System environment:

Caddy is running in a Proxmox LXC using Ubuntu 24.10. Pi-Hole is running as a separate LXC on the same machine.

b. Command:

I installed Caddy using the documentation here: Install — Caddy Documentation

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

c. Service/unit/compose file:

N/A

d. My complete Caddy config:

proxmox.internal {
        reverse_proxy https://192.168.1.217:8006 {
                # WARNING: this is unsafe if this is ever exposed to the global internet!!!
                # This is acceptable as long as the network is local, private, secure, and trusted
                transport http {
                        tls_insecure_skip_verify
                }
        }
}

pihole.internal {
        rewrite * /admin{uri}

        reverse_proxy https://192.168.1.218 {
                # WARNING: this is unsafe if this is ever exposed to the global internet!!!
                # This is acceptable as long as the network is local, private, secure, and trusted
                transport http {
                        tls_insecure_skip_verify
                }
        }
}

5. Links to relevant resources:

My reference for rewrite with reverse proxy is here: rewrite (Caddyfile directive) — Caddy Documentation

Looking at the HTML code you’re getting back, I’d rather do this:

pihole.internal {
        redir / /admin/

        reverse_proxy https://192.168.1.218 {
                # WARNING: this is unsafe if this is ever exposed to the global internet!!!
                # This is acceptable as long as the network is local, private, secure, and trusted
                transport http {
                        tls_insecure_skip_verify
                }
        }
}

I tried that as well – same result.

not sure if this will help, but try this

pihole.internal {
        redir / /admin/
        handle_path /admin/* {
                reverse_proxy https://192.168.1.218 {
                        # WARNING: this is unsafe if this is ever exposed to the global internet!!!
                        # This is acceptable as long as the network is local, private, secure, and trusted
                        header_up Host {upstream_hostport}
                        transport http {
                                tls_insecure_skip_verify
                        }
                }
        }
}

That doesn’t quite work either, but the result is interesting:

admin@caddy:~$ curl -vk https://pihole.internal
* Host pihole.internal:443 was resolved.
* IPv6: (none)
* IPv4: 192.168.1.219
*   Trying 192.168.1.219:443...
* Connected to pihole.internal (192.168.1.219) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: [NONE]
*  start date: Jul 28 15:47:32 2025 GMT
*  expire date: Jul 29 03:47:32 2025 GMT
*  issuer: CN=Caddy Local Authority - ECC Intermediate
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://pihole.internal/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: pihole.internal]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.9.1]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: pihole.internal
> User-Agent: curl/8.9.1
> Accept: */*
> 
< HTTP/2 302 
< alt-svc: h3=":443"; ma=2592000
< location: /admin/
< server: Caddy
< content-length: 0
< date: Mon, 28 Jul 2025 22:21:18 GMT
< 
* Connection #0 to host pihole.internal left intact

I’m not sure why, but trying this and then reverting to the answer timelordx gave me made his solution work. Maybe something with caching that I was missing when testing this initially.

I’m 100% sure it was your browser caching your old results. I’m glad it’s working now. And thanks for reporting back the result :+1: