from fastcore.test import *API
How it works
fastcaddy reads and changes Caddy’s JSON configuration through the admin API. It does not edit Caddyfiles. The default API address is http://localhost:2019.
You can configure Caddy at two levels:
- Read or change configuration by path with
gcfg/pcfg, or by@idwithgid/pid. - Helpers such as
setup_caddyandadd_reverse_proxyconfigure TLS and routes. Each helper documents what it replaces or leaves unchanged.
Arrange how to restore the configuration after a Caddy restart. Our production apps run a setup script on each deploy. The script calls reset(), then adds the required configuration. reset() erases the entire configuration, including anything added outside the script.
Route helpers use these @id values to find existing routes:
- A reverse proxy uses its hostname.
- A wildcard route uses
wildcard-{domain}. - A subdomain proxy uses
{subdomain}.{domain}.
Use del_id to remove a route by ID.
Admin API primitives
/config/ addresses configuration by path. /id/ finds an object by its @id value anywhere in the configuration.
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('jph.answer.ai')'http://localhost:2019/id/jph.answer.ai/'
get_path('/apps/tls/automation/policies')'http://localhost:2019/config/apps/tls/automation/policies/'
Connection errors include the admin API address. HTTP errors include Caddy’s error detail. _req adds these notes to the original exceptions before raising them.
pid
def pid(
d, path:str='/', method:str='post'
):Put/post config d to the object whose @id matches path
method selects a Caddy admin API operation:
postsets a value, or appends to an array.putcreates a value and any missing parent paths. An existing object key causes HTTP 409.patchreplaces an existing value.deleteremoves a value.
Reading a missing key under an existing object returns null. Traversing through a missing key fails. has_path treats None as absence.
Posting to / replaces the entire configuration. reset posts an empty object. nested_setcfg also writes the entire document after modifying the requested path.
reset leaves an empty configuration.
reset()
gcfg(){}Config tree helpers
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('/apps/tls/automation/policies')['apps', 'tls', 'automation', 'policies']
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
init_path creates missing parents and leaves an existing path unchanged.
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’s automation policies control certificate issuance. Two helpers configure the first policy under /apps/tls/automation:
add_acme_configuses ACME with Cloudflare DNS challenges.add_tls_internal_configuses Caddy’s internal CA for local development.
Both leave existing automation configuration unchanged. Neither modifies configuration 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
Configure ACME with a valid Cloudflare API token:
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'}]}]}Check that add_acme_config leaves the internal CA policy unchanged. After a reset, it requires a token to create a new policy.
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
The package includes Caddy’s JSON schema. search_schema finds matching keys and values. Pass a returned path to get_schema to read that node. Paths represent list indices as [n].
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')]
Check returned paths, including list indices, with get_schema.
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
The route helpers use an HTTP server named srv0. init_routes creates it on ports 80 and 443 when no servers exist. Routes go in /apps/http/servers/srv0/routes.
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 configures TLS automation and initializes srv0. It uses ACME by default or the internal CA with local=True. install_trust controls installation of the local CA in the system trust store.
setup_caddy(cf_token, subjects=['*.example.com', 'example.com'])Use the internal CA for the remaining local examples:
setup_caddy(local=True)
gcfg(srvs_path){'srv0': {'listen': [':80', ':443'], 'protocols': ['h1', 'h2'], 'routes': []}}A route’s handle list contains its handlers. encode_handler builds response compression configuration. proxy_handler builds reverse-proxy configuration. You can combine them with other handlers in a custom route.
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_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 add_reverse_proxy again for the same host replaces its route without 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 every object with the ID, including duplicates created through add_route.
add_route({"@id": host, "handle": [proxy_handler('localhost:9999')]})
del_id(host)
test_eq(has_id(host), False)On-demand TLS
Caddy obtains certificates during the TLS handshake for customers who point their own domains at your app.
The permission endpoint lets you prevent strangers from obtaining certificates through your server. Caddy sends it GET {endpoint}?domain={domain} before issuing a certificate. Return HTTP 200 to authorize the domain. tools/testverify.py provides a minimal FastHTML example.
add_on_demand_tls sets the permission endpoint. It appends an on_demand: true policy if none exists, preserving existing policies.
The appended policy has no subjects restriction. Caddy rejects a configuration with two unrestricted policies. Restrict earlier policies with subjects, as in this setup:
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")Use a subject-restricted internal CA policy for this local example. Check that the on-demand policy follows it and that a second call adds no duplicate.
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
A wildcard route groups subdomains under a *.{domain} host matcher. Caddy can use one wildcard certificate for these hosts. Each subdomain has a route inside the wildcard’s subroute handler.
Create the wildcard with add_wildcard_route, then add subdomains with add_sub_reverse_proxy or custom routes with add_sub_route. Calling add_wildcard_route again leaves the route and its subroutes unchanged.
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 appends a route inside the wildcard, replacing any existing route with its @id. Use it to configure custom handlers. Our production configuration combines encode, a custom router and reverse_proxy in one route.
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[str, int, Sequence], # 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']}]}Replace the subdomain’s route with two upstream ports, then remove it with del_id.
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)