fastops

End-to-end developer operations toolkit: Docker, VPS, DNS, Caddy, and CI/CD from Python

Install

pip install fastops

Requires Python 3.10+ and a docker (or podman) CLI on your PATH.

The Dockerfile Builder

Dockerfile is an immutable, fluent builder — every method returns a new instance, so you can safely branch, compose, or pass it to functions.

from fastops import Dockerfile

df = (Dockerfile()
    .from_('python', '3.12-slim')
    .workdir('/app')
    .copy('requirements.txt', '.')
    .run('pip install --no-cache-dir -r requirements.txt')
    .copy('.', '.')
    .expose(8080)
    .cmd(['python', 'app.py']))

print(df)

Batteries included: apt_install and a built-in escape hatch

apt_install(*pkgs, y=False) chains apt-get update && apt-get install for you. Any Dockerfile keyword not explicitly modelled is available via __getattr__ — just call it as a method.

df_ubuntu = (Dockerfile()
    .from_('ubuntu', '22.04')
    .apt_install('curl', 'git', y=True)
    .run('pip install uv'))

# Any unknown attribute becomes an instruction: df.KEYWORD(args)
df_scratch = (Dockerfile()
    .from_('scratch')
    .add('binary', '/binary')
    .entrypoint(['/binary']))

print(df_ubuntu)
print()
print(df_scratch)

Multi-stage builds

Chain multiple from_() calls — the builder handles stage aliases naturally.

df_ms = (Dockerfile()
    .from_('golang:1.21', as_='builder')
    .workdir('/src')
    .copy('.', '.')
    .run('go build -o /app')
    .from_('alpine')
    .copy('/app', '/app', from_='builder')
    .cmd(['/app']))

print(df_ms)

Dockerfile.load(path) parses an existing file into the builder for further chaining. df.save(path) writes it back and returns the Path.

Building and Running Images

These helpers require a running Docker daemon. They wrap docker build, docker run, docker ps, etc. via subprocess.

from fastops import Dockerfile, run, test, containers, images, stop, logs, rm, rmi

df = Dockerfile().from_('python', '3.12-slim').cmd(['python', '-c', 'print("hi")'])

img = df.build(tag='myapp:latest')        # saves Dockerfile + runs docker build
ok  = test(img, 'python -c "import os"')   # True if exit code 0
cid = run(img, detach=True, ports={8080: 8080}, name='myapp')

print(containers())    # ['myapp']
print(logs('myapp', n=5))

stop('myapp'); rm('myapp'); rmi(img)

Raw CLI access via dk

For anything not covered by helpers, dk (a Docker() singleton) dispatches any subcommand. kwargs become flags using the same convention: single-char k=v-k v, multi-char key=v--key=v.

from fastops import dk

try:
    print(dk.version())
except Exception as e:
    print(f'Docker not running: {e}')

# Equivalent shell commands:
# dk.ps(format='{{.Names}}', a=True)   → docker ps --format={{.Names}} -a
# dk.image('prune', f=True)            → docker image prune -f
# dk.build('.', t='myapp', rm=True)    → docker build . -t myapp --rm

Docker Compose

Compose is a fluent builder for docker-compose.yml. Chain .svc(), .network(), .volume(), then .save() to write or .up() to write and start.

from fastops import Compose

dc = (Compose()
    .svc('db',
         image='postgres:16',
         env={'POSTGRES_PASSWORD': 'secret'},
         volumes={'pgdata': '/var/lib/postgresql/data'})
    .svc('redis', image='redis:7-alpine')
    .svc('app',
         build='.',
         ports={8080: 8080},
         env={'DATABASE_URL': 'postgresql://postgres:secret@db/app'},
         depends_on=['db', 'redis'],
         networks=['web'])
    .network('web')
    .volume('pgdata'))

print(dc)

appfile() — standard Python webapp Dockerfile

A one-liner for the most common Dockerfile pattern: copy requirements, pip install, copy source, expose port, run main.py.

from fastops import appfile

print(appfile(port=8080, image='python:3.12-slim'))

Use Compose.load(path) to round-trip an existing docker-compose.yml. DockerCompose(path) wraps the CLI for running compose commands against any file.

Reverse Proxying with Caddy

The caddy module generates a Caddyfile and returns service kwargs for Compose.svc(). Four topologies are supported, from simplest to most secure.

Plain Caddy — auto-TLS, ports 80 and 443

from fastops import caddyfile, caddy
import tempfile

# What the Caddyfile looks like:
print(caddyfile('myapp.example.com', port=8080))
with tempfile.TemporaryDirectory() as tmp:
    dc = (Compose()
        .svc('app', build='.', networks=['web'])
        .svc('caddy', **caddy('myapp.example.com', port=8080,
                              conf=f'{tmp}/Caddyfile'))
        .network('web')
        .volume('caddy_data').volume('caddy_config'))
    print(dc)

DNS-01 challenge — no port 80 required

Pass dns='cloudflare' or dns='duckdns' to use DNS-01 ACME. This lets you get TLS certs on machines that have port 80 blocked.

print(caddyfile('myapp.example.com', port=8080, dns='cloudflare', email='me@example.com'))

Cloudflare tunnel — zero open ports

With cloudflared=True, Caddy listens on plain HTTP and cloudflared tunnels traffic in. No ports need to be open on the host at all.

from fastops import cloudflared_svc

with tempfile.TemporaryDirectory() as tmp:
    dc = (Compose()
        .svc('app', build='.', networks=['web'])
        .svc('caddy', **caddy('myapp.example.com', port=8080,
                              cloudflared=True, conf=f'{tmp}/Caddyfile'))
        .svc('cloudflared', **cloudflared_svc(), networks=['web'])
        .network('web')
        .volume('caddy_data').volume('caddy_config'))
    print(dc)

Full security stack — CrowdSec + Cloudflare tunnel

Add crowdsec=True to wire in the CrowdSec intrusion-detection bouncer. The image is selected automatically based on the combination of crowdsec and dns.

from fastops import crowdsec

with tempfile.TemporaryDirectory() as tmp:
    dc = (Compose()
        .svc('app', build='.', networks=['web'])
        .svc('caddy', **caddy('myapp.example.com', port=8080,
                              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'))
    print(dc)

Caddy image selection

crowdsec dns Image
False None caddy:2
True None serfriz/caddy-crowdsec:latest
False 'cloudflare' serfriz/caddy-cloudflare:latest
True 'cloudflare' ghcr.io/buildplan/csdp-caddy:latest
False 'duckdns' serfriz/caddy-duckdns:latest

SWAG (nginx alternative)

If you prefer LinuxServer SWAG (nginx + Certbot), use swag(). It generates an nginx site-conf and returns service kwargs for Compose.svc().

from fastops import swag, swag_conf

# nginx site-conf for proxying to app:8080
print(swag_conf('myapp.example.com', port=8080))
with tempfile.TemporaryDirectory() as tmp:
    dc = (Compose()
        .svc('app', build='.', networks=['web'])
        .svc('swag', **swag('myapp.example.com', port=8080,
                            conf_path=f'{tmp}/proxy.conf'))
        .network('web')
        .volume('swag_config'))
    print(dc)

Local Linux VMs with Multipass

The multipass module wraps the Multipass CLI for spinning up ephemeral Ubuntu VMs. Ideal for testing deployment scripts locally — no cloud account needed.

cloud_init_yaml — VM bootstrap config

from fastops import cloud_init_yaml

# Default: Docker pre-installed
print(cloud_init_yaml(docker=True, packages=['htop', 'tree']))

launch_docker_vm — the one-liner

launch_docker_vm() is the convenience wrapper that pairs cloud_init_yaml with launch. It covers the most common use case: spin up a clean Ubuntu VM with Docker ready to go.

from fastops import launch_docker_vm, vm_ip, exec_, transfer, delete, vms

# Spin up an Ubuntu VM with Docker pre-installed (takes ~60s first run)
vm = launch_docker_vm('test-vm', cpus=2, memory='2G')

print(vms(running=True))             # ['test-vm']
print(vm_ip('test-vm'))              # '192.168.64.5'

exec_('test-vm', 'docker', 'ps')    # run any command in the VM
transfer('./docker-compose.yml',
         'test-vm:/home/ubuntu/docker-compose.yml')  # copy files

delete('test-vm')                    # purge when done

launch() accepts cloud_init as either a YAML string or a path to an existing file — if it’s a string it writes a temp file, passes --cloud-init, and cleans up automatically.

For raw CLI access use the mp singleton (same pattern as dk):

from fastops import mp
mp.info("test-vm")            # → multipass info test-vm
mp.snapshot("test-vm", name="before-deploy")

Cloudflare DNS and Tunnels

The cloudflare module wraps the official Cloudflare Python SDK for managing DNS records and Zero Trust tunnels. Set CLOUDFLARE_API_TOKEN in your environment.

pip install "fastops[cloudflare]"
# or: pip install cloudflare

dns_record — the one-liner

dns_record() is the convenience wrapper: reads CLOUDFLARE_API_TOKEN from env, looks up the zone, deletes any existing record with the same name and type, then creates the new one.

from fastops import dns_record, CF

# Point myapp.example.com at a server IP
record = dns_record('example.com', 'myapp', '1.2.3.4', proxied=True)

# Full control via CF() for multi-step workflows
cf = CF()  # reads CLOUDFLARE_API_TOKEN

# DNS
zid = cf.zone_id('example.com')
records = cf.dns_records(zid)
cf.delete_record(zid, records[0]['id'])

# Tunnels
tunnel = cf.create_tunnel('myapp-prod')
token  = cf.tunnel_token(tunnel['id'])
# → pass token as CF_TUNNEL_TOKEN in your environment

App Dockerfiles

The apps module generates complete, production-ready Dockerfiles for common stacks. All variants support uv-based installs, extra apt packages, and optional healthchecks.

Python / FastHTML

from fastops import python_app, fasthtml_app

# Generic single-stage Python app
print(python_app(port=8080, cmd=['uvicorn', 'main:app', '--host', '0.0.0.0']))
# FastHTML shortcut: uv + port 5001 + sensible defaults
print(fasthtml_app(port=5001, pkgs=['rclone'], volumes=['/app/data']))

FastAPI + React (two-stage)

from fastops import fastapi_react

# Stage 1: Node builds the frontend; Stage 2: Python serves the API
print(fastapi_react(port=8000, frontend_dir='frontend'))

Go and Rust (two-stage → distroless)

from fastops import go_app, rust_app

print(go_app(port=8080, go_version='1.22'))
print()
print(rust_app(port=8080, binary='myapp'))

Cache mounts for faster rebuilds

run_mount() adds RUN --mount=type=cache,... to any instruction, keeping pip/uv/cargo/go caches across builds:

df = (Dockerfile().from_('python:3.12-slim')
    .run_mount('uv sync --frozen --no-dev', target='/root/.cache/uv')
    .run_mount('go mod download',           target='/go/pkg/mod'))

VPS Provisioning

The vps module covers the full lifecycle from a blank cloud server to a running Compose stack: cloud-init generation, Hetzner provisioning, and SSH-based deployment. No cloud SDK required beyond the hcloud CLI and ssh/rsync.

vps_init — cloud-init YAML

from fastops import vps_init

# Full bootstrap: UFW, deploy user, Docker, Cloudflare tunnel
yaml = vps_init(
    'prod-01',
    pub_keys='ssh-rsa AAAA...',
    docker=True,
    packages=['git', 'htop'],
)
print(yaml[:500])

Hetzner provisioning

create() wraps hcloud server create. Pass the cloud-init YAML string directly — it handles the temp-file lifecycle automatically.

from fastops import create, servers, server_ip, delete

ip = create(
    'prod-01',
    image='ubuntu-24.04',
    server_type='cx22',
    location='nbg1',
    cloud_init=yaml,
    ssh_keys=['my-laptop'],
)
print(servers())   # [{'name': 'prod-01', 'ip': '...', 'status': 'running'}]

Requires hcloud CLI and HCLOUD_TOKEN in env.

deploy — sync and start

deploy() accepts a Compose object or a raw YAML string, rsyncs it to the server, and runs docker compose up -d:

from fastops import deploy

deploy(dc, ip, user='deploy', key='~/.ssh/id_ed25519', path='/srv/myapp')
# 1. mkdir -p /srv/myapp  (via SSH)
# 2. rsync docker-compose.yml → prod-01:/srv/myapp/
# 3. docker compose up -d  (via SSH)

For one-off commands use run_ssh():

from fastops import run_ssh

print(run_ssh(ip, 'docker ps', user='deploy', key='~/.ssh/id_ed25519'))

End-to-End: Deploy a Python App

Here is the complete workflow for deploying a Python webapp with Caddy TLS, a Cloudflare tunnel, and CrowdSec — starting from a blank Python file.

Step 1: generate the configs (pure Python, no daemon needed)

from fastops import Dockerfile
from fastops import Compose, appfile
from fastops import caddy, cloudflared_svc, crowdsec
import tempfile

DOMAIN = 'myapp.example.com'
PORT   = 8080

# Standard Python app Dockerfile
df = appfile(port=PORT)

with tempfile.TemporaryDirectory() as tmp:
    dc = (Compose()
        .svc('app',         build='.',  networks=['web'])
        .svc('caddy',       **caddy(DOMAIN, port=PORT,
                                   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'))

    print('--- Dockerfile ---')
    print(df)
    print('\n--- docker-compose.yml ---')
    print(dc)

Step 2: save and deploy (requires Docker daemon)

df.save('Dockerfile')
dc.save('docker-compose.yml')
dc.up()  # writes file + runs docker compose up -d

Step 3: wire up DNS (requires CLOUDFLARE_API_TOKEN)

from fastops import dns_record

dns_record('example.com', 'myapp', '1.2.3.4', proxied=True)

Step 4: test locally first (requires Multipass)

from fastops import launch_docker_vm, vm_ip, exec_, transfer, delete
from fastops import dns_record

vm = launch_docker_vm('test-vm')
transfer('./docker-compose.yml', 'test-vm:/home/ubuntu/')
exec_('test-vm', 'docker', 'compose', 'up', '-d')

ip = vm_ip('test-vm')
dns_record('example.com', 'myapp', ip)  # point DNS at the VM

delete('test-vm')  # clean up

Step 5: provision and deploy to a real server (requires hcloud CLI + HCLOUD_TOKEN)

from fastops import vps_init, create, deploy
from fastops import dns_record

# Bootstrap a fresh Hetzner server
yaml = vps_init('prod-01', pub_keys=open('~/.ssh/id_ed25519.pub').read(),
                docker=True)
ip = create('prod-01', server_type='cx22', location='nbg1',
            cloud_init=yaml, ssh_keys=['my-laptop'])

# Point DNS at it
dns_record('example.com', 'myapp', ip, proxied=True)

# Sync Compose stack and start
deploy(dc, ip, key='~/.ssh/id_ed25519', path='/srv/myapp')

Podman Support

Set DOCKR_RUNTIME=podman to switch all CLI calls to podman. The generated Dockerfiles and Compose YAML are runtime-agnostic.

export DOCKR_RUNTIME=podman

Credential-stripping (_clean_cfg()) is skipped automatically for non-docker runtimes.