API

Configure Caddy through its admin API, with helpers for TLS and reverse-proxy routes

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 @id with gid/pid.
  • Helpers such as setup_caddy and add_reverse_proxy configure 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.

from fastcore.test import *

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.


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/'

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.


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 selects a Caddy admin API operation:

  • post sets a value, or appends to an array.
  • put creates a value and any missing parent paths. An existing object key causes HTTP 409.
  • patch replaces an existing value.
  • delete removes 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.


source

reset

def reset():

Erase the entire caddy config

reset leaves an empty configuration.

reset()
gcfg()
{}

Config tree helpers


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

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:

Both leave existing automation configuration unchanged. Neither modifies configuration 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

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].


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')]

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.


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 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.


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 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:


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")

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.


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 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.


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[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)