d = service(image='nginx', ports={80: 80})
assert d['image'] == 'nginx'
assert d['ports'] == ['80:80']compose
Service
service is a function that returns a plain dict matching the docker-compose service spec. It handles the conversion from Python-friendly args (dict ports, dict env) to compose format (list ports, environment key).
service
def service(
image:NoneType=None, build:NoneType=None, ports:NoneType=None, env:NoneType=None, volumes:NoneType=None,
depends_on:NoneType=None, command:NoneType=None, kw:VAR_KEYWORD
):
Create a docker-compose service dict
dict2str
def dict2str(
d:dict, sep:str=':'
):
d = service(image='postgres:15', env={'POSTGRES_PASSWORD': 'secret'}, volumes={'pgdata': '/var/lib/postgresql/data'})
assert d['environment'] == ['POSTGRES_PASSWORD=secret']
assert d['volumes'] == ['pgdata:/var/lib/postgresql/data']Compose
The Compose class provides a fluent builder for docker-compose files. Chain .svc(), .network(), and .volume() calls, then render with str() or save to disk.
Services are stored as plain dicts. to_dict() just assembles the top-level compose structure.
DockerCompose
def DockerCompose(
path:str='docker-compose.yml'
):
Wrap docker compose CLI: getattr dispatches subcommands, kwargs become flags
Compose
def Compose(
items:NoneType=None, rest:VAR_POSITIONAL, use_list:bool=False, match:NoneType=None
):
Fluent builder for docker-compose.yml files
dc = (Compose()
.svc('web', image='nginx', ports={80: 80})
.svc('db', image='postgres:15', env={'POSTGRES_PASSWORD': 'secret'}))
d = dc.to_dict()
assert 'web' in d['services']
assert 'db' in d['services']
assert d['services']['web']['image'] == 'nginx'
print(dc)dc = (Compose()
.svc('web', image='nginx', ports={80: 80})
.svc('redis', image='redis:alpine')
.svc('db', image='postgres:15', env={'POSTGRES_PASSWORD': 'secret'}, volumes={'pgdata': '/var/lib/postgresql/data'})
.network('backend')
.volume('pgdata'))
d = dc.to_dict()
assert 'networks' in d
assert 'volumes' in d
assert 'pgdata' in d['volumes']
print(dc)Services with a Dockerfile builder set build: . in the compose output:
from fastcore.foundation import working_directorydf = Dockerfile().from_('python:3.11-slim').run('pip install flask').copy('.', '/app').cmd(['python', 'app.py'])
dc = Compose().svc('web', build=df, ports={5000: 5000})
assert dc.to_dict()['services']['web']['build'] == '.'
print(dc)Templates
Modular building blocks for production Docker stacks — use them independently or compose together.
swag_conf
def swag_conf(
domain, port, app:str='app'
):
SWAG nginx site-conf for reverse-proxying to app
swag
def swag(
domain, app:str='app', port:NoneType=None, conf_path:str='proxy.conf', validation:str='http',
subdomains:str='wildcard', cloudflared:bool=False, mods:NoneType=None, kw:VAR_KEYWORD
):
SWAG reverse-proxy service kwargs for Compose.svc()
appfile
def appfile(
port:int=5001, volume:str='/app/data', image:str='python:3.12-slim'
):
Standard Python webapp Dockerfile
Usage
# Standalone Dockerfile (works with anything: AWS, bare metal, SWAG)
appfile(port=5001).save('myapp/Dockerfile')
# Compose with SWAG (port= auto-writes the nginx proxy conf)
dc = (Compose()
.svc('app', build='.', networks=['web'], restart='unless-stopped')
.svc('swag', **swag('myapp.ai', port=5001, conf_path='myapp/proxy.conf',
cloudflared=True, mods=['crowdsec']))
.network('web').volume('swag_config'))
dc.save('myapp/docker-compose.yml')
# Compose without SWAG
dc = Compose().svc('app', build='.', ports={5001: 5001})
# Mix with anything
dc = (Compose()
.svc('app', build='.', networks=['web'])
.svc('swag', **swag('myapp.ai', port=5001))
.svc('redis', image='redis:alpine', networks=['web'])
.network('web').volume('swag_config'))df = appfile(port=5001)
s = str(df)
assert 'FROM python:3.12-slim' in s
assert 'WORKDIR /app' in s
assert 'COPY requirements.txt .' in s
assert 'RUN pip install --no-cache-dir -r requirements.txt' in s
assert 'mkdir -p /app/data' in s
assert 'VOLUME' not in s
assert 'EXPOSE 5001' in s
assert 'CMD ["python", "main.py"]' in s
print('appfile() \u2713'); print(df)kw = swag('myapp.ai')
assert kw['image'] == 'lscr.io/linuxserver/swag'
assert kw['ports'] == {443: 443, 80: 80}
assert kw['env']['URL'] == 'myapp.ai'
assert kw['cap_add'] == ['NET_ADMIN']
assert kw['depends_on'] == ['app']
print('swag() \u2713')kw = swag('myapp.ai', cloudflared=True, mods=['crowdsec'])
assert 'ports' not in kw
assert 'crowdsec' in kw['env']['DOCKER_MODS']
assert 'universal-cloudflared' in kw['env']['DOCKER_MODS']
assert kw['env']['CF_REMOTE_MANAGE_TOKEN'] == '${CF_TUNNEL_TOKEN}'
# Integrates cleanly with Compose.svc()
dc = (Compose()
.svc('app', build='.', networks=['web'], restart='unless-stopped')
.svc('swag', **swag('myapp.ai', cloudflared=True, mods=['crowdsec']))
.network('web').volume('swag_config'))
d = dc.to_dict()
assert 'swag' in d['services']
assert d['services']['swag']['cap_add'] == ['NET_ADMIN']
print('swag() + cloudflared + mods \u2713'); print(dc)Live example: FastHTML + SWAG + DuckDNS (free SSL)
DuckDNS is a free DNS service. SWAG has built-in DuckDNS validation — no Cloudflare or domain registrar needed.
- Go to https://www.duckdns.org, sign in (GitHub/Google), create a subdomain (e.g.
myapp.duckdns.org), copy your token - Point the subdomain to your VPS IP (done in DuckDNS web UI)
- Set the two vars below and run
Note: DuckDNS wildcard certs (
*.sub.duckdns.org) don’t cover the bare domain. Usesubdomains=''for DuckDNS.
import os
app_dir = Path.home() / '.fastops-example'
if app_dir.exists():
import shutil; shutil.rmtree(app_dir)
app_dir.mkdir()
# Write the FastHTML app
(app_dir / 'main.py').write_text('''from fasthtml.common import *
db = database('data/todos.db')
todos = db.t.todos
if todos not in db.t: todos.create(id=int, title=str, done=bool, pk='id')
Todo = todos.dataclass()
app, rt = fast_app(live=False)
@rt('/')
def get():
items = [Li(f"{'\u2713' if t.done else '\u25cb'} {t.title}", id=f'todo-{t.id}') for t in todos()]
return Titled('Todos',
Ul(*items),
Form(Input(name='title', placeholder='New todo...'), Button('Add'), action='/add', method='post'))
@rt('/add', methods=['post'])
def post(title: str):
todos.insert(Todo(title=title, done=False))
return Redirect('/')
@rt('/api/todos')
def api(): return [dict(id=t.id, title=t.title, done=t.done) for t in todos()]
serve(host='0.0.0.0', port=5001)
''')
(app_dir / 'requirements.txt').write_text('python-fasthtml\n')
# Generate the stack with modular API
DUCKDNS_SUBDOMAIN = 'angalama'
DUCKDNS_TOKEN = '2d150216-df4d-4ba5-8c74-d519226ed65f'
domain = f'{DUCKDNS_SUBDOMAIN}.duckdns.org'
appfile(port=5001).save(app_dir / 'Dockerfile')
dc = (Compose()
.svc('app', build='.', networks=['web'], restart='unless-stopped')
.svc('swag', **swag(domain, port=5001, conf_path=app_dir/'proxy.conf',
subdomains='', validation='duckdns', DUCKDNSTOKEN=DUCKDNS_TOKEN))
.network('web').volume('swag_config'))
dc.save(str(app_dir / 'docker-compose.yml'))
print(dc)
print(f'\nGenerated files: {os.listdir(app_dir)}')services:
app:
build: .
networks:
- web
restart: unless-stopped
swag:
image: lscr.io/linuxserver/swag
depends_on:
- app
ports:
- 443:443
- 80:80
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
- URL=angalama.duckdns.org
- SUBDOMAINS=
- VALIDATION=duckdns
- DUCKDNSTOKEN=2d150216-df4d-4ba5-8c74-d519226ed65f
volumes:
- swag_config:/config
- ./proxy.conf:/config/nginx/site-confs/proxy.conf
networks:
- web
cap_add:
- NET_ADMIN
restart: unless-stopped
networks:
web: null
volumes:
swag_config: null
Generated files: ['Dockerfile', 'requirements.txt', 'main.py', 'docker-compose.yml', 'proxy.conf']
# To start: cd into app_dir and run docker compose up
with working_directory(app_dir) as w: dc.up() # or: run('docker', 'compose', '-f', str(app_dir/'docker-compose.yml'), 'up', '-d')
# Your app will be live at https://{DUCKDNS_SUBDOMAIN}.duckdns.org with auto-SSL!Deploying to AWS
For AWS ECS / Cloud Run / Azure Container Apps: no reverse proxy needed — use cloud-native load balancer + managed SSL. Just use appfile() and push to your registry.
# For hyperscaler deployments, just use appfile() directly:
df = appfile(port=5001, volume=None) # no volume needed for stateless containers
# Build and push to ECR
# df.build(tag='123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest')
# _docker('push', '123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest')
print(df)Loading from an existing docker-compose.yml
Use Compose.load() to read an existing file, then continue chaining.
import tempfile
tmp = tempfile.mkdtemp()
path = f'{tmp}/docker-compose.yml'
Path(path).write_text("""services:
web:
image: nginx
ports:
- "80:80"
db:
image: postgres:15
environment:
- POSTGRES_PASSWORD=secret
networks:
backend:
volumes:
pgdata:
""")
dc = Compose.load(path)
d = dc.to_dict()
assert 'web' in d['services']
assert d['services']['web']['image'] == 'nginx'
assert 'networks' in d
assert 'volumes' in d
# Chain after loading
dc2 = dc.svc('redis', image='redis:alpine')
assert len(dc2.to_dict()['services']) == 3
print(dc)services:
web:
image: nginx
ports:
- 80:80
db:
image: postgres:15
environment:
- POSTGRES_PASSWORD=secret
networks:
backend: null
volumes:
pgdata: null
Example: Production stack (multi-stage build + SWAG + Cloudflare)
A realistic production setup inspired by real FastHTML deployments: multi-stage Dockerfile with uv, COPY --link, apt packages, and a healthcheck — plus a Compose stack with SWAG (Cloudflare DNS + cloudflared tunnel), container_name, extra_hosts, env_file, and read-only host-path volumes.
# -- Multi-stage Dockerfile with uv, COPY --link, healthcheck --
df = (Dockerfile()
# Stage 1: dependency builder
.from_('python', '3.12-slim', as_='builder')
.run('pip install uv')
.workdir('/app')
.copy('pyproject.toml', '.', link=True)
.copy('uv.lock', '.', link=True)
.run('uv export --no-hashes -o requirements.txt && pip install --no-cache-dir -r requirements.txt')
# Stage 2: runtime
.from_('python', '3.12-slim')
.apt_install('curl', 'sqlite3', y=True)
.copy('/usr/local/lib/python3.12/site-packages', '/usr/local/lib/python3.12/site-packages', from_='builder', link=True)
.workdir('/app')
.copy('.', '.', link=True)
.expose(5001)
.healthcheck('curl -f http://localhost:5001/ || exit 1', i='30s', t='10s', r='3')
.cmd(['python', 'main.py']))
s = str(df)
assert 'FROM python:3.12-slim AS builder' in s
assert 'COPY --link pyproject.toml .' in s
assert 'COPY --from=builder --link /usr/local/lib' in s
assert '--interval=30s --timeout=10s --retries=3' in s
print(df)
# -- Compose: app + SWAG with cloudflared, container_name, extra_hosts, env_file --
dc = (Compose()
.svc('app',
build='.',
container_name='myapp',
networks=['web'],
extra_hosts=['host.docker.internal:host-gateway'],
env_file=['.env'],
volumes={'app_data': '/app/data', './config.yml': '/app/config.yml:ro'},
restart='unless-stopped')
.svc('swag', **swag('myapp.example.com', cloudflared=True, mods=['crowdsec'],
DNSPLUGIN='cloudflare'))
.network('web')
.volume('swag_config')
.volume('app_data'))
d = dc.to_dict()
assert d['services']['app']['container_name'] == 'myapp'
assert d['services']['app']['extra_hosts'] == ['host.docker.internal:host-gateway']
assert d['services']['app']['env_file'] == ['.env']
assert './config.yml:/app/config.yml:ro' in d['services']['app']['volumes']
assert 'ports' not in d['services']['swag'] # cloudflared = no exposed ports
assert 'crowdsec' in d['services']['swag']['environment'][-1] # DOCKER_MODS
print('\n---\n')
print(dc)