dockeasy writes the files that run an app in Docker containers, and runs Docker from Python.
A Dockerfile is the recipe Docker follows to build an image: a base image to start from, files to copy in, commands to run, and the command to start. A container is a running copy of an image. A Compose file, docker-compose.yml, describes several containers that run together, such as a web app, its database and a proxy. Each named application component is a service. Compose creates the containers of each service. Writing these files by hand needs exact syntax and quoting. With dockeasy you build each file with Python method calls, print it to check it, and save it.
Dockerfile and Compose write the two files. Builders such as python_app and go_app return a ready-made Dockerfile for a common kind of project. detect_app checks your project’s files and returns a Dockerfile from the matching builder. The proxy helpers write a Caddyfile, the config for the Caddy web server. Caddy takes requests from the internet, handles HTTPS certificates and passes the requests on to your app. Thin wrappers around the docker command build images and run containers. A small store keeps config values and secrets.
pip install dockeasyEverything is exported from the top-level package:
from dockeasy import *Start with an empty Dockerfile() and call one method per instruction. from_ sets the base image, with a trailing underscore because from is a Python keyword. copy copies files from your project into the image, run runs a shell command while the image is built, expose records the port the app listens on, and cmd sets the command the container runs when it starts. Printing the object shows the file it will write.
Each method returns a new Dockerfile and leaves the one you called it on unchanged, so you can keep a base recipe in a variable and extend it in different ways.
df = (Dockerfile()
.from_('python:3.12-slim')
.workdir('/app')
.copy('requirements.txt', '.')
.run('pip install -r requirements.txt')
.copy('.', '.')
.expose(8000)
.cmd(['python', 'main.py']))
print(df)FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "main.py"]
A multi-stage build compiles your app in one image and runs it in a second, smaller one, so compilers and source code stay out of the image you ship. Each from_ call starts a new stage. Name a stage with as_=, then copy files out of it with copy(..., from_='builder'). Here a Go program is built in the golang image and only the compiled binary is copied into a minimal distroless image:
df = (Dockerfile()
.from_('golang:1.22-alpine', as_='builder')
.workdir('/src')
.copy('.', '.')
.run('go build -o /app .')
.from_('gcr.io/distroless/static')
.copy('/app', '/app', from_='builder')
.cmd(['/app']))
print(df)FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN go build -o /app .
FROM gcr.io/distroless/static
COPY --from=builder /app /app
CMD ["/app"]
Docker reruns a RUN step whenever the files copied in before it change, and a package install then downloads everything again. run_mount writes RUN --mount=type=cache,..., which keeps a folder, such as pip’s or uv’s download cache, from one build to the next. Pass the cache folder as target:
df = (Dockerfile()
.from_('python:3.12-slim')
.workdir('/app')
.copy('requirements.txt', '.')
.run_mount('pip install -r requirements.txt', target='/root/.cache/pip')
.copy('.', '.')
.cmd(['python', 'main.py']))
print(df)FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]
For common kinds of project you do not need to write the Dockerfile yourself. Each function below returns a complete Dockerfile, which you can print, extend with more method calls, or save.
python_app is for a Python project managed with uv, with a pyproject.toml and a uv.lock. By default it writes a two-stage build. The first stage runs in uv’s own image and installs your locked dependencies into a .venv folder. The second stage starts from python:3.13-slim and copies the app folder across, .venv included, so uv itself stays behind. The container runs python main.py on port 8000; cmd and port change them.
python_app()FROM ghcr.io/astral-sh/uv:python3.13-bookworm AS builder
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
COPY pyproject.toml .
COPY uv.lock .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /app /app
ENV PATH=/app/.venv/bin:$PATH
EXPOSE 8000
CMD ["python", "main.py"]
pkgs installs system packages with apt-get, vols creates the folders you plan to mount volumes on, and healthcheck adds a HEALTHCHECK that requests that path on the app’s port every 30 seconds. The slim Python image has no curl, so the check uses Python’s urllib unless pkgs installs curl, as it does here. multistage=False gives a single-stage build that installs uv with pip, and adding req=True to that installs from requirements.txt instead of pyproject.toml.
python_app(pkgs=['libpq-dev', 'curl'], vols=['/app/data'], healthcheck='/health')FROM ghcr.io/astral-sh/uv:python3.13-bookworm AS builder
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
COPY pyproject.toml .
COPY uv.lock .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y libpq-dev curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app /app
ENV PATH=/app/.venv/bin:$PATH
RUN mkdir -p /app/data
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD curl -f http://localhost:8000/health
EXPOSE 8000
CMD ["python", "main.py"]
fasthtml_app is python_app with the settings a FastHTML app usually needs: port 5001, the command python app.py, and a single-stage build. You can still override any of them.
fasthtml_app()FROM python:3.13-slim
WORKDIR /app
RUN pip install uv
COPY pyproject.toml .
RUN uv sync --no-dev
ENV PATH=/app/.venv/bin:$PATH
COPY . .
EXPOSE 5001
CMD ["python", "app.py"]
go_app downloads the modules and compiles a static binary in golang:1.22-alpine, then copies only the binary into gcr.io/distroless/static, a minimal image with no shell or package manager. The app listens on port 8080, and binary names the output file.
go_app()FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod .
COPY go.sum .
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
ENV CGO_ENABLED=0
RUN go build -ldflags="-s -w" -o /app .
FROM gcr.io/distroless/static
COPY --from=builder /app /app
EXPOSE 8080
CMD ["/app"]
rust_app runs cargo build --release, with Cargo’s package registry cached between builds, and copies target/release/app into a distroless image. Set binary if your binary is not called app, and features to build with Cargo features turned on.
rust_app()FROM rust:1-slim-bookworm AS builder
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry cargo build --release
FROM gcr.io/distroless/static
COPY --from=builder /src/target/release/app /app
EXPOSE 8080
CMD ["/app"]
node_app installs dependencies with npm ci and runs node index.js on port 3000. With static=True it is for a site that compiles to static files instead: it runs npm run build, then serves the dist folder with the serve package from a fresh image.
node_app(static=True)FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
RUN npm install -g serve
COPY --from=builder /app/dist .
EXPOSE 3000
CMD ["serve", "-s", ".", "-l", "3000"]
detect_app looks at the files in a project folder and calls the matching builder. It checks in this order:
| files found | builder |
|---|---|
Cargo.toml |
rust_app |
go.mod |
go_app |
package.json with pyproject.toml or requirements.txt |
fastapi_react, a Node frontend with a Python backend |
package.json alone |
node_app |
pyproject.toml that mentions python-fasthtml |
fasthtml_app |
any other pyproject.toml |
python_app |
requirements.txt alone |
python_app(multistage=False, req=True) |
It raises ValueError if none of these files is there. Extra keyword arguments go to the builder it picks. The example below makes a temporary folder for some of the cases and shows which builder each one gets:
def tmp(files):
d = Path(tempfile.mkdtemp())
for f, c in files.items(): (d/f).write_text(c)
return d
cases = [
({'go.mod': 'module x'}, 'go.mod → go_app'),
({'Cargo.toml': '[package]'}, 'Cargo.toml → rust_app'),
({'package.json': '{}', 'pyproject.toml': '[p]'}, 'package.json + pyproject.toml → fastapi_react'),
({'pyproject.toml': '[project]'}, 'pyproject.toml → python_app (port 8000)'),
]
for files, label in cases:
df = detect_app(tmp(files))
print(label)go.mod → go_app
Cargo.toml → rust_app
package.json + pyproject.toml → fastapi_react
pyproject.toml → python_app (port 8000)
Compose writes a docker-compose.yml in the same way that Dockerfile writes a Dockerfile. svc adds a service. Give it either image, a published image to run, or build, the folder holding the Dockerfile to build. ports maps host ports to container ports, env sets environment variables, volumes attaches named volumes or host folders at paths inside the container, and depends_on names services that must start first. Any other keyword, such as networks or restart, is copied into the service unchanged. network and volume declare the networks and named volumes that the services use.
The api service below passes a Dockerfile object as build, which svc writes as build: ., meaning the current folder. python_app().save() writes that Dockerfile into the current folder and the final .save() writes docker-compose.yml there too. Running this cell leaves both files next to the notebook.
dc = (Compose()
.svc('web', image='nginx', ports={80: 80}, networks=['backend'])
.svc('api', build=python_app().save(), ports={8000: 8000},
env={'DATABASE_URL': 'postgresql://db/app'},
depends_on=['db'], networks=['backend'])
.svc('db', image='postgres:16',
env={'POSTGRES_PASSWORD': 'secret'},
volumes={'pgdata': '/var/lib/postgresql/data'})
.network('backend')
.volume('pgdata')).save()
dcservices:
web:
image: nginx
ports:
- 80:80
networks:
- backend
api:
depends_on:
- db
ports:
- 8000:8000
build: .
environment:
- DATABASE_URL=postgresql://db/app
networks:
- backend
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=secret
volumes:
- pgdata:/var/lib/postgresql/data
networks:
backend: null
volumes:
pgdata: null
save writes the file, to docker-compose.yml by default. up saves it and runs docker compose up -d, and down runs docker compose down. Their keyword arguments become command-line flags. v=True adds -v, which also deletes the stack’s named volumes. The docs build does not run the cell below. The stack above is an illustration, and the cell fails if you run it as it is.
dc.save('docker-compose.yml')
dc.up()
dc.down(v=True)These functions wrap the docker command so that a script or notebook can build an image, run it and clean up without leaving Python. They need Docker installed and running, so these cells are not run when the docs are built, and the outputs shown come from an earlier run.
Dockerfile.build saves the Dockerfile into the folder path and builds it with docker compose build, which uses the Docker daemon’s own BuildKit. The separate buildx plugin is not needed. It returns the tag. no_creds=True builds with a copy of your Docker config that has the credential helpers removed. drun runs a container and returns what it printed, and rmi removes an image. The example below saves its Dockerfile into an empty temporary folder. For your own application, pass the folder of that application as path:
tmp = tempfile.mkdtemp()
df = (Dockerfile()
.from_('alpine')
.cmd(['echo', 'hello from docker']))
tag = df.build(tag='myapp:latest', path=tmp, no_creds=True)
print(f'Built: {tag}')
out = drun(tag, remove=True)
print(out.strip())
rmi(tag, force=True)
print('Cleaned up.')Built: myapp:latest
hello from docker
Cleaned up.
With detach=True, drun starts the container in the background and returns its ID. ports maps container ports to host ports, the other way round from Compose: {'80/tcp': 8080} makes the container’s port 80 reachable on port 8080 of your machine. With check=True, the default, drun watches the container for up to two seconds and raises RuntimeError, with the container’s logs, if it stops in that time. containers lists the names of running containers, logs returns the last n lines of a container’s log, and stop and rm stop and remove it:
cid = drun('nginx', detach=True, ports={'80/tcp': 8080}, name='my-nginx', check=True)
print(containers())
print(logs('my-nginx', n=5))
stop('my-nginx')
rm('my-nginx')['my-nginx']
;; 2026/03/22 18:20:16 [notice] 1#1: OS: Linux 6.8.0-90-generic
2026/03/22 18:20:16 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1024:524288
2026/03/22 18:20:16 [notice] 1#1: start worker processes
2026/03/22 18:20:16 [notice] 1#1: start worker process 29
2026/03/22 18:20:16 [notice] 1#1: start worker process 30
test runs a command in a throwaway container from an image and returns True if the command exits with status 0, and False otherwise. It is a quick check, in CI for example, that an image starts and has what your app needs:
assert test('python:3.12-slim', ['python', '-c', 'import sys; sys.exit(0)'])
assert not test('python:3.12-slim', ['python', '-c', 'raise SystemExit(1)'])Deploy scripts need settings such as a server’s IP address, and secrets such as API tokens, and these should not be written into the code. env_set saves a value in ~/.config/fastops/.env, a file only you can read, and also sets it in os.environ so the running process sees it straight away. env_get reads os.environ first and falls back to the file:
env_set('VPS_IP', '1.2.3.4')
env_set('APP_NAME', 'my-app')
print(env_get('VPS_IP'))
print(env_get('APP_NAME'))1.2.3.4
my-app
secret_set is for sensitive values. It stores the value in the system keychain through the keyring package, such as the macOS Keychain, and raises RuntimeError on a machine with no keychain, as on most servers. Pass save=True to also write the value, in plain text, to the same .env file and to os.environ. With save=True and no keychain, secret_set warns that the .env file holds the only copy and writes it. secret_get looks in the keychain first, then os.environ, then the file, and returns default if none of them has the key.
secret_set('FOO', 'bar', save=True)
print(secret_get('FOO'))bar
secrets reads several keys at once and returns a dict of the ones that have a value. Missing keys are left out without a warning.
secret_set('DATABASE_URL', 'postgresql://...', save=True)
cfg = secrets('FOO', 'DATABASE_URL', 'MISSING_KEY')
cfg{'FOO': 'bar', 'DATABASE_URL': 'postgresql://...'}
A reverse proxy sits in front of your app and receives the requests that arrive from the internet. Caddy is a web server that does this job and also fetches and renews HTTPS certificates for your domain from Let’s Encrypt on its own, using the ACME protocol. You configure it with a file called a Caddyfile.
caddy returns a Caddyfile object for a domain. Unless you say otherwise, it forwards requests to the service called app on port 5001. Print it to see the file, or call .save() to write it. The simplest one is a single site block with a reverse_proxy line:
print(caddy('myapp.example.com'))myapp.example.com {
reverse_proxy app:5001
}
To issue a certificate, Let’s Encrypt normally connects to your server on port 80 to check that you control the domain. When that port is closed, Caddy can prove control with a DNS-01 challenge instead, by creating a DNS record through your DNS provider’s API. Pass the provider as dns. Caddy reads the API token from an environment variable, CLOUDFLARE_API_TOKEN here, and dns_token_env names a different one. email is the address Let’s Encrypt can write to about your certificates. The official Caddy image has no DNS provider plugins. caddy_svc below switches to an image that has the Cloudflare plugin.
print(caddy('myapp.example.com', dns='cloudflare', email='me@example.com')){
email me@example.com
}
myapp.example.com {
tls {
acme_dns cloudflare {$CLOUDFLARE_API_TOKEN}
}
reverse_proxy app:5001
}
A Cloudflare Tunnel lets visitors reach your server without opening any inbound port. A program called cloudflared runs in a container next to your app, makes an outgoing connection to Cloudflare, and receives your site’s traffic over it. Cloudflare serves HTTPS to the visitor, so Caddy only needs to speak plain HTTP.
caddy_svc writes the Caddyfile to conf and returns the settings for a Caddy service, ready to pass to Compose.svc with **. With cloudflared=True the Caddyfile serves plain http:// and the service publishes no ports. cloudflared_svc returns the settings for the tunnel container, which reads its token from the CF_TUNNEL_TOKEN environment variable on the host. Both services join a network called web, and Caddy keeps its certificates and state in the caddy_data and caddy_config volumes, so the Compose file declares all three. To add CrowdSec, which blocks IP addresses known for attacks, see caddy_svc(crowdsec=True) and crowdsec on the proxy page.
import tempfile
tmp = tempfile.mkdtemp()
dc = (Compose()
.svc('app', build='.', networks=['web'], restart='unless-stopped')
.svc('caddy', **caddy_svc('myapp.example.com', cloudflared=True, conf=f'{tmp}/Caddyfile'))
.svc('cloudflared', **cloudflared_svc())
.network('web').volume('caddy_data').volume('caddy_config'))
print(dc)services:
app:
build: .
networks:
- web
restart: unless-stopped
caddy:
image: caddy:2
depends_on:
- app
volumes:
- /private/var/folders/kg/9vdw4mdd1fs58svgh4k1qhr09x7dqh/T/tmpv94f2dqp/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- web
restart: unless-stopped
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${CF_TUNNEL_TOKEN}
networks:
- web
restart: unless-stopped
networks:
web: null
volumes:
caddy_data: null
caddy_config: null
The two module pages go further. Their examples run as tests, except the cells that need Docker or the internet.
- core covers the whole
DockerfileandComposeAPI, the ready-made builders, the container functions and the config store. - proxy covers every Caddyfile option, the CrowdSec security service and five complete stacks. It ends with a live test that starts a FastHTML app behind Caddy and a Cloudflare Tunnel and fetches it over the internet.