Caddy Remote Admin Configuration


These notes are valid as of January 6, 2026 for Caddy version 2.6.2 installed via apt on Debian 13.1. Every configuration or output where a <value> is provided is a placeholder which will represent what makes sense to go in that place. Please know that I’m not an expert on any of this, it’s just what’s worked for me so far.

What You’ll End Up With

By the end of this you’ll have a Caddy server with a remote admin endpoint on port 2021 that:

  • Uses mTLS authentication (sort of, the current options are a little strange)
  • Is limited to your management subnet via firewall rules
  • Can be accessed remotely with client certificates

Prerequisites

If you want to follow along exactly, you’ll need:

  • A Debian system running Caddy 2.6.2 installed via apt
  • OPNsense for certificate management (or any CA you prefer)
  • Basic familiarity with curl and JSON configs

The caddy remote admin endpoint isn’t documented almost at all so getting it setup and working was a bit of a bear.

The following are some foot-guns I ran into along the way:

  • Remote admin currently leverages a pseudo mTLS authentication flow. I say pseudo because while a CA is required for the remote access endpoint, the public key provided for authentication is not required to be signed by the CA for the remote access endpoint.
  • Public and private keys for the client to be authenticated appear to be able to be signed by any CA and the authentication is instead controlled by entering a base64 encoded version of the generated public client certificate.
  • The “admin” module for remote access requires the identity, remote, and app.pki modules to be enabled in order to function.
  • Whatever CA is provided to the identity portion of the “admin” module will need to have its root CA trusted-by or provided-to the client attempting to interact with the remote admin endpoint.

caddy-proxy setup

For my self-hosting setup I ended up with the following configuration:

Auth Endpoint

An internal unencrypted api endpoint can be used to configure caddy at localhost:2019. Initially I tried just binding the port to 0.0.0.0:2019 instead and that worked, but required no authentication and provided no encryption. Looking through documentation at https://caddyserver.com/docs/json/admin/, I came across a “remote” admin configuration, but other than the reference to the configuration itself and one unsuccessful blog post of someone frustrated trying to get it setup, there are almost no hints provided in order to get it working. After going through all the certificate configuration (seen below) I ended up with an HTTPS port 2021 open on my caddy-proxy vm in the dmz limited to my management subnet using ufw rules.

Client Certs

Client certs are generated in opnsense from the System→Trust→Certificates menu and are signed by a local CA configured in System→Trust→Authorities with a root and intermediate CA. Turns out this isn’t strictly necessary, but I may end up leveraging these certificates in other manners moving forward.

In the future I may move to either Boulder, SmallStep, or attempt to automate certificates through opnsense’s API.

If a new client needs to authenticate with the caddy admin endpoint for whatever reason, the following actions can be taken in order to generate a new cert and convert it to a format that can be included in the caddy.json config as an authorized public key for remote admin.

  1. Navigate to opnsense→system→certificates and click on the ”+” to create a new certificate
  2. Create a new certificate with the following information:
    • Method: Create an internal Certificate
    • Description: <proxy admin client cert usage description>
    • Type: Client Certificate
    • Private key location: Save on this firewall
    • Key type: RSA-2048(or higher)
    • Digest Algorithm: SHA256(or higher)
    • Issuer: <intermediate ca cert used for other auth certs>
    • Lifetime: <number of days < the days left on the CA cert defaults to 397 in opnsense>
    • Common Name: <descriptive-name-for-client>
  3. Click on the generated certificate and copy the PEM cert and key data from the UI
  4. Enter the certs into Bitwarden Secrets Manager (bws) with sensible keys
  5. From a machine that can call bws call run the following command to convert the PEM cert to a DER encoded b64 string
openssl x509 -pubkey -noout -in $(bws secret get <certificate PEM bws uuid> | jq '.value') | openssl pkey -pubin -outform DER | base64 -w 0
  1. Enter the generated b64 string in your caddy config.json and reload the config

Server CA

I was confused about this at first but eventually got it working.

Moving from Caddyfile configuration to caddy.json config, from this point on I made changes to a local file on my caddy-proxy called caddy.json and attempted to reload configuration using curl to the localhost http admin endpoint on the default 2019 port using the following command where caddy.json is your intended configuration and is at your current pwd:

curl localhost:2019/load \
	-H "Content-Type: application/json" \
	-d @caddy.json

At first, it seemed like I could just provide the generated b64 string in the public_keys section of the caddy json config, and then it would “just work” but attempting to reload the config from localhost on the caddy-proxy resulted in the following error:

{
  "error": "loading config: loading new config: provisioning remote admin endpoint: cannot enable remote admin without a certificate cache; configure identity management to initialize a certificate cache"
}

At least this actually told me what I needed more or less: the remote admin endpoint needs an identity management configuration in order to actually fulfill requests. First I tried providing the public certs for the CA I had established from my opnsense instance, but only providing the public certs resulted in this slightly cryptic but still helpful error.

{
  "error": "loading config: loading new config: loading pki app module: provision pki: provisioning CA 'opnsense_auth_ca': open : no such file or directory"
}

So even though I thought that I was going to manage the CA externally, the pki module still attempted to provision its own CA. Theoretically I could provide caddy with the private key for the Intermediate and Root CA’s, but then I’d be issuing certificates for the same CA from different origins with no realistic revocation path which I didn’t like. I ultimately settled for letting caddy create its own CA specifically for the remote admin endpoint and was planning on figuring out client cert signing later but was surprised when it worked even without the client and server certs sharing the same CA as I expected from mTLS auth.

I think in the future, moving to an ACME compatible CA management will clean this up and allow moving away from the internal CA provider in this situation. The documentation marks all these configurations as experimental so they could and probably will change sooner than later.

At this point I had an initial config loaded following the example set below.

Example Initial Config

The admin and apps.pki settings are the only additions to this from the default Caddyfile provided from an apt installation in Debian 13 converted to a caddy.json with the following command:

cd /etc/caddy
caddy adapt --pretty | sudo tee ./caddyfile.json

If you are already using caddy’s internal api, instead you can get your current config you can just curl localhost:2019/config/ >> ./caddy.json as well. If you are on Debian, to use the api config swap the active caddy service from the caddy.service to the caddy-api.service by disabling the caddy.service and sudo systemctl enable --now caddy-api.service. Common names provided aren’t strictly required, but can be nice when interacting with the generated certs elsewhere. I decided to import the generated root CA public certificate to my opnsense instance without the private key, which will allow me to reference it without going digging for it.

{
  "admin": {
    "identity": {
      "identifiers": ["caddy-proxy.local", "<your proxy ip address>"],
      "issuers": [
        {
          "module": "internal",
          "ca": "your_auth_ca"
        }
      ]
    },
    "remote": {
      "access_control": [
        {
          "public_keys": ["<base64 encoded DER client cert>"]
        }
      ]
    }
  },
  "apps": {
    "http": {
      "servers": {
        "srv0": {
          "listen": [":80"],
          "routes": [
            {
              "handle": [
                {
                  "handler": "vars",
                  "root": "/usr/share/caddy"
                },
                {
                  "handler": "file_server",
                  "hide": ["./Caddyfile"]
                }
              ]
            }
          ]
        }
      }
    },
    "pki": {
      "certificate_authorities": {
        "your_auth_ca": {
          "name": "Your Authentication CA",
          "root_common_name": "Your Authentication Root CA",
          "intermediate_common_name": "Your Authentication Intermediate CA",
          "install_trust": false
        }
      }
    }
  }
}

Remote Admin Endpoint

Finally with all that set, we can start checking if our remote endpoint is live and if we can interact with it. From the remote admin documentation the default port should be open at port 2021 and after making the appropriate ufw exceptions to allow for requests to the proxy from my management subnet we can attempt to make our first request from outside the proxy itself.

From my laptop now I can try the following:

curl https://caddy-proxy.local/config/

But received the following output:

curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.

I had expected to see an error about authentication certificates missing or something like that, but I suppose an SSL error makes sense seeing how the CA configuration for the remote admin api is using our internal “authentication” CA. After some digging I found the generated root public cert for our your_auth_ca at the path /var/lib/caddy/.local/share/caddy/pki/authorities/your_auth_ca/root.crt and providing that cert with the --cacert flag in our curl request allowed me to move onto the next step.

The eventual goal is to make these requests in ansible instead of from curl, so there will be future flexibility with being able to read the certificates directly from secrets storage instead of copied to the client computer and saved as files, but during the process of getting things setup that was an easy and foolproof temporary solution.

curl --cacert ./caddyroot.crt https://caddy-proxy.local:2021/config/

And sending that request resulted in:

curl: (56) OpenSSL SSL_read: OpenSSL/3.5.1: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0

That seemed to track with what I was expecting, providing the public and private keys generated for our client cert that were converted to der base64 and uploaded to our config allows us to finally get what we were looking for.

curl --cert ./cert.pem --key ./priv.key --cacert ./caddyroot.crt https://caddy-proxy.local:2021/config/

Output:

{
  "admin": {
    "identity": {
      "identifiers": ["caddy-proxy.local", "<myip>"],
      "issuers": [{ "ca": "your_auth_ca", "module": "internal" }]
    },
    "remote": { "access_control": [{ "public_keys": ["<base64 DER pub>"] }] }
  },
  "apps": {
    "http": {
      "servers": {
        "srv0": {
          "listen": [":80"],
          "routes": [
            {
              "handle": [
                { "handler": "vars", "root": "/usr/share/caddy" },
                { "handler": "file_server", "hide": ["./Caddyfile"] }
              ]
            }
          ]
        }
      }
    },
    "pki": {
      "certificate_authorities": {
        "your_auth_ca": {
          "install_trust": false,
          "intermediate_common_name": "Your Authentication Intermediate CA",
          "name": "Your Authentication CA",
          "root_common_name": "Your Authentication Root CA"
        }
      }
    }
  }
}

It appears that once authenticated the remote admin api works like the internal one and configurations can be updated in part, and queried by section. For sake of thoroughness attempting to connect to the endpoint from a different subnet resulted in a failed request and closed socket. Attempting to submit a request with a different public and private key pair that aren’t configured in the caddy config resulted in the following:

curl --cert ./bad_cert.pem --key ./bad_cert.key --cacert ./caddyroot.crt https://caddy-proxy.local:2021/config/

Output:

curl: (56) OpenSSL SSL_read: OpenSSL/3.5.1: error:0A000418:SSL routines::tlsv1 alert unknown ca, errno 0

That seems to put my mind at ease for the most part.

What I Learned

The big surprises for me were:

  • Client certs don’t need to be signed by the same CA as the server (not true mTLS)
  • Caddy will create its own CA for the admin endpoint even if you provide external certs
  • The “experimental” tag in the docs isn’t kidding, and you can expect this to change

If you run into issues, the error messages are actually pretty helpful once you know what they’re telling you. Start with the identity/pki configuration first, then add the remote access controls.