Metadata-Version: 2.5
Name: dockeasy
Version: 0.0.11
Summary: docker made easy
Project-URL: Repository, https://github.com/vedicreader/dockeasy
Project-URL: Documentation, https://vedicreader.github.io/dockeasy/
Author-email: Karthik <karthik.rajgopal@hotmail.com>
License: Apache-2.0
License-File: LICENSE
Keywords: compose,containers,docker,nbdev,podman
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.10
Requires-Dist: fastcore>=1.12.30
Requires-Dist: keyring>=25.7.0
Requires-Dist: notebook
Requires-Dist: python-dotenv>=1.2.2
Requires-Dist: pyyaml>=6.0
Description-Content-Type: text/markdown

# dockeasy


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

dockeasy writes the files you need to ship an app in Docker containers, and runs Docker for you 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 in front of them, and each of those is called a service. Writing these files by hand means remembering their syntax and quoting rules. With dockeasy you build each one with ordinary Python method calls, print it to check it, and save it.

[`Dockerfile`](https://vedicreader.github.io/dockeasy/core.html#dockerfile) and [`Compose`](https://vedicreader.github.io/dockeasy/core.html#compose) write the two files. Builders such as [`python_app`](https://vedicreader.github.io/dockeasy/core.html#python_app) and [`go_app`](https://vedicreader.github.io/dockeasy/core.html#go_app) return a ready-made Dockerfile for a common kind of project, and [`detect_app`](https://vedicreader.github.io/dockeasy/core.html#detect_app) picks one by looking at your project’s files. The proxy helpers write a Caddyfile, the config for the Caddy web server, which takes requests from the internet, handles HTTPS certificates and passes the requests on to your app. Around these sit thin wrappers over the `docker` command for building images and running containers, and a small store for config values and secrets.

## Install

``` sh
pip install dockeasy
```

Everything is exported from the top-level package:

``` python
from dockeasy import *
```

## Dockerfiles in Python

Start with an empty [`Dockerfile()`](https://vedicreader.github.io/dockeasy/core.html#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`](https://vedicreader.github.io/dockeasy/core.html#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.

``` python
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"]

### Multi-stage builds

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:

``` python
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"]

### Build cache mounts

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`:

``` python
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"]

## Ready-made Dockerfiles

For common kinds of project you do not need to write the Dockerfile yourself. Each function below returns a complete [`Dockerfile`](https://vedicreader.github.io/dockeasy/core.html#dockerfile), which you can print, extend with more method calls, or save.

### Python with uv

[`python_app`](https://vedicreader.github.io/dockeasy/core.html#python_app) is for a Python project managed with [uv](https://docs.astral.sh/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
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
# With system packages, persistent volume dir, and a healthcheck
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

`fasthtml_app` is [`python_app`](https://vedicreader.github.io/dockeasy/core.html#python_app) with the settings a [FastHTML](https://fastht.ml) app usually needs: port 5001, the command `python app.py`, and a single-stage build. You can still override any of them.

``` python
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

[`go_app`](https://vedicreader.github.io/dockeasy/core.html#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.

``` python
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

[`rust_app`](https://vedicreader.github.io/dockeasy/core.html#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.

``` python
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.js app

[`node_app`](https://vedicreader.github.io/dockeasy/core.html#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.

``` python
# Two-stage: build → serve static output
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"]

### Pick a builder from the project’s files

[`detect_app`](https://vedicreader.github.io/dockeasy/core.html#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`](https://vedicreader.github.io/dockeasy/core.html#rust_app) |
| `go.mod` | [`go_app`](https://vedicreader.github.io/dockeasy/core.html#go_app) |
| `package.json` with `pyproject.toml` or `requirements.txt` | [`fastapi_react`](https://vedicreader.github.io/dockeasy/core.html#fastapi_react), a Node frontend with a Python backend |
| `package.json` alone | [`node_app`](https://vedicreader.github.io/dockeasy/core.html#node_app) |
| `pyproject.toml` that mentions `python-fasthtml` | `fasthtml_app` |
| any other `pyproject.toml` | [`python_app`](https://vedicreader.github.io/dockeasy/core.html#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:

``` python
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)

## Docker Compose

[`Compose`](https://vedicreader.github.io/dockeasy/core.html#compose) writes a `docker-compose.yml` in the same way that [`Dockerfile`](https://vedicreader.github.io/dockeasy/core.html#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`](https://vedicreader.github.io/dockeasy/core.html#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, so running this cell leaves both files next to the notebook.

``` python
dc = (Compose()
    .svc('web',  image='nginx', ports={80: 80}, networks=['backend'])
      # we are passing a dummy dockerfile. point the python_app builder to your project dir for real use
    .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()
dc
```

    services:
      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, so `v=True` adds `-v`, which also deletes the stack’s named volumes. The cell below is not run when the docs are built, and the stack above is only an illustration, so it would fail if you ran it as it is.

``` python
dc.save('docker-compose.yml')  # writes the file
# Save to disk, bring it up, tear it down. This will fail as-is
dc.up()                        # docker compose up -d
dc.down(v=True)                # docker compose down -v
```

## Building and running containers

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`](https://vedicreader.github.io/dockeasy/core.html#dockerfile.build) saves the Dockerfile into the folder `path` and builds it with `docker compose build`, which uses the Docker daemon’s own BuildKit, so 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`](https://vedicreader.github.io/dockeasy/core.html#drun) runs a container and returns what it printed, and [`rmi`](https://vedicreader.github.io/dockeasy/core.html#rmi) removes an image:

``` python
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`](https://vedicreader.github.io/dockeasy/core.html#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`](https://vedicreader.github.io/dockeasy/core.html#drun) watches the container for up to two seconds and raises `RuntimeError`, with the container’s logs, if it stops in that time. [`containers`](https://vedicreader.github.io/dockeasy/core.html#containers) lists the names of running containers, [`logs`](https://vedicreader.github.io/dockeasy/core.html#logs) returns the last `n` lines of a container’s log, and [`stop`](https://vedicreader.github.io/dockeasy/core.html#stop) and [`rm`](https://vedicreader.github.io/dockeasy/core.html#rm) stop and remove it:

``` python
# Start a named, detached container with port mapping
cid = drun('nginx', detach=True, ports={'80/tcp': 8080}, name='my-nginx', check=True)

print(containers())          # ['my-nginx']
print(logs('my-nginx', n=5)) # last 5 log lines

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

### Smoke-test an image

[`test`](https://vedicreader.github.io/dockeasy/core.html#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:

``` python
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)'])
```

## Config and secrets

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`](https://vedicreader.github.io/dockeasy/core.html#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`](https://vedicreader.github.io/dockeasy/core.html#env_get) reads `os.environ` first and falls back to the file:

``` python
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`](https://vedicreader.github.io/dockeasy/core.html#secret_set) is for sensitive values. It stores the value in the system keychain through the `keyring` package, such as the macOS Keychain, and quietly skips that step on a machine with no keychain, as on most servers. Unless you pass `save=False`, it also writes the value to the same `.env` file and to `os.environ`. [`secret_get`](https://vedicreader.github.io/dockeasy/core.html#secret_get) looks in the keychain first, then `os.environ`, then the file, and returns `default` if none of them has the key.

``` python
# Stored in OS keychain on macOS; env var fallback on Linux/CI
secret_set('FOO', 'bar')
print(secret_get('FOO'))
```

    bar

[`secrets`](https://vedicreader.github.io/dockeasy/core.html#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.

``` python
# Read multiple secrets at once — missing keys are silently skipped
secret_set('DATABASE_URL', 'postgresql://...')
cfg = secrets('FOO', 'DATABASE_URL', 'MISSING_KEY')
cfg
```

    {'FOO': 'bar', 'DATABASE_URL': 'postgresql://...'}

## Reverse proxy

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`](https://vedicreader.github.io/dockeasy/proxy.html#caddy) returns a [`Caddyfile`](https://vedicreader.github.io/dockeasy/proxy.html#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:

``` python
# Minimal: auto-TLS via Let's Encrypt
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, which is why [`caddy_svc`](https://vedicreader.github.io/dockeasy/proxy.html#caddy_svc) below switches to an image that has the Cloudflare one.

``` python
# DNS-01 wildcard cert — works even when port 80 is closed
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
    }

### Running Caddy behind a Cloudflare Tunnel

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`](https://vedicreader.github.io/dockeasy/proxy.html#caddy_svc) writes the Caddyfile to `conf` and returns the settings for a Caddy service, ready to pass to [`Compose.svc`](https://vedicreader.github.io/dockeasy/core.html#compose.svc) with `**`. With `cloudflared=True` the Caddyfile serves plain `http://` and the service publishes no ports. [`cloudflared_svc`](https://vedicreader.github.io/dockeasy/proxy.html#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`](https://vedicreader.github.io/dockeasy/proxy.html#crowdsec) on the proxy page.

``` python
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

## Next steps

The two module pages go further, and every example on them runs as a test.

- [core](core.html) covers the whole [`Dockerfile`](https://vedicreader.github.io/dockeasy/core.html#dockerfile) and [`Compose`](https://vedicreader.github.io/dockeasy/core.html#compose) API, the ready-made builders, the container functions and the config store.
- [proxy](proxy.html) 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.
