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)fastops
Install
pip install fastopsRequires 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.
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 --rmDocker 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 donelaunch() 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 cloudflaredns_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 environmentApp 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 -dStep 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 upStep 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=podmanCredential-stripping (_clean_cfg()) is skipped automatically for non-docker runtimes.