compose

Docker Compose file generation and orchestration

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


source

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


source

dict2str


def dict2str(
    d:dict, sep:str=':'
):
d = service(image='nginx', ports={80: 80})
assert d['image'] == 'nginx'
assert d['ports'] == ['80:80']
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.


source

DockerCompose


def DockerCompose(
    path:str='docker-compose.yml'
):

Wrap docker compose CLI: getattr dispatches subcommands, kwargs become flags


source

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_directory
df = 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.


source

swag_conf


def swag_conf(
    domain, port, app:str='app'
):

SWAG nginx site-conf for reverse-proxying to app


source

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


source

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.

  1. Go to https://www.duckdns.org, sign in (GitHub/Google), create a subdomain (e.g. myapp.duckdns.org), copy your token
  2. Point the subdomain to your VPS IP (done in DuckDNS web UI)
  3. Set the two vars below and run

Note: DuckDNS wildcard certs (*.sub.duckdns.org) don’t cover the bare domain. Use subdomains='' 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)