API

Wrap the Caddy admin API: low-level config primitives, plus opinionated recipes for TLS and reverse-proxy routes

How it works

Caddy holds its entire configuration as a single JSON document, which it serves and accepts over its admin API (http://localhost:2019 by default). fastcaddy talks to that API; it never touches a Caddyfile.

There are two layers here:

Config set over the API is ephemeral: it does not survive a caddy restart unless you arrange that yourself. The recommended pattern (used by our production apps) is a setup script that runs on every deploy: reset(), then the recipes you need. That way the script is the single source of truth for your routing config.

Routes created by the recipes are tagged with predictable @ids, which is how re-runs find and replace them: a plain reverse proxy is tagged with its hostname, a wildcard route for *.{domain} is tagged wildcard-{domain}, and a subdomain proxy inside it is tagged {subdomain}.{domain}. del_id removes any of them.

from fastcore.test import *

Admin API primitives

These wrap the admin API endpoints directly. /config/ addresses config subtrees by path; /id/ addresses any config object that carries an "@id" key, no matter where it lives in the tree.

The admin API location comes from the CADDY_ADMIN env var if set. To point somewhere else at runtime, set fastcaddy.core.admin_url directly.


source

get_id

def get_id(
    path
):

Get an ID full URL from a path

get_id('jph.answer.ai')
'http://localhost:2019/id/jph.answer.ai/'

source

get_path

def get_path(
    path
):

Get a config full URL from a path

get_path('/apps/tls/automation/policies')
'http://localhost:2019/config/apps/tls/automation/policies/'

All requests go through one private helper: _req turns a connection failure into a clear “is caddy running?” message, and attaches caddy’s JSON error detail to any HTTP error before re-raising.


source

gid

def gid(
    path:str='/'
):

Get the config object whose @id matches path


source

has_id

def has_id(
    id
):

Check if id is set up


source

gcfg

def gcfg(
    path:str='/'
):

Get the config at path


source

has_path

def has_path(
    path
):

Check if any config exists at path


source

pid

def pid(
    d, path:str='/', method:str='post'
):

Put/post config d to the object whose @id matches path


source

pcfg

def pcfg(
    d, path:str='/', method:str='post'
):

Put/post config d at path

method follows caddy’s semantics: post sets a value (appending, when the target is an array), put creates a new value and fails with a 409 if the path already exists (creating any missing parent levels on the way), patch replaces an existing value, and delete removes one.

Two things to know about caddy paths: getting a missing key under an existing object returns null rather than an error (only traversing through a missing key fails), which is why has_path checks for None; and posting to '/' replaces the entire config – that’s what reset is for, and nothing else here does it implicitly.


source

reset

def reset():

Erase the entire caddy config

On a fresh (or just-reset) caddy, the config is simply empty:

reset()
gcfg()
{}

Config tree helpers

Small utilities for building and initializing nested config paths.


source

nested_setdict

def nested_setdict(
    sd, value, *keys
):

Returns sd updated to set value at the path keys

nested_setdict({'a':'b'}, {'c':'d'}, 'apps', 'http', 'servers', 'srv0')
{'a': 'b', 'apps': {'http': {'servers': {'srv0': {'c': 'd'}}}}}

source

path2keys

def path2keys(
    path
):

Split path by ‘/’ into a list

path2keys('/apps/tls/automation/policies')
['apps', 'tls', 'automation', 'policies']

source

keys2path

def keys2path(
    *keys
):

Join keys into a ‘/’ separated path

keys2path('apps', 'tls', 'automation', 'policies')
'/apps/tls/automation/policies'

source

nested_setcfg

def nested_setcfg(
    value, *keys
):

Set value at the path keys in the live caddy config


source

init_path

def init_path(
    path
):

Create path (and any missing parents) as an empty object, if not already present

Caddy’s put creates every missing level in one call, so this needs no loop – and thanks to the has_path guard it’s a no-op when the path already exists:

init_path('/apps/tls/automation')
init_path('/apps/tls/automation')
test_eq(has_path('/apps/tls/automation'), True)
gcfg()
{'apps': {'tls': {'automation': {}}}}

TLS automation

Caddy issues certificates according to automation policies under /apps/tls/automation. Two recipes set up the first policy: add_acme_config for real certificates (ACME with cloudflare DNS challenges – what you want in production), and add_tls_internal_config for caddy’s internal CA (local development). Both are no-ops if any automation config already exists, and neither touches anything outside /apps/tls/automation.

cf_token = os.environ.get('CADDY_CF_TOKEN', 'XXX')

source

get_acme_config

def get_acme_config(
    token
):

An ACME issuer config using cloudflare DNS challenges with token

The issuer config that add_acme_config installs:

get_acme_config('some-token')
{'module': 'acme',
 'challenges': {'dns': {'provider': {'name': 'cloudflare',
    'api_token': 'some-token'}}}}

source

add_tls_internal_config

def add_tls_internal_config():

Set up a TLS automation policy using caddy’s internal CA, if no automation config exists yet


source

add_acme_config

def add_acme_config(
    cf_token, subjects:NoneType=None
):

Set up a TLS automation policy using ACME with cloudflare DNS challenges, if no automation config exists yet

On a production box you’d now run (caddy verifies the token with cloudflare when the config is loaded, so it must be valid):

add_acme_config(cf_token)

For local development, use the internal CA instead:

reset()
add_tls_internal_config()
gcfg(automation_path)
{'policies': [{'issuers': [{'module': 'internal'}]}]}

Since automation config now exists, add_acme_config is a no-op rather than clobbering it – and without a token it refuses to set up a config that caddy would reject:

add_acme_config(cf_token)
test_eq(gcfg(automation_path+'/policies/0/issuers/0/module'), 'internal')
reset()
test_fail(lambda: add_acme_config(None), contains='required')

Schema reference

A copy of the caddy JSON schema ships with the package, so you can look up what config options exist without leaving Python. search_schema finds keys and descriptions matching a term; every path it returns can be passed straight to get_schema to fetch that node (list indices appear as [n] path parts).


source

caddy_docs

def caddy_docs():

The caddy JSON schema, loaded (once) from the bundled caddy_schema.json


source

get_schema

def get_schema(
    path:str
):

Get the caddy schema node at path (e.g. ‘/definitions/tls/properties/automation’ or a path from search_schema)


source

search_schema

def search_schema(
    term:str, path:str='', max_results:int=20
):

Recursively search the caddy schema for keys/values containing term; returned paths work with get_schema

search_schema('on_demand', max_results=3)
[('key', '/definitions/tls/properties/automation/properties/on_demand'),
 ('value',
  '/definitions/tls/properties/automation/properties/on_demand/description',
  'on_demand: object\nModule: tls\nhttps://pkg.go.dev/github.com/caddyserver/caddy/v2/modules/caddytls#OnDemandConfig\nOn-Demand TLS defers certificate operations to the\nmoment they are needed, e.g. during '),
 ('value',
  '/definitions/tls/properties/automation/properties/on_demand/markdownDescription',
  'on_demand: `object`  \nModule: `tls`  \n[godoc](https://pkg.go.dev/github.com/caddyserver/caddy/v2/modules/caddytls#OnDemandConfig)  \nOn-Demand TLS defers certificate operations to the\nmoment they are n')]

Every returned path round-trips through get_schema, including ones with list indices:

res = search_schema('issuers', max_results=50)
assert any('[' in p for _,p,*_ in res)
for _,p,*_ in res: get_schema(p)
get_schema(search_schema('on_demand')[0][1])['description']
"on_demand: object\nModule: tls\nhttps://pkg.go.dev/github.com/caddyserver/caddy/v2/modules/caddytls#OnDemandConfig\nOn-Demand TLS defers certificate operations to the\nmoment they are needed, e.g. during a TLS handshake.\nUseful when you don't know all the hostnames at\nconfig-time, or when you are not in control of the\ndomain names you are managing certificates for.\nIn 2015, Caddy became the first web server to\nimplement this experimental technology.\n\nNote that this field does not enable on-demand TLS;\nit only configures it for when it is used. To enable\nit, create an automation policy with `on_demand`.\n\n\nOnDemandConfig configures on-demand TLS, for obtaining\nneeded certificates at handshake-time. Because this\nfeature can easily be abused, Caddy must ask permission\nto your application whether a particular domain is allowed\nto have a certificate issued for it.\n"

Routes and reverse proxies

fastcaddy manages a single http server named srv0, listening on ports 80 and 443. All routes live in its routes list; each recipe below adds or replaces one route there, identified by its @id.


source

init_routes

def init_routes():

Create the basic http server config (srv0 on ports 80/443), if no servers exist yet


source

setup_pki_trust

def setup_pki_trust(
    install_trust
):

Configure PKI certificate authority trust installation


source

setup_caddy

def setup_caddy(
    cf_token:NoneType=None, # Cloudflare API token (required unless `local`)
    local:bool=False, # Use caddy's internal CA instead of ACME (for local dev)
    install_trust:bool=None, # Install the local CA into the system trust store?
    subjects:NoneType=None, # Subject names to restrict ACME cert issuance to
):

Create TLS automation config and the http server skeleton

setup_caddy is the one-call version of the above: TLS automation (ACME by default, internal CA with local=True), optional trust-store installation, and the srv0 server skeleton. In production:

setup_caddy(cf_token, subjects=['*.example.com', 'example.com'])

For local dev (which is also how the rest of this notebook runs – note that the earlier reset means the internal CA policy is created fresh here):

setup_caddy(local=True)
gcfg(srvs_path)
{'srv0': {'listen': [':80', ':443'], 'protocols': ['h1', 'h2'], 'routes': []}}

Handlers are the units a route’s handle list is built from. These two builders cover the common pair – response compression and the reverse proxy itself – so custom routes can compose them instead of hand-writing the dicts:


source

encode_handler

def encode_handler():

An encode handler that compresses responses with zstd or gzip


source

proxy_handler

def proxy_handler(
    *upstreams, # Upstream dial addresses, e.g. 'localhost:5001'
    st_delay:str='1m', # Keep streaming connections open this long across config reloads (None to disable)
):

A reverse_proxy handler dialing upstreams


source

add_route

def add_route(
    route
):

Append route to srv0’s route list


source

del_id

def del_id(
    id
):

Delete every config object whose @id matches id (e.g. a host)


source

add_reverse_proxy

def add_reverse_proxy(
    from_host, to_url, st_delay:str='1m', encode:bool=True
):

Create (or replace) a route reverse-proxying from_host to to_url, tagged with @id from_host

host = 'foo.example.com'
add_reverse_proxy(host, "localhost:5001")
gid(host)
{ '@id': 'foo.example.com',
  'handle': [{'encodings': {'gzip': {'level': 1}, 'zstd': {'level': 'fastest'}}, 'handler': 'encode', 'prefer': ['zstd', 'gzip']}, {'handler': 'reverse_proxy', 'stream_close_delay': '1m', 'upstreams': [{'dial': 'localhost:5001'}]}],
  'match': [{'host': ['foo.example.com']}],
  'terminal': True}

Calling it again for the same host replaces the route (same @id, new target) rather than adding a duplicate:

n = len(gcfg(rts_path))
add_reverse_proxy(host, "localhost:8000")
test_eq(len(gcfg(rts_path)), n)
test_eq(gid(host).handle[1].upstreams[0].dial, 'localhost:8000')

del_id removes it – and keeps going until no route with that @id remains, so even ids duplicated via raw add_route are fully cleared:

add_route({"@id": host, "handle": [proxy_handler('localhost:9999')]})
del_id(host)
test_eq(has_id(host), False)

On-demand TLS

On-demand TLS makes caddy obtain a certificate at handshake time for domains it has never seen – the mechanism behind letting customers point their own domains at your app. To stop strangers minting certs through your server, caddy first asks your endpoint (GET {endpoint}?domain={domain}) and only proceeds on a 200 response. tools/testverify.py in this repo is a minimal fasthtml example of such an endpoint.

add_on_demand_tls sets the permission endpoint and ensures one automation policy with on_demand: true exists, appending it after any existing policies (it never replaces them). One caddy rule to know: the on-demand policy is a catch-all (no subjects), and caddy rejects a config where a catch-all follows another catch-all – so any earlier policies must be subject-restricted, which is why production setups pass subjects to setup_caddy.


source

add_on_demand_tls

def add_on_demand_tls(
    endpoint
):

Enable on-demand TLS, asking endpoint for permission before each cert is issued

setup_caddy(cf_token, subjects=["*.example.com", "example.com"])
add_on_demand_tls("http://localhost:5431/verifydom")

Locally we can demonstrate the exact production shape with a subject-restricted internal policy standing in for the ACME one – the on-demand policy lands after it, and re-running adds nothing:

reset()
init_path(automation_path)
pcfg([{'subjects': ['*.example.com'], 'issuers': [{'module': 'internal'}]}], automation_path+'/policies')
add_on_demand_tls("http://localhost:5431/verifydom")
pols = gcfg(automation_path+'/policies')
test_eq(pols[1].on_demand, True)
add_on_demand_tls("http://localhost:5431/verifydom")
test_eq(len(gcfg(automation_path+'/policies')), 2)

Wildcard subdomains

For many subdomains of one domain, a wildcard route is better than per-host routes: caddy can then use a single wildcard certificate (*.{domain}), and subdomains route through a subroute handler inside it. Create the wildcard once, then add each subdomain with add_sub_reverse_proxy (or a custom route with add_sub_route).

add_wildcard_route is a no-op if the wildcard already exists – replacing it would destroy the subroutes inside – so re-running a setup script keeps existing subdomains intact.


source

add_wildcard_route

def add_wildcard_route(
    domain
):

Add a route matching *.{domain} (tagged wildcard-{domain}) for subroutes; no-op if it already exists

reset()
setup_caddy(local=True)
add_wildcard_route('something.example.com')
add_wildcard_route('something.example.com')
test_eq(sum(1 for o in gcfg(rts_path) if o['@id']=='wildcard-something.example.com'), 1)

add_sub_route is how routes get inside the wildcard: it appends to the subroute list, replacing any existing route with the same @id. This is also the extension point for custom handlers (our production config pushes an encode + custom router + reverse_proxy chain through it).


source

add_sub_route

def add_sub_route(
    domain, route
):

Append route to domain’s wildcard subroute list, replacing any existing route with the same @id


source

add_sub_reverse_proxy

def add_sub_reverse_proxy(
    domain, # Domain with an existing wildcard route
    subdomain, # Subdomain to proxy (tagged `{subdomain}.{domain}`)
    port:Union, # A single port or list of ports
    host:str='localhost', # Host the upstream(s) listen on
    st_delay:str='1m', # Keep streaming connections open this long across config reloads (None to disable)
    encode:bool=True, # Compress responses?
):

Create (or replace) a reverse proxy to {subdomain}.{domain} inside domain’s wildcard route

add_sub_reverse_proxy('something.example.com', 'foo', 5001)
gid('foo.something.example.com')
{ '@id': 'foo.something.example.com',
  'handle': [{'encodings': {'gzip': {'level': 1}, 'zstd': {'level': 'fastest'}}, 'handler': 'encode', 'prefer': ['zstd', 'gzip']}, {'handler': 'reverse_proxy', 'stream_close_delay': '1m', 'upstreams': [{'dial': 'localhost:5001'}]}],
  'match': [{'host': ['foo.something.example.com']}]}

Re-adding the same subdomain replaces its route rather than duplicating it, multiple ports become multiple upstreams, and del_id deletes it as usual:

add_sub_reverse_proxy('something.example.com', 'foo', [5002, 5003])
subs = gid('wildcard-something.example.com').handle[0].routes
test_eq(len(subs), 1)
test_eq([u.dial for u in subs[0].handle[1].upstreams], ['localhost:5002', 'localhost:5003'])
del_id('foo.something.example.com')
test_eq(has_id('foo.something.example.com'), False)