from fastcore.test import *API
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:
- Primitives map 1:1 to admin API operations:
gcfg/pcfgget and set config subtrees by path,gid/pid/del_iddo the same for objects tagged with an@id,has_path/has_idtest for existence, andreseterases everything. - Recipes (
setup_caddy,add_reverse_proxy,add_wildcard_route,add_sub_reverse_proxy,add_on_demand_tls) each own one documented subtree of the config, and are safe to re-run: they replace or skip their own subtree and never touch anything else.
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.
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.
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/'
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.
gid
def gid(
path:str='/'
):Get the config object whose @id matches path
has_id
def has_id(
id
):Check if id is set up
gcfg
def gcfg(
path:str='/'
):Get the config at path
has_path
def has_path(
path
):Check if any config exists at path
pid
def pid(
d, path:str='/', method:str='post'
):Put/post config d to the object whose @id matches path
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.
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.
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'}}}}}
path2keys
def path2keys(
path
):Split path by ‘/’ into a list
path2keys('/apps/tls/automation/policies')['apps', 'tls', 'automation', 'policies']
keys2path
def keys2path(
*keys
):Join keys into a ‘/’ separated path
keys2path('apps', 'tls', 'automation', 'policies')'/apps/tls/automation/policies'
nested_setcfg
def nested_setcfg(
value, *keys
):Set value at the path keys in the live caddy config
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')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'}}}}
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
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).
caddy_docs
def caddy_docs():The caddy JSON schema, loaded (once) from the bundled caddy_schema.json
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)
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.
init_routes
def init_routes():Create the basic http server config (srv0 on ports 80/443), if no servers exist yet
setup_pki_trust
def setup_pki_trust(
install_trust
):Configure PKI certificate authority trust installation
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:
encode_handler
def encode_handler():An encode handler that compresses responses with zstd or gzip
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
add_route
def add_route(
route
):Append route to srv0’s route list
del_id
def del_id(
id
):Delete every config object whose @id matches id (e.g. a host)
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.
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.
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).
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
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)