# 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)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().
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
# 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
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
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')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