Skip to content

Commit 6391ff9

Browse files
committed
proxy with caddy, crowdsec and cloudflared
1 parent 7b224ba commit 6391ff9

9 files changed

Lines changed: 1744 additions & 368 deletions

File tree

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,59 @@ cfg
395395
```
396396

397397
{'FOO': 'bar', 'DATABASE_URL': 'postgresql://...'}
398+
399+
## Reverse Proxy
400+
401+
[`caddy()`](https://Karthik777.github.io/dockeasy/proxy.html#caddy)
402+
generates a Caddyfile as a Python object — chainable, printable,
403+
saveable.
404+
[`caddy_svc()`](https://Karthik777.github.io/dockeasy/proxy.html#caddy_svc)
405+
writes the file and hands back service kwargs you can drop straight into
406+
[`Compose`](https://Karthik777.github.io/dockeasy/core.html#compose).
407+
408+
``` python
409+
# Minimal: auto-TLS via Let's Encrypt
410+
print(caddy('myapp.example.com'))
411+
```
412+
413+
``` python
414+
# DNS-01 wildcard cert — works even when port 80 is closed
415+
print(caddy('myapp.example.com', dns='cloudflare', email='me@example.com'))
416+
```
417+
418+
### Zero open ports with Cloudflare Tunnel
419+
420+
[`caddy_svc()`](https://Karthik777.github.io/dockeasy/proxy.html#caddy_svc) +
421+
[`cloudflared_svc()`](https://Karthik777.github.io/dockeasy/proxy.html#cloudflared_svc)
422+
→ a production stack with no inbound firewall rules at all.
423+
Add `crowdsec=True` to either call to layer in IP reputation blocking.
424+
425+
``` python
426+
import tempfile
427+
tmp = tempfile.mkdtemp()
428+
429+
dc = (Compose()
430+
.svc('app', build='.', networks=['web'], restart='unless-stopped')
431+
.svc('caddy', **caddy_svc('myapp.example.com', cloudflared=True, conf=f'{tmp}/Caddyfile'))
432+
.svc('cloudflared', **cloudflared_svc())
433+
.network('web').volume('caddy_data').volume('caddy_config'))
434+
435+
print(dc)
436+
```
437+
438+
## Next steps
439+
440+
The notebooks are executable specs — worth reading before shipping.
441+
442+
- **`nbs/01_proxy.ipynb`** — live integration test: boots a FastHTML
443+
app, tunnels it via Cloudflare, and asserts it’s reachable over the
444+
internet. Shows the full
445+
[`caddy_svc`](https://Karthik777.github.io/dockeasy/proxy.html#caddy_svc)
446+
/
447+
[`cloudflared_svc`](https://Karthik777.github.io/dockeasy/proxy.html#cloudflared_svc)
448+
/
449+
[`crowdsec`](https://Karthik777.github.io/dockeasy/proxy.html#crowdsec)
450+
surface area with every option.
451+
- **`nbs/00_core.ipynb`** — complete Dockerfile and Compose API,
452+
including multi-stage builds, framework builders, and container
453+
management.

dockeasy/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
__version__ = "0.0.1"
22
from .core import *
3+
from .proxy import *

dockeasy/_modidx.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,28 @@
104104
'dockeasy.core.secrets': ('core.html#secrets', 'dockeasy/core.py'),
105105
'dockeasy.core.service': ('core.html#service', 'dockeasy/core.py'),
106106
'dockeasy.core.stop': ('core.html#stop', 'dockeasy/core.py'),
107-
'dockeasy.core.test': ('core.html#test', 'dockeasy/core.py')}}}
107+
'dockeasy.core.test': ('core.html#test', 'dockeasy/core.py')},
108+
'dockeasy.proxy': { 'dockeasy.proxy.Caddyfile': ('proxy.html#caddyfile', 'dockeasy/proxy.py'),
109+
'dockeasy.proxy.Caddyfile.__init__': ('proxy.html#caddyfile.__init__', 'dockeasy/proxy.py'),
110+
'dockeasy.proxy.Caddyfile.__repr__': ('proxy.html#caddyfile.__repr__', 'dockeasy/proxy.py'),
111+
'dockeasy.proxy.Caddyfile.__str__': ('proxy.html#caddyfile.__str__', 'dockeasy/proxy.py'),
112+
'dockeasy.proxy.Caddyfile._add': ('proxy.html#caddyfile._add', 'dockeasy/proxy.py'),
113+
'dockeasy.proxy.Caddyfile._get': ('proxy.html#caddyfile._get', 'dockeasy/proxy.py'),
114+
'dockeasy.proxy.Caddyfile._has': ('proxy.html#caddyfile._has', 'dockeasy/proxy.py'),
115+
'dockeasy.proxy.Caddyfile._new': ('proxy.html#caddyfile._new', 'dockeasy/proxy.py'),
116+
'dockeasy.proxy.Caddyfile.acme_dns': ('proxy.html#caddyfile.acme_dns', 'dockeasy/proxy.py'),
117+
'dockeasy.proxy.Caddyfile.cloudflared': ('proxy.html#caddyfile.cloudflared', 'dockeasy/proxy.py'),
118+
'dockeasy.proxy.Caddyfile.crowdsec': ('proxy.html#caddyfile.crowdsec', 'dockeasy/proxy.py'),
119+
'dockeasy.proxy.Caddyfile.email': ('proxy.html#caddyfile.email', 'dockeasy/proxy.py'),
120+
'dockeasy.proxy.Caddyfile.encode': ('proxy.html#caddyfile.encode', 'dockeasy/proxy.py'),
121+
'dockeasy.proxy.Caddyfile.log': ('proxy.html#caddyfile.log', 'dockeasy/proxy.py'),
122+
'dockeasy.proxy.Caddyfile.max_body': ('proxy.html#caddyfile.max_body', 'dockeasy/proxy.py'),
123+
'dockeasy.proxy.Caddyfile.rate_limit': ('proxy.html#caddyfile.rate_limit', 'dockeasy/proxy.py'),
124+
'dockeasy.proxy.Caddyfile.save': ('proxy.html#caddyfile.save', 'dockeasy/proxy.py'),
125+
'dockeasy.proxy.Caddyfile.spa': ('proxy.html#caddyfile.spa', 'dockeasy/proxy.py'),
126+
'dockeasy.proxy._caddy_img': ('proxy.html#_caddy_img', 'dockeasy/proxy.py'),
127+
'dockeasy.proxy.caddy': ('proxy.html#caddy', 'dockeasy/proxy.py'),
128+
'dockeasy.proxy.caddy_api': ('proxy.html#caddy_api', 'dockeasy/proxy.py'),
129+
'dockeasy.proxy.caddy_svc': ('proxy.html#caddy_svc', 'dockeasy/proxy.py'),
130+
'dockeasy.proxy.cloudflared_svc': ('proxy.html#cloudflared_svc', 'dockeasy/proxy.py'),
131+
'dockeasy.proxy.crowdsec': ('proxy.html#crowdsec', 'dockeasy/proxy.py')}}}

dockeasy/core.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -136,16 +136,18 @@ def _compose_cmd():
136136
except Exception: return 'docker', 'compose'
137137

138138
def _clean_cfg():
139-
'Create a docker config dir with credential helpers stripped'
139+
'Create a docker config dir with credential helpers stripped, symlinking contexts and cli-plugins'
140140
src = Path(os.environ.get('DOCKER_CONFIG', Path.home()/'.docker'))
141141
dst = Path.home()/'.fastops'/'config'
142142
cfgf = dst/'config.json'
143-
if cfgf.exists(): return str(dst)
144-
cfg = src.joinpath('config.json').read_json() if (src/'config.json').exists() else {}
145-
cfg.pop('credsStore', None); cfg.pop('credHelpers', None)
146-
cfgf.write_json(cfg)
147-
ctx_src, ctx_dst = src/'contexts', dst/'contexts'
148-
if ctx_src.exists() and not ctx_dst.exists(): ctx_dst.symlink_to(ctx_src)
143+
if not cfgf.exists():
144+
dst.mkdir(parents=True, exist_ok=True)
145+
cfg = src.joinpath('config.json').read_json() if (src/'config.json').exists() else {}
146+
cfg.pop('credsStore', None); cfg.pop('credHelpers', None)
147+
cfgf.write_json(cfg)
148+
for name in ('contexts', 'cli-plugins'):
149+
link, target = dst/name, src/name
150+
if target.exists() and not link.exists(): link.symlink_to(target)
149151
return str(dst)
150152

151153
class Docker(Cli):
@@ -166,10 +168,20 @@ def compose(self, *a, f='docker-compose.yml'):
166168

167169
# %% ../nbs/00_core.ipynb #ae10280a7aae03af
168170
@patch
169-
def build(df:Dockerfile, tag:str=None, path:str='.', rm=True, no_creds=False, fn='Dockerfile'):
170-
'Build image from Dockerfile. path is the build context directory.'
171+
def build(df:Dockerfile, tag:str=None, path:str='.', no_creds=False, fn='Dockerfile'):
172+
'Build image from Dockerfile via docker compose build (uses daemon BuildKit, no buildx required).'
173+
import subprocess, tempfile
171174
df.save(Path(path) / fn)
172-
Docker(no_creds=no_creds).build(str(path).rstrip('/')+'/', t=tag, rm=rm)
175+
svc = {'build': {'context': str(Path(path).resolve())}}
176+
if tag: svc['image'] = tag
177+
with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f:
178+
f.write(yaml.dump({'services': {'img': svc}})); tmp = f.name
179+
try:
180+
pre = ['--config', _clean_cfg()] if no_creds else []
181+
res = subprocess.run(['docker'] + pre + ['compose', '-f', tmp, 'build'], capture_output=True)
182+
if res.returncode: raise IOError((res.stdout + b' ;; ' + res.stderr).decode().strip())
183+
finally:
184+
Path(tmp).unlink(missing_ok=True)
173185
return tag
174186

175187
def test(img_or_tag:str, cmd):
@@ -261,11 +273,11 @@ def __repr__(self): return str(self)
261273

262274
# %% ../nbs/00_core.ipynb #f56e4e9f534ac2ca
263275
@patch
264-
def inst_uv(self:Dockerfile):
265-
'Single-stage uv install: copy uv binary and sync deps'
266-
return (self.copy('/uv', '/usr/local/bin/uv', from_='ghcr.io/astral-sh/uv:latest')
267-
.copy('pyproject.toml', '.').copy('uv.lock', '.')
268-
.run_mount('uv sync --frozen --no-dev', target='/root/.cache/uv'))
276+
def inst_uv(self:Dockerfile, req=False, wd='/app'):
277+
'Single-stage uv install: copy uv binary and sync deps. req=True uses requirements.txt instead of pyproject.toml'
278+
self = self.copy('/uv', '/usr/local/bin/uv', from_='ghcr.io/astral-sh/uv:latest')
279+
if req: return self.copy('requirements.txt', '.').run('uv pip install --system -r requirements.txt')
280+
return self.copy('pyproject.toml', '.').run('uv sync --no-dev').env('PATH', f'{wd}/.venv/bin:$PATH')
269281

270282
@patch
271283
def with_uv(self:Dockerfile, uv_image, image, workdir):
@@ -284,11 +296,11 @@ def packages(self:Dockerfile, *pkgs):
284296

285297
# %% ../nbs/00_core.ipynb #e17b41b26d006536
286298
def python_app(port=8000, cmd=None, im='python:3.13-slim', wd='/app', pkgs=None,
287-
vols=None, multistage=True, uv_image='ghcr.io/astral-sh/uv:python3.13-bookworm', healthcheck=None):
288-
'Python app Dockerfile. multistage=True (default): uv-builder → slim. False: single-stage with uv binary copy.'
299+
vols=None, multistage=True, uv_image='ghcr.io/astral-sh/uv:python3.13-bookworm', healthcheck=None, req=False):
300+
'Python app Dockerfile. multistage=True (default): uv-builder → slim. False: single-stage with uv binary copy. req=True: use requirements.txt instead of pyproject.toml (forces multistage=False).'
289301
if multistage:
290-
df = Dockerfile().with_uv(uv_image, im, wd).packages(*listify(pkgs)).copy(wd, wd, from_='builder').env('PATH', f'{wd}/.venv/bin:$PATH')
291-
else: df = Dockerfile().from_(im).workdir(wd).packages(*listify(pkgs)).inst_uv().copy('.', '.')
302+
df = Dockerfile().with_uv(uv_image, im, wd).copy(wd, wd, from_='builder').env('PATH', f'{wd}/.venv/bin:$PATH').packages(*listify(pkgs))
303+
else: df = Dockerfile().from_(im).workdir(wd).packages(*listify(pkgs)).inst_uv(req=req, wd=wd).copy('.', '.')
292304
if vols: df = df.run('mkdir -p ' + ' '.join(listify(vols)))
293305
if healthcheck: df = df.healthcheck(f'curl -f http://localhost:{port}{healthcheck}', i='30s', t='5s', r='3')
294306
_cmd = cmd or ['python', 'main.py']
@@ -301,8 +313,8 @@ def fastapi_react(port=8000, node_version='20', frontend_dir='frontend', build_d
301313
df = (Dockerfile().from_(f'node:{node_version}-slim', as_='frontend')
302314
.workdir('/build').copy(f'{frontend_dir}/package*.json', '.')
303315
.run('npm ci').copy(frontend_dir, '.').run('npm run build'))
304-
if multistage: df = df.with_uv(uv_image, image, '/app').packages(*listify(pkgs)).copy('/app', '/app', from_='builder').env('PATH', '/app/.venv/bin:$PATH')
305-
else: df = df.from_(image).workdir('/app').packages(*listify(pkgs)).inst_uv().copy('.', '.')
316+
if multistage: df = df.with_uv(uv_image, image, '/app').copy('/app', '/app', from_='builder').env('PATH', '/app/.venv/bin:$PATH').packages(*listify(pkgs))
317+
else: df = df.from_(image).workdir('/app').packages(*listify(pkgs)).inst_uv(wd='/app').copy('.', '.')
306318
df = df.copy(f'/build/{build_dir}', '/app/static', from_='frontend')
307319
if healthcheck: df = df.healthcheck(f'curl -f http://localhost:{port}{healthcheck}', i='30s', t='5s', r='3')
308320
return df.expose(port).cmd(['uvicorn', 'main:app', '--host', '0.0.0.0', f'--port={port}'])
@@ -335,7 +347,7 @@ def node_app(port=3000, node_version='20', cmd=None, build_cmd='npm run build',
335347
.expose(port).cmd(cmd or ['serve', '-s', '.', '-l', str(port)]))
336348
return df.expose(port).cmd(cmd or ['node', 'index.js'])
337349

338-
fasthtml_app = bind(python_app, port=5001, cmd=['python', 'app.py'])
350+
fasthtml_app = bind(python_app, port=5001, cmd=['python', 'app.py'], multistage=False)
339351

340352
def detect_app(path='.', multistage=True, **kw):
341353
'A naive project type detector from path and return the appropriate Dockerfile. for **kw lookup other app builders'
@@ -349,7 +361,8 @@ def detect_app(path='.', multistage=True, **kw):
349361
if has('package.json') and has_py: return fastapi_react(multistage=multistage, **kw)
350362
if has('package.json'): return node_app(**kw)
351363
if pyp and 'python-fasthtml' in read('pyproject.toml'): return fasthtml_app(multistage=multistage, **kw)
352-
if has_py: return python_app(multistage=multistage, **kw)
364+
if pyp: return python_app(multistage=multistage, **kw)
365+
if req: return python_app(multistage=False, req=True, **kw)
353366
raise ValueError(f'Cannot detect project type in {path!r}')
354367

355368
# %% ../nbs/00_core.ipynb #5f5a4229dae3d2c4

0 commit comments

Comments
 (0)