caddy

Caddy reverse proxy, CrowdSec security, and Cloudflare tunnel support

Caddyfile generation

caddyfile() generates the Caddyfile text. caddy() writes it and returns service kwargs for Compose.svc().


source

caddyfile


def caddyfile(
    domain, app:str='app', port:int=5001, dns:NoneType=None, email:NoneType=None, crowdsec:bool=False,
    cloudflared:bool=False
):

Minimal Caddyfile for reverse-proxying app:port from domain

# Minimal
cf = caddyfile('myapp.example.com', port=5001)
assert 'myapp.example.com {' in cf
assert '    reverse_proxy app:5001' in cf
assert cf.count('{') == 1
print(cf)
# With Cloudflare DNS
cf = caddyfile('myapp.example.com', port=5001, dns='cloudflare', email='me@example.com')
assert 'email me@example.com' in cf
assert 'acme_dns cloudflare {$CLOUDFLARE_API_TOKEN}' in cf
print(cf)
# With CrowdSec
cf = caddyfile('myapp.example.com', port=5001, crowdsec=True)
assert 'api_url http://crowdsec:8080' in cf
assert '    crowdsec\n' in cf
assert 'api_key {$CROWDSEC_API_KEY}' in cf
print(cf)
# Cloudflared mode: HTTP prefix
cf = caddyfile('myapp.example.com', port=5001, cloudflared=True)
assert cf.startswith('http://myapp.example.com {')
assert 'reverse_proxy app:5001' in cf
print(cf)

Services


source

caddy


def caddy(
    domain, app:str='app', port:int=5001, dns:NoneType=None, email:NoneType=None, crowdsec:bool=False,
    cloudflared:bool=False, conf:str='Caddyfile', kw:VAR_KEYWORD
):

Write Caddyfile and return Caddy service kwargs for Compose.svc()

import tempfile
with tempfile.TemporaryDirectory() as tmp:
    kw = caddy('myapp.example.com', port=5001, conf=f'{tmp}/Caddyfile')
    assert kw['image'] == 'caddy:2'
    assert kw['ports'] == ['80:80', '443:443', '443:443/udp']
    assert kw['depends_on'] == ['app']
    assert 'myapp.example.com {' in Path(f'{tmp}/Caddyfile').read_text()
    print('caddy() basic OK')

with tempfile.TemporaryDirectory() as tmp:
    kw = caddy('myapp.example.com', dns='cloudflare', conf=f'{tmp}/Caddyfile')
    assert kw['image'] == 'serfriz/caddy-cloudflare:latest'
    assert kw['env'] == {'CLOUDFLARE_API_TOKEN': '${CLOUDFLARE_API_TOKEN}'}
    print('caddy() cloudflare OK')

with tempfile.TemporaryDirectory() as tmp:
    kw = caddy('myapp.example.com', crowdsec=True, conf=f'{tmp}/Caddyfile')
    assert kw['image'] == 'serfriz/caddy-crowdsec:latest'
    assert 'CROWDSEC_API_KEY' in kw['env']
    print('caddy() crowdsec OK')

with tempfile.TemporaryDirectory() as tmp:
    kw = caddy('myapp.example.com', cloudflared=True, conf=f'{tmp}/Caddyfile')
    assert kw['ports'] is None
    assert Path(f'{tmp}/Caddyfile').read_text().startswith('http://myapp.example.com {')
    print('caddy() cloudflared OK: no ports')

with tempfile.TemporaryDirectory() as tmp:
    kw = caddy('myapp.example.com', crowdsec=True, dns='cloudflare', conf=f'{tmp}/Caddyfile')
    assert kw['image'] == 'ghcr.io/buildplan/csdp-caddy:latest'
    print('caddy() crowdsec+cloudflare OK')
caddy() basic OK
caddy() cloudflare OK
caddy() crowdsec OK
caddy() cloudflared OK: no ports
caddy() crowdsec+cloudflare OK

source

cloudflared_svc


def cloudflared_svc(
    token_env:str='CF_TUNNEL_TOKEN', kw:VAR_KEYWORD
):

Cloudflare tunnel service kwargs for Compose.svc()

kw = cloudflared_svc()
assert kw['image'] == 'cloudflare/cloudflared:latest'
assert kw['command'] == 'tunnel --no-autoupdate run'
assert kw['env'] == {'TUNNEL_TOKEN': '${CF_TUNNEL_TOKEN}'}
print('cloudflared_svc() OK')

source

crowdsec


def crowdsec(
    collections:NoneType=None, bouncer_key_env:str='CROWDSEC_BOUNCER_KEY', kw:VAR_KEYWORD
):

CrowdSec agent service kwargs for Compose.svc()

kw = crowdsec()
assert kw['image'] == 'crowdsecurity/crowdsec:latest'
assert 'crowdsecurity/caddy' in kw['env']['COLLECTIONS']
assert kw['env']['BOUNCER_KEY_caddy'] == '${CROWDSEC_BOUNCER_KEY}'
assert 'crowdsec-db' in kw['volumes']
print('crowdsec() OK')

kw2 = crowdsec(collections=['crowdsecurity/linux', 'crowdsecurity/nginx'])
assert 'crowdsecurity/nginx' in kw2['env']['COLLECTIONS']
print('crowdsec() custom collections OK')

Example: FastHTML app with Caddy

Minimal stacks — run any with dc.save('docker-compose.yml') then docker compose up -d.

import tempfile
from fastops import Compose

tmp = tempfile.mkdtemp()

# Stack A: Direct (Caddy auto-TLS, ports 80+443 open)
dc = (Compose()
    .svc('app', build='.', networks=['web'], restart='unless-stopped')
    .svc('caddy', **caddy('myapp.example.com', port=5001, conf=f'{tmp}/Caddyfile'))
    .network('web').volume('caddy_data').volume('caddy_config'))

d = dc.to_dict()
assert d['services']['caddy']['image'] == 'caddy:2'
assert '80:80' in d['services']['caddy']['ports']
print('=== Stack A: Direct (Caddy auto-TLS) ===')
print(dc)
=== Stack A: Direct (Caddy auto-TLS) ===
services:
  app:
    build: .
    networks:
    - web
    restart: unless-stopped
  caddy:
    image: caddy:2
    depends_on:
    - app
    ports:
    - 80:80
    - 443:443
    - 443:443/udp
    volumes:
    - .//tmp/tmpaxsor1dz/Caddyfile:/etc/caddy/Caddyfile
    - caddy_data:/data
    - caddy_config:/config
    networks:
    - web
    restart: unless-stopped
networks:
  web: null
volumes:
  caddy_data: null
  caddy_config: null
import tempfile
from fastops import Compose

tmp = tempfile.mkdtemp()

# Stack B: cloudflared tunnel (zero open ports)
dc = (Compose()
    .svc('app', build='.', networks=['web'], restart='unless-stopped')
    .svc('caddy', **caddy('myapp.example.com', port=5001, cloudflared=True, conf=f'{tmp}/Caddyfile'))
    .svc('cloudflared', **cloudflared_svc(), networks=['web'])
    .network('web').volume('caddy_data').volume('caddy_config'))

d = dc.to_dict()
assert 'ports' not in d['services']['caddy']
assert d['services']['cloudflared']['image'] == 'cloudflare/cloudflared:latest'
print('=== Stack B: Cloudflared (zero open ports) ===')
print(dc)
=== Stack B: Cloudflared (zero open ports) ===
services:
  app:
    build: .
    networks:
    - web
    restart: unless-stopped
  caddy:
    image: caddy:2
    depends_on:
    - app
    volumes:
    - .//tmp/tmp1x3wkkm4/Caddyfile:/etc/caddy/Caddyfile
    - caddy_data:/data
    - caddy_config:/config
    networks:
    - web
    restart: unless-stopped
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run
    environment:
    - TUNNEL_TOKEN=${CF_TUNNEL_TOKEN}
    restart: unless-stopped
    networks:
    - web
networks:
  web: null
volumes:
  caddy_data: null
  caddy_config: null
import tempfile
from fastops import Compose

tmp = tempfile.mkdtemp()

# Stack C: CrowdSec + cloudflared (full security)
dc = (Compose()
    .svc('app', build='.', networks=['web'], restart='unless-stopped')
    .svc('caddy', **caddy('myapp.example.com', port=5001, crowdsec=True, cloudflared=True, conf=f'{tmp}/Caddyfile'))
    .svc('crowdsec', **crowdsec())
    .svc('cloudflared', **cloudflared_svc(), networks=['web'])
    .network('web')
    .volume('caddy_data').volume('caddy_config')
    .volume('crowdsec-db').volume('crowdsec-config'))

d = dc.to_dict()
assert d['services']['caddy']['image'] == 'serfriz/caddy-crowdsec:latest'
assert d['services']['crowdsec']['image'] == 'crowdsecurity/crowdsec:latest'
print('=== Stack C: CrowdSec + cloudflared ===')
print(dc)
=== Stack C: CrowdSec + cloudflared ===
services:
  app:
    build: .
    networks:
    - web
    restart: unless-stopped
  caddy:
    image: serfriz/caddy-crowdsec:latest
    depends_on:
    - app
    environment:
    - CROWDSEC_API_KEY=${CROWDSEC_API_KEY}
    volumes:
    - .//tmp/tmpcvibth_w/Caddyfile:/etc/caddy/Caddyfile
    - caddy_data:/data
    - caddy_config:/config
    networks:
    - web
    restart: unless-stopped
  crowdsec:
    image: crowdsecurity/crowdsec:latest
    environment:
    - COLLECTIONS=crowdsecurity/linux crowdsecurity/caddy crowdsecurity/http-cve
    - BOUNCER_KEY_caddy=${CROWDSEC_BOUNCER_KEY}
    volumes:
    - crowdsec-db:/var/lib/crowdsec/data
    - crowdsec-config:/etc/crowdsec
    networks:
    - web
    restart: unless-stopped
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run
    environment:
    - TUNNEL_TOKEN=${CF_TUNNEL_TOKEN}
    restart: unless-stopped
    networks:
    - web
networks:
  web: null
volumes:
  caddy_data: null
  caddy_config: null
  crowdsec-db: null
  crowdsec-config: null