Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Define your development tasks — build, test, lint, a database to develop against, … — once in configuration, and run them identically on any machine that has Docker. No “works on my machine”, no per-developer setup drift, no JVM to start: Ratect is a single native binary, so it runs instantly.

If that sounds like Batect, that’s deliberate: Batect was archived in October 2023, and Ratect grew out of it — same batect.yml, running on a native binary instead of a JVM one. But it’s not just a port: Ratect already fixes real bugs Batect never got to (leaked containers and networks on anything but Ctrl+C, an eight-year-old open proxy issue) and adds tooling Batect never had — ratect doctor, ratect resources, a native ratect.toml format. See Differences from Batect for the full list, drop-in replacement included. It’s an independent project, not affiliated with or endorsed by the original Batect project.

Source and issue tracker: or1can/ratect.

Installation

Prebuilt binaries

Every tagged release of ratect-compat or ratect publishes prebuilt binaries as GitHub Release assets, for five platforms:

Target triplePlatform
x86_64-unknown-linux-gnux64 Linux
x86_64-unknown-linux-muslx64 MUSL Linux (Alpine, minimal containers)
aarch64-unknown-linux-muslARM64 MUSL Linux (ARM servers, Raspberry Pi)
x86_64-apple-darwinIntel macOS
aarch64-apple-darwinApple Silicon macOS

ratect-compat and ratect are tagged and released independently (see ROADMAP.md), so they’re listed as separate entries on the Releases page — tags look like ratect-compat/vX.Y.Z and ratect/vX.Y.Z. Find the most recent tag for the binary you want, then download the archive matching your platform from that release’s assets.

Each archive extracts to a directory (named after the archive itself) containing the binary alongside LICENSE/NOTICE/README.md/RELEASES.md — the binary isn’t at the archive’s top level:

tar -xf ratect-compat-x86_64-unknown-linux-gnu.tar.xz
mv ratect-compat-x86_64-unknown-linux-gnu/ratect-compat ~/.local/bin/

(substitute the archive name for your platform and binary; ~/.local/bin assumes it’s already on your PATH — use whatever directory you normally install user binaries into.)

macOS: Ratect’s binaries aren’t code-signed or notarized. If macOS refuses to run the extracted binary (“cannot be opened because the developer cannot be verified” or similar — some download methods, like a browser, mark a downloaded file quarantined; others, like curl, don’t), clear it:

xattr -d com.apple.quarantine ratect-compat-x86_64-apple-darwin/ratect-compat

Homebrew

Each release also publishes a formula to a shared tap (or1can/homebrew-tap) for both binaries:

brew install or1can/tap/ratect-compat
brew install or1can/tap/ratect

Install script

Each release also publishes a shell installer that downloads, verifies, and extracts the right archive for your platform in one step:

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/or1can/ratect/releases/download/ratect-compat/vX.Y.Z/ratect-compat-installer.sh | sh

Substitute the tag (ratect-compat/vX.Y.Z or ratect/vX.Y.Z) for the release you want, and ratect-compat-installer.sh/ratect-installer.sh for the matching binary — see the Releases page for both. Installs to $CARGO_HOME/bin (or $HOME/.cargo/bin), adding that directory to PATH via your shell profile if it isn’t already there.

Verifying a download

Each release also includes a sha256.sum covering every archive and the source tarball, and every archive/SBOM/sha256.sum itself carries a GitHub Artifact Attestation confirming it was built by Ratect’s own CI from the tagged source, not tampered with in transit:

# Checksum (Linux: sha256sum -c sha256.sum). A trailing blank line in the
# file itself makes shasum warn "1 line is improperly formatted" — harmless,
# every real entry still verifies.
shasum -a 256 -c sha256.sum

# Provenance (requires the GitHub CLI, `gh`)
gh attestation verify ratect-compat-x86_64-unknown-linux-gnu.tar.xz --repo or1can/ratect

Not yet available

cargo-binstall needs Ratect published to crates.io to discover a release automatically, which is itself blocked — see decisions/0010’s crates.io deferral.

Building from source

The from-source path below is for contributors, or anyone on a platform without a prebuilt binary.

Prerequisites

  • Rust (stable toolchain)

  • Docker, running and reachable via the default local socket (Ratect connects the same way the docker CLI does — no extra configuration needed for a standard Docker install).

    Docker 20.10 or newer. Ratect negotiates the Docker Engine API version against your daemon at connection time, downgrading to whatever it offers, so any recent Docker install works with no extra configuration. 20.10 (December 2020) is Ratect’s own floor below that negotiation — the oldest release its features actually require, for the host-gateway sentinel behind proxy support — and a daemon older than that is refused with a clear error naming both its version and the one required, rather than some later request failing for an unexplained reason. Check yours with docker version --format '{{.Server.APIVersion}}' (API 1.41 corresponds to Docker 20.10).

Build from source

Clone the repository, then build a release binary. The workspace has two binary crates (see Roadmap) — ratect-compat is the one that implements Batect-compatible behavior today:

git clone https://github.com/or1can/ratect.git
cd ratect
cargo build --release -p ratect-compat

The compiled binary will be at target/release/ratect-compat.

Install the binary onto your PATH

To make ratect-compat available as a regular command:

cargo install --path ratect-compat

This installs to ~/.cargo/bin (assumed to already be on your PATH, which is the default for a standard rustup install).

Verify the install

ratect-compat --version
ratect-compat --help

Development builds

If you’re working on Ratect itself rather than just using it, a debug build is faster to compile and sufficient for local testing:

cargo build --workspace
cargo run -p ratect-compat -- --list-tasks

See AGENTS.md for the full contributor-facing tooling setup (formatting, linting, tests, coverage, dependency auditing).

Getting Started

This walks through defining and running your first task with Ratect. It assumes you’ve already installed Ratect and have Docker running.

1. Create a batect.yml

Ratect reads its configuration from a batect.yml file in the current directory (or wherever you point -f/--config-file — see the CLI reference).

project_name: my-project
containers:
  build-env:
    image: alpine:3.18
    volumes:
      - .:/code
tasks:
  test:
    run:
      container: build-env
      command: ls /code

This defines one container (build-env, based on the alpine:3.18 image, with the current directory mounted at /code) and one task (test, which runs ls /code inside that container).

See the configuration reference for the full schema.

2. List available tasks

ratect-compat --list-tasks
Tasks in my-project:
- test

3. Run a task

ratect-compat test

The first run pulls the alpine:3.18 image (printing “Pulling alpine:3.18…” / “Pulled alpine:3.18.” around it), then creates, starts, and runs the container. Whatever the container writes to stdout/stderr is streamed live and printed as-is — that’s the actual output of your task — framed by Ratect’s own progress lines (“Running test…”, then a “finished with exit code 0” summary).

4. Prerequisites

Tasks can depend on other tasks, which run first:

tasks:
  build:
    run:
      container: build-env
      command: echo "building..."
  test:
    run:
      container: build-env
      command: echo "testing..."
    prerequisites:
      - build

Running ratect-compat test runs build first, then test. Within a single ratect-compat invocation:

  • Each task runs at most once, even if it’s a prerequisite of more than one other task.
  • Each container image is pulled at most once, even if multiple tasks use it.
  • A prerequisite cycle (e.g. a depends on b, b depends on a) is detected and reported as an error rather than hanging.

See how it works for the details.

5. Environment variables and expressions

Containers and individual task runs can set environment variables, and their values can pull in a host environment variable or a declared config variable instead of being written as a literal:

config_variables:
  environment_name:
    default: dev
containers:
  build-env:
    image: alpine:3.18
    environment:
      GREETING: "hello-${WHO:-world}"
tasks:
  test:
    run:
      container: build-env
      command: echo "$GREETING in $ENVIRONMENT_NAME"
      environment:
        ENVIRONMENT_NAME: <environment_name

Running ratect-compat test (with WHO unset in your shell) prints hello-world in dev. Override the config variable from the command line instead of relying on its default:

ratect-compat --config-var environment_name=staging test

See the configuration reference for the full expression syntax (including batect.project_directory, always available without being declared) and the CLI reference for --config-var/ --config-vars-file.

6. Reading the output

Ratect separates two kinds of output:

  • stdout: the actual output of your command — this is what your task produces, and what --list-tasks prints. Safe to pipe or redirect.
  • stderr: Ratect’s own diagnostics (task lifecycle messages, warnings, errors), logged via tracing. Control verbosity with the RUST_LOG environment variable, e.g.:
RUST_LOG=debug ratect-compat test

debug also surfaces low-level Docker API activity (container create/start/remove), which is useful when troubleshooting.

How It Works

This describes Ratect’s internal pipeline, for anyone extending Ratect or trying to understand its behavior in detail. For the code itself, see AGENTS.md for a map of the source layout.

1. CLI parsing

Each binary has its own main.rs, and both parse arguments with clap: ratect-compat/src/main.rs for Batect’s flat flag interface (CLI reference), and ratect/src/main.rs for the subcommand interface (ratect CLI reference). Everything below this step is shared — both call into ratect-core, which is where the rest of this document lives. Parsing has to happen before config resolution (step 2) can finish, since --config-var/--config-vars-file feed into it.

2. Config loading and resolution (ratect-core/src/config.rs)

This is two separate steps, not one, because the second depends on CLI flags that aren’t known at the first:

  1. Config::load_from_file (or load_from_file_native, for ratect.toml): the root file is parsed into Config/Container/Task/TaskRun/ConfigVariable structs — YAML via noyalib, TOML via toml, one struct set for both formats — and its top-level include list (if any) is resolved, with every loaded file’s containers/tasks/config_variables merged into one Config (see Includes). No expression interpolation yet.

    Includes are walked breadth-first, so every entry in the root file is reached before any included file’s own, and each file is loaded exactly once however many entries name it. A type: git entry clones its repository into ~/.ratect/incl first (ratect-core/src/git_include.rs), and everything reached through one is confined to that clone and may do only what the entry granted it — see Git includes for the rules, and CONTEXT.md for what bundle, grant and boundary each denote.

    The result is a LoadedConfig: the merged Config, plus two maps step 2 needs — container_base_paths, recording which directory each container came from, and container_boundaries, recording the clone each container reached through a Git include must resolve its host paths within.

  2. LoadedConfig::resolve_expressions: called once, after --config-var/--config-vars-file have been parsed and merged into an overrides map — from load_project/load_project_native in config.rs, which run both steps in order so neither binary has to know the order. In one pass:

    • Resolves expressions ($VAR, ${VAR:-default}, <name, <{name}, plus the built-in batect.project_directory) in every field that takes one: environment values (container and task run), local volume mount host paths, build_directory, build_args, a build_secrets entry’s path, a build_ssh entry’s paths, run_as_current_user.home_directory, and — ratect.toml only — image. A cache mount’s name/container are plain strings, matching Batect: nothing to interpolate (see Cache volumes).

    • Volume path resolution: after interpolating a local mount’s host path, if the result is relative, it’s resolved to an absolute path relative to that container’s own origin file’s directory (via container_base_paths — the root config’s directory when there’s no include involved), not the current working directory — done in this order (interpolate, then resolve) because an expression can itself resolve to an absolute path, which mustn’t be treated as a relative fragment. batect.project_directory itself always resolves to the root config’s directory regardless of which file a container came from. A cache mount’s Docker volume name/host directory is resolved later instead (ratect-core/src/cache.rs, via engine.rs’s resolve_volumes), once --cache-type and the project’s own cache key are known — neither available at this stage.

      The resolved path is then checked against container_boundaries: a container that came from a Git-included file may only reach inside its own clone or your project directory, unless that include was granted allow_host_paths. Checked twice: once lexically, with both the check and the path normalized first, since Path::starts_with does not interpret ..; then against the real locations, with symlinks resolved as far as the path exists, since a bundle can commit one inside its own clone.

    See the configuration reference for the full expression syntax, precedence, and error rules.

3. Task engine (ratect-core/src/engine.rs)

TaskEngine::run_task(name) is a recursive async function. The order of its five steps is the part worth knowing here; for what each one does in detail, read engine.rs’s own module comment (cargo doc --open -p ratect-core), which is where that lives and stays current.

  1. Already executed? If this task has already run successfully in this invocation, return immediately. This is what makes shared prerequisites run only once.
  2. Cycle detection: a task already in the middle of being run — an ancestor of itself in the current call stack — errors immediately rather than recursing forever.
  3. Run prerequisites, each through the same recursive function, before the task’s own container step. They run with top_level: false, unlike the task actually named on the command line, and that flag is the whole of interactive-TTY eligibility: a prerequisite chain isn’t the thing being run interactively, so only the originally-requested task’s own container is ever eligible, however deeply nested its prerequisites are. A task with no run of its own stops here and succeeds — it exists purely to chain prerequisites (see config reference), matching Batect’s own TaskRunner.
  4. Create the task’s network and start everything in the task’s container graph on it, before the task’s own container, so it can reach them by name. The graph is the container’s own dependencies unioned with the task’s, resolved recursively, with any customise overrides applied to whichever container they target at whatever depth. Every task execution gets its own network — a task’s container is never left on Docker’s shared default bridge — and it is torn down afterwards. With --use-network an existing network is validated and reused instead, and never removed, since Ratect didn’t create it. See the task lifecycle for the step-by-step and diagrams.
  5. Resolve and run the image. resolve_image turns a container’s image or build_directory into something runnable — pulling (per image_pull_policy) or building, or erroring if neither is set — and is used identically for the task’s own container and for dependencies. The container then runs with the task’s command, joined to the task’s network, its environment layered host TERMproxy variables → the container’s environment → the task’s run.environment, each winning over the last. Everything else on the container — ports, hostnames, working directory, entrypoint, capabilities, devices, and the rest — is assembled here from the config and handed to docker.rs as plain values; the config reference is the list of what those fields mean, and which accept a task-level run override.

The “run once”, “pull once” and “build once” guarantees are in-memory and scoped to a single invocation — nothing persists between runs, so a build_directory container is rebuilt every time. Pulls and builds are memoized as shareable futures rather than plain sets, so two containers resolving the same image share one in-flight operation instead of racing. Dependency readiness, by contrast, is scoped to a single task execution and discarded when it finishes.

Concurrency follows Batect exactly: prerequisites run sequentially, one to completion after another, even when independent — while one task’s dependency startup is concurrent, with independent branches of its graph pulling, building, starting and health-waiting at the same time, gated only on each container’s own dependencies being ready. Running independent prerequisites concurrently too is a possible Rust-specific enhancement beyond Batect — see the roadmap — and task lifecycle has the detail.

Testability

The engine talks to Docker through a ContainerRuntime trait (defined in ratect-core/src/docker.rs) rather than depending on the concrete Docker client directly. This is what lets the engine’s prerequisite/cycle/dedup logic be unit-tested with a fake implementation instead of a real Docker daemon.

4. Docker integration (ratect-core/src/docker.rs)

DockerClient wraps bollard and implements ContainerRuntime. What each method is for:

  • pull_image and build_image: fetch or build the image a container needs, streaming the daemon’s progress to the output layer as events — what, if anything, those render as is the selected output style’s decision, not docker.rs’s (see Logging vs. output). build_image first packs the build directory into an in-memory tar, honouring .dockerignore via the dockerignore crate.
  • run_container: creates, starts and streams the task’s own container until it exits. Three start/attach paths sit behind it — fully non-interactive, stdin-forwarding, and a real TTY with raw mode and live resize — chosen by whether the task is interactive-eligible and whether Ratect’s own stdin and stdout are terminals. It does not remove the container: the engine’s cleanup stage removes everything a task created, its own container included, so --no-cleanup-* is interpreted in exactly one place.
  • create_network / remove_network / network_exists: the per-task network, plus the up-front validation that makes --use-network fail with a clear error rather than an unrelated API failure later.
  • start_background_container / stop_and_remove_container: the same, for a dependency or sidecar — started and left running alongside the task rather than waited on, so no logs are streamed and no task command applies.
  • wait_for_container_healthy / exec_in_container: the two halves of the dependency readiness gate. The first blocks on Docker’s own event stream, replayed from the beginning so a verdict that arrived before the stream opened still counts, and turns an unhealthy verdict into an error carrying the last health check’s exit code and output. The second runs one setup_commands entry in the running container and returns its exit code and output for the engine to judge.

Nothing here depends on config types: engine.rs converts config into plain values first, which is why the same options struct serves both container methods. The module’s own comment carries the gotchas — where each path calls Docker’s start relative to attaching, why cleanup ownership is not to be split again, how build_ssh paths are classified — and is the thing to read before changing any of this.

Container creation/start/removal events are logged at debug level via tracing (see below) — not shown by default, but useful with RUST_LOG=debug. This includes each setup_commands exec’s raw output, which is whatever the command itself printed — so if a setup command’s own output could include something sensitive (a failed connection string, a verbose HTTP client dumping request headers), that ends up in the debug log too. Treat RUST_LOG=debug (or narrower ratect_core=debug) output with the same care you’d give the command’s own output before pasting it into a support ticket, chat message, or CI log.

5. Logging vs. output

Ratect keeps two channels deliberately separate:

  • stdout: the task’s user-facing output — container log output, --list-tasks listings, and Ratect’s own progress lines (“Running build…”, “Pulling alpine:3.18…”, “build finished with exit code 0 in 2.3s.”), matching where Batect puts them. Internally these progress lines are typed events (ratect-core/src/ui/): engine.rs and docker.rs post task-execution milestones to an event sink instead of printing, and the selected output style (--output/-o) decides what each event renders as — fancy’s live per-container status block on an interactive terminal, simple‘s plain append-only lines otherwise, nothing at all under quiet (whose stdout is then exactly the containers’ own output, safe to pipe), or all’s per-container prefixed lines (the one style where even container stdout routes through the event sink, line-buffered, instead of streaming to stdout directly).
  • stderr: Ratect’s own diagnostics, via tracing / tracing-subscriber, filtered by RUST_LOG (defaults to info) — except a fatal error (the reason the process is about to exit non-zero), which main.rs prints directly (Error: <message>) rather than through tracing::error!: it must stay visible even when RUST_LOG suppresses everything else, since there’d otherwise be no visible explanation at all for the failure under RUST_LOG=off combined with -o quiet.

Colors (e.g. the exit code in the task summary line) are only emitted when stdout is actually a terminal — piped or redirected output gets plain text.

Filtering RUST_LOG

RUST_LOG isn’t just an on/off level switch — tracing-subscriber’s EnvFilter syntax lets you scope it to specific modules (target=level directives, comma-separated). This matters in practice once you turn on debug for anything build-related (e.g. to see a live image build transcript): bollard (the Docker API client Ratect is built on) also logs at debug, and a bare RUST_LOG=debug includes all of its raw API traffic — usually far more noise than signal.

A directive with no target (e.g. RUST_LOG=debug) applies everywhere, including dependencies like bollard. Scoping to a specific target instead — ratect_core covers everything Ratect itself logs — excludes anything not matched, including bollard, without needing to name it:

# Only ratect_core's own logs, at debug — no bollard noise at all.
RUST_LOG=ratect_core=debug ratect-compat -f batect.yml build

# Keep the normal `info` default everywhere else, but add ratect_core's debug-level
# output on top (e.g. build transcripts) — usually the more useful combination.
RUST_LOG=info,ratect_core=debug ratect-compat -f batect.yml build

# Narrower still: just the Docker/build/container-runtime module, not task
# orchestration (`ratect_core::engine`) as well.
RUST_LOG=ratect_core::docker=debug ratect-compat -f batect.yml build

If you do want a blanket debug sweep across everything (including bollard) but need to silence one specific dependency, add it as its own =off directive instead: RUST_LOG=debug,bollard=off.

Task Lifecycle

This is the detailed, step-by-step version of what ratect-compat <task> actually does, covering dependency (sidecar) container resolution and cleanup in depth. For the broader architecture (config loading, CLI parsing, logging), see how it works; this page is the equivalent of Batect’s own task lifecycle page, describing Ratect’s own (deliberately simplified) version of the same idea.

Task ordering

Ratect only ever runs one task’s containers at a time. A task’s prerequisites just order sequential task executions — each prerequisite task runs to completion (including its own cleanup, described below) before the next one starts, and before the originally-requested task itself runs.

tasks:
  compile:
    run:
      container: build-env
      command: ./build.sh
  test:
    prerequisites:
      - compile
    run:
      container: build-env
      command: ./test.sh

Running ratect-compat test here runs compile to completion first, fully cleaning up after it, then runs test.

A task doesn’t strictly need a run of its own — a task with only prerequisites is valid (see config reference), and exists purely to chain other tasks together:

tasks:
  ci:
    prerequisites:
      - compile
      - test

Running ratect-compat ci here runs compile then test to completion, same as above, then stops — there’s no container of ci’s own left to run.

Per-task steps

Every task execution gets its own Docker network, whether or not its container declares dependencies — so a task’s container is never left running on Docker’s shared default bridge network, reachable by or able to reach anything else on the host. If the container does declare dependencies, those are started on that network before the task’s own container, so the task’s container can reach them by name — and so is anything named in the task’s own dependencies (sidecars scoped to this task specifically, distinct from the container-level field — see config reference), unioned in alongside the container-level ones. All of this — network, dependencies, and the task’s own container — is scoped to this one task execution and torn down before moving on, regardless of whether the task succeeded — unless --no-cleanup/--no-cleanup-after-failure/ --no-cleanup-after-success says otherwise, in which case everything below is left genuinely running instead, for investigation (see CLI reference):

sequenceDiagram
    participant Engine as TaskEngine
    participant Docker
    participant Dep as Dependency container(s)
    participant Main as Task's own container

    Engine->>Docker: create_network()

    par independent branches of the dependency graph
        Engine->>Docker: pull_image()/build_image() (per image_pull_policy, unless already decided this run)
        Engine->>Docker: start_background_container(alias, network)
        Docker-->>Dep: created, started, joined to network
        Engine->>Docker: wait_for_container_healthy()
        Docker-->>Engine: healthy (immediate if no health check)
        loop for each setup command, in declared order
            Engine->>Docker: exec_in_container(command)
            Dep-->>Engine: exit code 0 (non-zero fails the task)
        end
    end

    Note over Engine: a container with dependencies of its own doesn't start<br/>its own branch above until all of them are ready

    Engine->>Docker: pull_image() (task's own image, per image_pull_policy, unless already decided)
    Engine->>Docker: run_container(name, network)
    Docker-->>Main: created, started, joined to network
    Main-->>Engine: runs to completion, logs streamed live to stdout

    Note over Engine: cleanup — runs even if the task's container failed,<br/>unless --no-cleanup* says otherwise
    Engine->>Docker: stop_and_remove_container() for the task's own container
    Engine->>Docker: stop_and_remove_container() for each dependency
    Engine->>Docker: remove_network()

If the container has no dependencies, the dependency steps (the loop above) are skipped — but the network is still created and the task’s own container still joins it, isolating it just the same as a task with dependencies.

--no-cleanup-after-failure skips the cleanup step above for a genuine infrastructure failure (a build/pull/health-check/setup-command failure, or anything else before the task’s own container gets to run); --no-cleanup-after-success skips it when the task’s own container ran to completion instead, regardless of its exit code (a non-zero exit is still “success” for this purpose — it’s the task’s own container actually running that matters, not what it returned); --no-cleanup is both at once. Either way, everything above is left genuinely running, not just present-but-stopped — see CLI reference.

pull_image() in the diagram above is conditional on image_pull_policy (see config reference): IfNotPresent, the default, checks whether the image already exists locally first and skips the pull entirely if so; Always skips that check and pulls unconditionally. Either way, the decision (pull or don’t) is made once per image name per ratect invocation, same as before this field existed — a dependency and the task’s own container sharing an image name don’t re-decide for each other.

Passing --use-network <name> skips network creation and teardown entirely for every task in this invocation: the named network is checked to exist up front (a clear error if it doesn’t), and reused instead — dependencies and the task’s own container all join it exactly as they would a freshly-created one, but it’s never removed at cleanup, since Ratect didn’t create it. See CLI reference.

Dependency resolution

Dependencies are resolved concurrently, gated by readiness: a container with dependencies of its own never starts before every one of them is ready (see below), but two containers with no dependency relationship to each other start at the same time rather than one after the other. For example:

containers:
  app:
    image: my-app
    dependencies:
      - database
  database:
    image: postgres:16
    dependencies:
      - cache
  cache:
    image: redis:7-alpine
graph TD
    app["app (task's container)"] --> database
    database --> cache

Running a task against app starts cache first (nothing else is holding it back), then database once cache is ready, then app once database is ready — a straight chain, so each one is genuinely waiting on the last. All three share one network and are reachable by their container-config name (e.g. app’s command can reach database:5432 and cache:6379).

Add a second container that also depends on cache — say queue, also one of app’s dependencies, but with no relationship to database — and cache is now a shared dependency of two others, forming a diamond rather than a straight chain:

graph TD
    app["app (task's container)"] --> database
    app --> queue
    database --> cache
    queue --> cache
sequenceDiagram
    participant Engine as TaskEngine
    participant Cache as cache
    participant Database as database
    participant Queue as queue
    participant App as app (task's container)

    Note over Engine: cache has no dependencies of its own — starts immediately
    Engine->>Cache: start, wait for healthy, run setup commands
    Note over Cache: ready

    par database and queue both depend only on cache — start together,<br/>the moment it's ready, not one after the other
        Engine->>Database: start, wait for healthy, run setup commands
        Note over Database: ready
    and
        Engine->>Queue: start, wait for healthy, run setup commands
        Note over Queue: ready
    end

    Note over Engine: app depends on both database and queue —<br/>waits for whichever is slower before starting
    Engine->>App: start (runs to completion)

cache is only ever started once, even though both database and queue depend on it: whichever of the two reaches it first triggers the actual start, and the other waits on that same in-flight readiness rather than starting a second instance or pulling its image twice (see below — this holds generally, not just for a leaf like cache). database and queue then genuinely overlap in time — both start the moment cache’s readiness gate has actually passed, not just once its container exists, and neither waits on the other since they share no relationship. app is gated on whichever of the two takes longer, not just the first one to finish.

This concurrency is unbounded by default — every independent branch’s pull/build, create+start, and setup commands can all be in flight at once, across the whole invocation, not just within one task. --max-parallelism <N> caps it: at most N of those specific operations run at a time, invocation-wide. The health-check wait itself is deliberately not capped (it’s a polling wait, not real work), so two dependencies can still become healthy at the same time even under a low cap — only the pull/build/ start/setup-command steps queue up behind it. See CLI reference and differences from Batect for exactly what’s covered.

A task’s own dependencies (sidecars scoped to that task specifically) join this same resolution at the root, alongside app’s own — each still resolves its own container-level dependencies transitively from there, same as any other dependency, and is just as eligible to start concurrently with an unrelated branch. And a task’s customise map, if it has one, is checked against whichever dependency is starting: a match overrides that container’s environment/ports/ working_directory for this task’s run of it specifically (merged the same way a task’s own run overrides its main container — see config reference), before it starts, regardless of how deep in this graph it sits.

Started isn’t ready, though: each dependency must become ready before whatever depends on it starts — it must report healthy (immediately so for a container with no Docker health check at all, from neither its image nor the health_check field), and then every one of its setup_commands must succeed, in declared order. In the example above, database’s migrations (a setup command) provably finish before app’s command gets to run. A dependency that’s reported unhealthy — or that exits before a verdict, or whose setup command exits non-zero — fails the task; already-started containers are still cleaned up as usual.

Health is a one-time gate in this sequence, not ongoing monitoring: Ratect waits for Docker’s first health verdict and never re-checks — matching Batect, a dependency that turns unhealthy after its dependents have started doesn’t affect the rest of the task, even though Docker itself keeps running the check for the container’s whole lifetime. How long the wait for that first verdict can take (and why an unhealthy verdict can’t arrive quickly) is Docker’s own verdict lifecycle — see How Docker reaches its verdict in the config reference.

Not re-checking health doesn’t mean staying silent, though: a dependency that has already become ready and then exits on its own — while the task’s own command, or a later dependency’s own health/setup wait, is still going — prints a warning naming the container and its exit code, in every output mode. Without it, that container’s own death would otherwise surface later as a confusing symptom in whatever depended on it (a connection refused, a timeout) rather than the real cause. This is a notification only — the run isn’t failed or stopped because of it — and it’s never printed for a container cleanup itself stops: Ratect stops watching a dependency for this the moment the task’s own execution finishes, strictly before cleanup ever touches a container.

More generally, within one task’s resolution any dependency shared by two others — not just a leaf like cache above — is only ever started once, no matter how many dependents reach it or how deep in the graph they sit, including when they reach it genuinely concurrently: the second to arrive waits on the first’s already-in-flight readiness rather than starting a second instance or double-pulling its image. A circular container dependency (a depends on b depends on a) is detected up front, before any container starts, and reported as an error rather than hanging.

Cross-task isolation

Because dependency resolution is scoped to a single task execution, two different tasks that each depend on the same container name get their own separate instance — nothing is shared or deduped across tasks, even within one ratect invocation:

tasks:
  migrate:
    run:
      container: app
      command: run-migrations.sh
  test:
    prerequisites:
      - migrate
    run:
      container: app
      command: run-tests.sh

Both migrate and test here depend on database (via app’s container config). Running ratect-compat test starts a database instance, its own network, runs migrate, cleans both up — then starts a second, independent database instance and network for test. This matches Batect’s own documented behavior (“each task will start its own instance of each container, even if multiple tasks share the same container”) and is also what makes concurrent ratect invocations on the same host safe: each task execution’s network is named with a random UUID, so there’s no risk of two runs colliding.

Known simplifications relative to Batect

  • The task’s own container’s readiness gate can race a fast main command. Since 0.21.0, the task’s own container goes through the same readiness gate a dependency always has — health-check wait, then setup_commands, in order — run concurrently with its main command rather than gating anything on it (nothing else in the graph depends on the task container’s own readiness). A setup command or health-check failure fails the task even if the main command already succeeded. One race this doesn’t close, matching Batect’s own (its RunStage completion is driven purely by the container’s exit event, not its readiness): a main command that exits very quickly — especially with no health_check configured, since the readiness gate then starts its setup_commands almost immediately after the container starts — can finish before a setup_commands entry gets a chance to docker exec into it, surfacing Docker’s own “container is not running” error instead of that setup command’s actual outcome. In practice this only bites a near-instant main command; anything taking more than a few tens of milliseconds gives the setup command time to run and report its real result. Also unlike Batect: the main command itself is never cancelled early just because the readiness gate fails first — it always runs to completion, and the task is still reported as failed overall either way.
  • Prerequisite tasks stay sequential, matching Batect exactlyprerequisites entries run one after another, each to completion, never concurrently with each other or with the task that named them (see “Task ordering” above). This is Batect’s own behavior (TaskExecutionOrderResolver/SessionRunner), not a Ratect simplification — Batect doesn’t parallelize independent prerequisite tasks either. Running independent prerequisites concurrently remains a possible Rust-specific enhancement beyond Batect, tracked under Rust Enhancements, not something planned currently.
  • Minimal networking. The network created here exists only to make dependency containers reachable by name for the duration of one task (or, with --use-network, an existing network you reuse instead). It’s not the fully-configurable Docker networking Batect offers (custom drivers, other than by pre-creating the network yourself) — see differences from Batect.

CLI Reference

ratect-compat [OPTIONS] [TASK_NAME] [-- ADDITIONAL_ARGS...]

This reflects the flags Ratect actually implements today (ratect-compat/src/main.rs), not the full Batect CLI — see differences from Batect for what’s missing.

This is the ratect-compat binary, whose interface deliberately matches Batect’s. The forward-looking ratect binary has its own, subcommand-based interface — see the ratect CLI reference.

Options

Grouped by purpose, matching ratect-compat --help. The grouping is presentational only — every flag parses the same regardless of the section it’s listed under.

FlagShortDefaultDescription
--config-file <PATH>-fbatect.ymlPath to the configuration file to load.
--list-tasks-TList all tasks defined in the config file, then exit. Doesn’t run anything.
--help-hPrint help (auto-generated by clap).
--version-VPrint the Ratect version.

Configuration variables

FlagShortDefaultDescription
--config-var <NAME=VALUE>Sets a config variable’s value; repeatable. Takes precedence over --config-vars-file and the variable’s default.
--config-vars-file <PATH>batect.local.yml if it existsA flat YAML file of config variable name: value pairs, in the same format as batect.yml itself. Lower precedence than --config-var. When not given, defaults to batect.local.yml in the current directory if that file exists (an absent default file just means no overrides from a file, not an error) — matching Batect.

Task execution

FlagShortDefaultDescription
--use-network <NAME>Reuses an existing Docker network for every task in this invocation instead of creating (and removing) a fresh one per task. Errors clearly if the named network doesn’t exist. See task lifecycle.
--disable-portsDisables publishing of any container’s ports to the host, regardless of what’s configured.
--no-proxy-varsDon’t propagate proxy-related environment variables (http_proxy, https_proxy, ftp_proxy, no_proxy) to image builds or containers. See Proxy environment variables.
--skip-prerequisitesDon’t run the named task’s own prerequisites. Only ever affects the task actually named on the command line — if that task is itself reached as someone else’s prerequisite in a later invocation, this flag has no bearing on that.
--override-image <CONTAINER=IMAGE>Overrides the image used by CONTAINER; repeatable. Replaces the container’s image/build_directory and image_pull_policy entirely — the override is always pulled under the default IfNotPresent policy, regardless of what the container itself configures. Errors immediately if CONTAINER isn’t defined in the config.
--tag-image <CONTAINER=TAG>Tags the image built by CONTAINER with TAG, in addition to the default <project_name>-<container_name> tag; repeatable, and CONTAINER may be given more than once to apply multiple tags. Only valid for a container that actually builds an image — errors immediately if CONTAINER ends up using a pulled image (whether configured that way or via --override-image), and errors once the whole task (and its prerequisites) finishes if CONTAINER never actually ran.
--enable-buildkitUse BuildKit for image builds, taking precedence over the DOCKER_BUILDKIT environment variable — see config reference. No --disable-buildkit counterpart; force the classic builder via DOCKER_BUILDKIT=0/false instead.
--max-parallelism <N>unboundedCaps how many image pulls/builds, dependency container starts, and setup-command executions run concurrently across the whole invocation. Health-check waits and container stop/removal are never gated — see Differences from Batect.
--cache-type <volume|directory>volumeStorage mechanism for a cache volume mount (see Cache volumes): volume resolves it to a Docker named volume, directory to a host directory under <project_directory>/.batect/caches/<name>/. Has no effect on a config with no cache mounts — but does still select which storage --clean/--clean-cache act on.

Cleanup after a run

FlagShortDefaultDescription
--no-cleanupEquivalent to providing both --no-cleanup-after-failure and --no-cleanup-after-success.
--no-cleanup-after-failureIf an infrastructure error occurs (a build/pull/health-check/setup-command failure, or anything else before the task’s own container gets to run), leave every container and network created for that task in place instead of removing them, so the issue can be investigated. A task’s own container exiting non-zero is not “failure” for this purpose — see --no-cleanup-after-success. One divergence from Batect: containers are left genuinely running, not just present-but-stopped — see Differences from Batect.
--no-cleanup-after-successIf the task’s own container runs to completion — regardless of its exit code — leave every container and network created for that task in place instead of removing them. Same divergence as --no-cleanup-after-failure: left genuinely running, not stopped-but-present.

Docker connection

FlagShortDefaultDescription
--docker-host <HOST>Docker host to connect to, e.g. unix:///var/run/docker.sock or tcp://1.2.3.4:5678. Defaults to the DOCKER_HOST environment variable, then Docker’s own platform default (a Unix socket or Windows named pipe). Cannot be combined with --docker-context.
--docker-context <NAME>Docker CLI context to connect through — read from the Docker CLI’s own context store (~/.docker/contexts/, or --docker-config’s directory). Defaults to the DOCKER_CONTEXT environment variable, then the Docker CLI’s own active context (~/.docker/config.json’s currentContext). Cannot be combined with --docker-host. Errors clearly if the named context doesn’t exist in the store.
--docker-config <PATH>Directory containing the Docker CLI’s own configuration files (context store, config.json). Defaults to the DOCKER_CONFIG environment variable, then ~/.docker.
--docker-tlsUse TLS when connecting to the Docker host. Behaves identically to --docker-tls-verify — the daemon’s certificate is always fully verified; there is no way to skip verification. Cannot be combined with --docker-context.
--docker-tls-verifyUse TLS when connecting to the Docker host, verifying its certificate. Defaults to the DOCKER_TLS_VERIFY environment variable. Cannot be combined with --docker-context.
--docker-cert-path <PATH>Directory containing ca.pem/cert.pem/key.pem to authenticate to the Docker host and verify it, unless overridden individually by --docker-tls-ca-cert/-cert/-key. Defaults to the DOCKER_CERT_PATH environment variable, then ~/.docker. Cannot be combined with --docker-context.
--docker-tls-ca-cert <PATH>Path to the TLS CA certificate file used to verify the Docker host’s own certificate. Defaults to ca.pem in --docker-cert-path’s directory. Cannot be combined with --docker-context.
--docker-tls-cert <PATH>Path to the TLS certificate file used to authenticate to the Docker host. Defaults to cert.pem in --docker-cert-path’s directory. Cannot be combined with --docker-context.
--docker-tls-key <PATH>Path to the TLS key file used to authenticate to the Docker host. Defaults to key.pem in --docker-cert-path’s directory. Cannot be combined with --docker-context.

Cache management

FlagShortDefaultDescription
--cleanRemoves every one of this project’s own cache volumes/directories (per --cache-type) and exits — doesn’t run anything, and doesn’t need --config-file to actually exist. See Cache volumes.
--clean-cache <NAME>Removes just the named cache (repeatable) and exits, instead of every one of them. Given together with --clean, the explicit name(s) win — --clean’s own “everything” behavior only applies when --clean-cache is never given at all.

Output

FlagShortDefaultDescription
--output <STYLE>-oautoForces a particular output style for Ratect’s own progress reporting: fancy (a live-updating status block, one line per container), simple (plain, append-only milestone lines), quiet (error messages only, and a machine-readable --list-tasks format), or all (line-by-line output from every container, prefixed with its name — the only style that changes what the task command’s own output looks like; the others never touch it) — see Output styles. Unset means auto-select: fancy on an interactive console, simple otherwise.
--no-colorDisables colored output from Ratect itself (task command output is never affected). Colors are already skipped automatically when stdout isn’t a terminal, so this only matters on an interactive console — unless CLICOLOR_FORCE is also set, which forces them past that check regardless. Also makes simple the auto-selected output style. The NO_COLOR environment variable has exactly the same effect, if set, and always wins over CLICOLOR_FORCE.
--log-file <PATH>Writes Ratect’s own internal logs to this file, in addition to stderr (both still governed by RUST_LOG — see Environment variables). Plain text, no ANSI color codes, regardless of stderr’s own coloring.

Recognized for Batect compatibility, no effect

--upgrade, --no-update-notification, and --no-wrapper-cache-cleanup are accepted but do nothing — hidden from --help, since they’re not real Ratect features, just recognized so an existing Batect invocation carrying one doesn’t hard-fail outright (before these were recognized, any of them caused a clap parse error that killed the entire invocation before anything ran at all, including --list-tasks). All three only make sense for Batect’s own self-updating wrapper script, which Ratect — a single native binary — doesn’t have and isn’t planning to grow. --upgrade specifically prints a one-line notice to stderr and exits 0 rather than running silently, since a user invoking it is likely expecting some visible response; reinstall or rebuild Ratect to get a newer version instead. The other two have no wrapper-cache/update notification to disable in the first place, so they’re silently accepted with no message at all.

Output styles

--output/-o controls how Ratect reports its own progress on stdout — never what the task’s command itself prints, which always streams through unmodified. The styles are Batect’s own four, all implemented:

  • fancy — a live status block, one line per container in the task’s dependency graph (<name>: <what it's doing right now> — pulling/building with live progress detail, waiting for dependencies, starting, waiting to become healthy, running setup commands, ready), repainted in place as events arrive. There is no spinner — the animation is purely rewriting changed lines, exactly like Batect. The moment the task’s own container starts, the block freezes behind a blank line and the container’s raw output streams below it untouched; after it exits, a single live Cleaning up: ... countdown line tracks teardown, then makes way for the final summary line. Lines are clipped to the terminal’s current width. Requires an interactive console — an explicit -o fancy without one fails up front with a clear error (Batect instead accepts it and crashes on the first repaint). Works with --no-color (the repaint stays; bold/color go — a combination Batect rejects).
  • simple — plain, append-only milestone lines: Running <task>..., Pulling <image>.../Pulled <image>., Building <container>.../Built <container>., dependency start/health/setup-command milestones, a blank line + Cleaning up..., and a final <task> finished with exit code <n> in <duration>. summary (the exit code green/red on a color-capable console). No live-updating progress detail at all — safe for CI logs and redirected output. The health/setup-command milestones are shown for dependency containers only: the task’s own container’s readiness runs concurrently with its command (see task lifecycle), so printing them would drop a line into the middle of that command’s own output — use all (below) to see them. A readiness failure is still reported, on stderr, in every style.
  • quiet — no milestone lines at all: stdout is exactly the containers’ own output, so it’s safe to pipe (error reporting stays on stderr, unchanged). Also switches --list-tasks to a machine-readable format: one task per line, sorted by name, as name alone or name<TAB>description — no header, no grouping.
  • all — every line of output prefixed with the container it belongs to (name | , padded to a common column, each container’s prefix in its own color), interleaved as it happens. The only style that shows dependency containers’ stdout/stderr, setup-command output (Setup command N | ...), and full image-build output (Image build | ...) — everything the other styles discard. In exchange, no container is interactive in this mode: the task container gets no TTY and no stdin, and every container gets TERM=dumb (matching Batect — a full-screen program can’t render into line-prefixed output). Task-level lines (the Running <task>... preamble, Cleaning up..., the summary) carry the task’s own name as their prefix.

When --output isn’t given, Ratect auto-selects: fancy on an interactive console (stdout a real terminal, TERM set and not dumb, terminal size queryable, no --no-color); simple otherwise. quiet and all are never auto-selected.

TLS with a private certificate authority

--docker-tls/--docker-tls-verify always fully verify the Docker daemon’s certificate — there is no flag or environment variable that skips verification, unlike Batect’s own bare --docker-tls (which sets Go’s tls.Config.InsecureSkipVerify, disabling chain-of-trust, expiry, and hostname checks all at once, not just the hostname check). This isn’t just inherited from a missing feature: rustls, the library Ratect’s TLS support is built on, takes the same position deliberately — there’s no boolean toggle for skipping verification in rustls either, only a dangerous() accessor that requires implementing the ServerCertVerifier trait from scratch to bypass it. Ratect doesn’t reach for that. If you’ve historically reached for --docker-tls (skip-verify) because your daemon’s certificate is self-signed — including for local development or CI — the fix isn’t to skip verification, it’s to make the certificate verifiable: run your own certificate authority, and trust that, rather than trusting nothing.

The daemon side of this (configuring dockerd to require TLS, generating its server certificate) is standard Docker documentation, not Ratect-specific — see Protect the Docker daemon socket. What follows is the client side: a self-contained, worked example of generating a private root CA, signing a server certificate for the daemon with it, and pointing Ratect at the result.

  1. Create a root CA. This is the one certificate you’ll trust from now on — keep ca-key.pem private; it’s the only thing standing between “verified” and “not”.

    openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
      -keyout ca-key.pem -out ca.pem -subj "/CN=my-docker-ca"
    
  2. Generate and sign the daemon’s own certificate, naming every hostname/IP clients will actually connect through as a Subject Alternative Name (SAN) — verification checks this, not the certificate’s CN:

    openssl req -newkey rsa:4096 -sha256 -nodes \
      -keyout server-key.pem -out server-req.pem -subj "/CN=docker-daemon"
    openssl x509 -req -in server-req.pem -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
      -out server-cert.pem -days 3650 -sha256 \
      -extfile <(printf "subjectAltName=DNS:docker-daemon.example.com,IP:203.0.113.10")
    
  3. Configure dockerd to require TLS with this certificate (/etc/docker/daemon.json or the equivalent dockerd flags — see the Docker documentation linked above), using ca.pem/server-cert.pem/server-key.pem from steps 1–2.

  4. Point Ratect at the CA (client certificate/key are only needed if the daemon itself also requires client auth — generate a second cert signed by the same CA for that, following step 2’s pattern):

    ratect-compat --docker-host tcp://docker-daemon.example.com:2376 \
      --docker-tls-verify \
      --docker-tls-ca-cert ./ca.pem \
      test
    

    Or set --docker-cert-path to a directory containing ca.pem (and cert.pem/key.pem, if the daemon requires client auth) instead of naming each file individually — see Options.

If verification fails, the error names the problem (expired, wrong host, untrusted issuer) rather than silently connecting anyway — that’s the entire point of not supporting skip-verify. Regenerate whichever certificate is actually at fault, rather than reaching for a flag Ratect doesn’t have.

Positional arguments

ArgumentDescription
TASK_NAMEThe name of the task to run, as defined under tasks: in the config file. Optional — if omitted (and --list-tasks isn’t given), Ratect logs a warning and exits without doing anything.
-- ADDITIONAL_ARGS...Anything after a literal -- is appended as literal argv entries after the task’s own tokenized command — see below. Only applies to the task named on the command line, never to its prerequisites.

Examples

# List tasks defined in ./batect.yml
ratect-compat --list-tasks

# Run a task from ./batect.yml
ratect-compat test

# Use a config file in a different location
ratect-compat -f ./ci/batect.yml build

# Pass extra arguments through to the task's command
ratect-compat test -- --verbose some/specific/file.rs

# Set a config variable referenced via `<name`/`<{name}` in `environment`
ratect-compat --config-var environment_name=staging test

# Load config variable values from a file instead
ratect-compat --config-vars-file ./ci/config-vars.yml test

Using ADDITIONAL_ARGS in a task command

run.command is tokenized into literal argv (quote/backslash-aware whitespace splitting, no shell involved — matching Batect’s own tokenizer exactly), and anything after -- is appended as further literal argv entries — no special syntax needed in command itself to receive them:

tasks:
  test:
    run:
      container: build-env
      command: cargo test

Running ratect-compat test -- --nocapture here runs cargo test --nocapture inside the container. Args are appended as literal argv entries (never concatenated into the command string and re-parsed), so they’re safe even if they contain characters that would be shell metacharacters elsewhere, like ;, &&, or backticks — Ratect never passes command/ADDITIONAL_ARGS through a shell at all.

If the task’s container has no command at all, ADDITIONAL_ARGS (when given) are passed directly as the container’s entrypoint arguments instead, matching plain docker run <image> <args>.

Exit codes and error reporting

Ratect uses a plain 0 (success) / non-zero (failure) convention, but note the current actual behavior — it doesn’t yet distinguish “nothing to do” from “success”:

  • Running with no task name at all (and not --list-tasks) currently exits 0 — Ratect logs a warning but doesn’t fail the process. This is a rough edge, not intentional design; don’t rely on it in scripts.
  • A missing or malformed config file (fails to parse), a task/container referenced by name that doesn’t exist, or a dependency cycle all cause a non-zero (1) exit. The error is printed to stderr as Error: <message> — deliberately not through tracing::error!/RUST_LOG (which every other diagnostic goes through — see how it works): a fatal error is the reason the process is about to exit non-zero, not an optional diagnostic, so it stays visible even under RUST_LOG=off or a filter that excludes Ratect’s own target — including under -o quiet, whose whole contract is “only error messages”.
  • A misspelled task name (whether given directly on the command line, or as a prerequisites entry) gets a Did you mean 'x'? suggestion appended to the error, for every existing task name within a Levenshtein edit distance of 3 — ported from Batect’s own TaskSuggester/EditDistanceCalculator (confirmed by reading Batect’s source). Multiple equally-close matches are all suggested, e.g. Did you mean 'build' or 'bulid'? — Batect’s own implementation can silently drop one of two equally-close suggestions (its sorting comparator doubles as its de-duplication key), which Ratect’s deliberately doesn’t replicate.
  • A run ended by a signal exits 128 + that signal’s own number130 for Ctrl+C (SIGINT), 143 for SIGTERM, 129 for SIGHUP — the shell’s own convention, so a script or CI job can tell a cancelled run apart from a failed one and tell what cancelled it. All three abandon the run and then clean it up — see Differences from Batect for the full behavior, including what --no-cleanup-after-failure does to it and what a second signal does.
  • A failing command inside the container fails the ratect process too, with the same exit code. Ratect waits for the container to exit and inspects its status — a task whose command is exit 42 makes ratect itself exit 42, matching docker run’s convention rather than collapsing every failure to a generic 1. A task that runs as a prerequisite and fails this way stops the rest of the chain immediately — no other prerequisites, and not the task that depended on it, will run — matching Batect’s documented behavior.
  • A crash (a genuine bug, not one of the above) exits 101, Rust’s own default for an unhandled panic. ratect-compat prints where to report it (https://github.com/or1can/ratect/issues/new), the binary’s version and platform, and — if RUST_BACKTRACE isn’t already set — a reminder to re-run with it set, since a backtrace makes a much more useful report.

Environment variables

VariableEffect
RUST_LOGControls log verbosity on stderr (error, warn, info [default], debug, trace) — and, if --log-file is given, the same file too. See how it works. Unlike Batect, Ratect always logs to stderr regardless of --log-file; Batect’s own default with no --log-file is silent. See Differences from Batect.
DOCKER_HOSTDocker host to connect to — see --docker-host.
DOCKER_CONTEXTDocker CLI context to connect through — see --docker-context.
DOCKER_CONFIGDirectory containing the Docker CLI’s own configuration files — see --docker-config.
DOCKER_CERT_PATHDirectory containing ca.pem/cert.pem/key.pem for TLS — see --docker-cert-path.
DOCKER_TLS_VERIFYEnables TLS (fully verified — see TLS with a private certificate authority) — see --docker-tls-verify.
DOCKER_BUILDKITForces the image builder on (1/true) or off (0/false) — see --enable-buildkit and config reference.
NO_COLORIf set (to anything — see no-color.org), has exactly the same effect as --no-color: disables colored output and makes simple the auto-selected output style.
CLICOLOR_FORCEIf set to anything other than 0, forces colored output even when stdout isn’t a terminal (e.g. a CI log viewer that renders ANSI despite the pipe) — but never affects output style selection, and never wins over --no-color/NO_COLOR if either is also set.

Ratect supports interpolating host environment variables and config variables into environment values, volume host paths, build_directory, build_args, build_secretspath, and a build_ssh entry’s paths in batect.yml ($VAR, ${VAR:-default}, <name — see config reference) — see differences from Batect.

Configuration Reference

Ratect reads a YAML file (batect.yml by default) describing containers and tasks. This documents the schema Ratect actually parses today (ratect-core/src/config.rs) — it is a subset of Batect’s configuration format. See differences from Batect for what’s not yet supported.

Standard YAML features — anchors (&name), aliases (*name), and merge keys (<<:) — work throughout the file, not just in specific fields: they’re core YAML syntax handled by the parser itself (noyalib) before any of the schema below ever sees the document, not something Ratect implements or could disable. Useful for factoring out a shared base container definition, for example:

containers:
  base: &base
    image: alpine:3.18
    environment:
      COMMON_VAR: shared-value
  worker:
    <<: *base
    working_directory: /worker

Extensions

An anchor has to live on something, and sometimes the value you want to share isn’t a container or task at all — a block of environment variables, say. For that, a top-level key whose name starts with . is an extension: Ratect ignores it entirely, so it can hold an anchor for the rest of the file to alias (matching Batect, which sets kaml’s extensionDefinitionPrefix to .).

.common-environment: &common-environment
  TZ: UTC
  CI: "true"

containers:
  build-env:
    image: alpine:3.18
    environment:
      <<: *common-environment
      EXTRA_VAR: extra-value

Only top-level keys are extensions. A .-prefixed key anywhere else is an ordinary field name, and rejected if it isn’t a real one — so a typo nested inside a container still gets caught.

Top level

project_name: my-project
containers:
  <name>: <Container>
tasks:
  <name>: <Task>
config_variables:
  <name>: <ConfigVariable>
FieldTypeRequiredDescription
project_namestringyesUsed only for display (e.g. in --list-tasks output).
containersmap of name → ContaineryesContainer definitions, keyed by name. Referenced from tasks via run.container.
tasksmap of name → TaskyesTask definitions, keyed by name. Run by name via ratect-compat <task-name>.
config_variablesmap of name → ConfigVariablenoDeclares the config variables usable via <name/<{name} expressions. A name must be declared here before it can be referenced — see Expressions.
includelist of string or IncludenoSplits configuration across multiple files — see Includes below.
forbid_telemetrybooleannoRecognized but inert — Ratect collects no telemetry, so there’s nothing to forbid.

Includes

include:
  - some-include.yml
  - path: some-other-include.yml
    type: file
  - type: git
    repo: https://github.com/my-org/my-batect-bundle.git
    ref: v1.2.3
    path: bundle.yml

Each entry is either a bare string path (a local file include), or an object form — {path, type: file} for another local file, or {type: git, repo, ref, path} for a Git include (a “bundle”: shared tasks/containers imported from a separate Git repository). Any other type is rejected with a clear error.

An included file uses the same schema as the root file, with two differences:

  • It must not declare project_name — that’s root-only.
  • containers, tasks, and config_variables may each be omitted entirely (they default to empty) — a file that exists only to include further files, or only to add one task, doesn’t need to restate the others.

Every loaded file’s containers, tasks, and config_variables are merged into one flat set. A name defined in more than one file is a hard error naming the conflicting files — it’s never treated as one file overriding another.

Relative paths within a container (a volume’s host path, build_directory, a build_secrets entry’s path) resolve against that container’s own origin file’s directory, not the root project directory. Use the built-in batect.project_directory config variable — which always resolves to the root’s directory regardless of which file a container is defined in — to reference the root project directory explicitly from an included file.

For example, a project laid out as:

myproject/
├── batect.yml
├── scripts/
└── containers/
    ├── extra.yml
    └── data/

Root batect.yml:

project_name: myproject
include:
  - containers/extra.yml
tasks:
  my-task:
    run:
      container: my-other-container

containers/extra.yml :

containers:
  my-other-container:
    image: alpine:1.2.3
    volumes:
      # containers/extra.yml's own directory is containers/, so this
      # resolves to myproject/containers/data — not myproject/data.
      - ./data:/data
      # Always the root project directory, regardless of which file this
      # is written in — so this is myproject/scripts, not
      # myproject/containers/scripts.
      - <{batect.project_directory}/scripts:/scripts

Running ratect-compat my-task from myproject/ starts my-other-container with those two volumes mounted exactly as resolved above — my-task itself lives in the root file, but the container it runs could equally have been declared there instead of in the include; only the volume paths’ own resolution depends on which file declares the container.

Local file includes

A local include’s path is resolved relative to the directory of the file that declares the includenot the root project directory — so an included file further down a subdirectory can itself include more files using paths relative to its own location. An already-loaded file (by resolved absolute path) is skipped rather than reloaded, so it’s safe for two files to both include a common third file.

Git includes

FieldTypeRequiredDescription
repostringyesA Git remote — anything git clone itself accepts (an HTTPS/SSH URL, or a local path).
refstringyesThe tag, branch, or commit to check out. Must be a value that never changes (in practice, an immutable tag or a pinned commit SHA, not a branch) — see below.
pathstringno, default batect-bundle.ymlThe path, within the repository, of the file to include — resolved relative to the repository’s own root, not the file that declared the include.

A (repo, ref) pair is cloned once and cached forever at ~/.ratect/incl/<hash>, keyed by a hash of the pair — it is never re-fetched, even if the remote’s ref later moves (e.g. a branch, or a tag someone re-pushed). This is why ref must be pinned to something immutable: Ratect has no update/refresh mechanism yet, matching Batect, whose own cache clones only when the working copy is missing too. Note that the 30-day eviction sweep doesn’t help here — it removes entries that go unused, and an include you’re actively using never becomes stale, so it stays frozen at whatever its ref pointed to when first cloned. If you need to pick up a change made to a bundle, choose a new ref (e.g. bump the tag) or delete the corresponding directory under ~/.ratect/incl by hand.

The included file’s own relative paths (a volume’s host path, build_directory, a build_secrets entry’s path, and any further include entries it declares) resolve against the cloned repository’s root, the same way a local include’s relative paths resolve against its own directory (see above) — just rooted at the clone instead of a directory in your project.

Containment: a Git include’s path, and every include entry declared (transitively) by the file it names, must resolve to somewhere inside that repository’s own clone — an absolute path, a ../.. traversal, or a symlink pointing back out are all rejected with a clear error rather than silently reading a file elsewhere on the machine running ratect. This matters because repo/ref may point at a repository you don’t fully control, unlike a local file include (which stays unrestricted, since it’s always something already in your own project checkout). A Git-included bundle can still declare a further type: git include of its own — that’s a fresh repository with its own boundary, not an escape from this one.

ratect.toml differs here. The native format refuses a bundle’s own Git includes unless you opt in per bundle with allow_nested_git_includes — see Nested Git includes. This page describes batect.yml, where they are always allowed, matching Batect.

The same containment applies to a volumes host path, build_directory, or build_secrets entry’s path declared by a container defined inside a Git-included file: it must resolve to somewhere inside that repository’s own clone, or inside your project directory — an absolute path, a ../.. traversal, or a symlink pointing back out of both is rejected the same way. A path Ratect cannot resolve at all is also rejected, rather than assumed harmless: if a directory along it can’t be searched, or a symlink loops, where the path really leads can’t be established — and the Docker daemon that would dereference it runs as root, under no such restriction. A path that simply doesn’t exist yet is fine; Ratect or Docker creates it. The project directory is allowed as a second root (rather than requiring pure containment within the clone) because referencing it explicitly via batect.project_directory (e.g. <{batect.project_directory}/output:/output) is a legitimate, common thing for a shared bundle to do — the project directory is your own fully-trusted tree, distinct from the repository the container definition itself came from.

Vouching for a bundle (allow_host_paths): some bundles legitimately need a path outside both roots — most often a shared tool cache under your home directory, like ~/.cache/trivy, so the cached data is reused across all your projects rather than re-fetched per project. Since neither customise nor a local redefinition can add a volume to someone else’s container, there’d otherwise be no way to use such a bundle at all. allow_host_paths: true on the include entry lifts the restriction for that bundle:

include:
  - type: git
    repo: https://github.com/my-org/infra-bundle.git
    ref: 1.2.3
    allow_host_paths: true

It applies only to the bundle named there — never to bundles it includes in turn — and is honoured only in your own configuration: the same flag written inside a Git-included file is ignored, so a bundle can’t grant itself the permission or pass it along. It doesn’t relax the include-path containment above either; that governs which files become part of your configuration, which is a separate question from where a container may mount. Full rationale, and what a future allowlist form would have to preserve, in decisions/0004.

Put a vouched-for include on the entry that reaches the file first. A repository is cloned once and each file in it read once, however many entries reach it, so whichever entry gets there first decides what that file is allowed — and a grant on the loser would quietly do nothing. Entries in the root configuration file are always reached before any bundle’s own, so declaring the include yourself beats a bundle to it; between two entries in the same file, the earlier one wins. Where two entries reach the same file and the losing one carries a grant, Ratect refuses to load, names the repository and gives the ordering rule above, rather than leaving you to wonder why the flag had no effect. It cannot name the winning entry for you, and that entry is often inside a bundle you can’t edit — in which case the move is to declare the include yourself, in your root file, where it gets there first. It is the file that races, not the repository: two entries naming the same repository with different paths pull in two different files, and each keeps the grant written on its own entry. Note the two outcomes are different: a grant written inside a bundle is ignored (accepted, worth nothing — see above), while one of your own that loses this race is refused outright.

Cloning requires the system git binary to be installed and on PATH — Ratect shells out to it (git clone --quiet --no-checkout followed by git checkout --recurse-submodules <ref>) rather than embedding a Git library, so submodules and any Git configuration (credentials, .gitconfig rewrites, etc.) that your normal git clone already relies on work the same way here.

Container

containers:
  build-env:
    image: alpine:3.18
    volumes:
      - .:/code
FieldTypeRequiredDescription
imagestringone of image/build_directoryA Docker image reference to pull and run (e.g. alpine:3.18). Exactly one of image/build_directory is required, and giving both is an error rather than one silently winning. image also can’t be combined with any build-only field (build_args, build_target, dockerfile, build_secrets, build_ssh) — those apply only to a container built from a build_directory. All of this is ratect-compat behaviour; ratect’s native format deliberately allows these combinations because extends needs them — see where the semantics differ. No expression support: Batect resolves none here, so one is rejected when the file loads rather than resolved, which would make the file unusable under batect itself. ratect.toml does resolve them — see Expressions in image.
image_pull_policyIfNotPresent or AlwaysnoOn an image container: whether to pull it fresh or only when it’s missing locally. On a build_directory container: whether to force-pull the build’s own base image (docker build --pull) before building, or leave Docker’s own local-cache-if-present behavior alone — Batect’s own second, distinct use of this same field. Defaults to IfNotPresent either way, matching Batect. No expression support.
build_directorystringone of image/build_directoryBuilds an image from a Dockerfile in this directory (see Image building below) instead of pulling a pre-built one. Supports expressions and is resolved to an absolute path the same way a volume’s host_path is — see Volume path resolution.
build_argsmap of string → stringnoBuild-time variables passed to docker build (Docker’s own --build-arg mechanism), e.g. VERSION: "1.2.3". Only meaningful alongside build_directory. Values support expressions.
dockerfilestringnoThe Dockerfile to build, as a path relative to build_directory’s own root. Defaults to Dockerfile at build_directory’s root. Only meaningful alongside build_directory. No expression support.
build_targetstringnoThe build stage to stop at (Docker’s own --target mechanism), for a multi-stage FROM ... AS <name> Dockerfile. Only meaningful alongside build_directory. No expression support.
volumeslist of strings/objectsnoHost bind mounts (local), named cache volumes (cache), and in-memory tmpfs mounts (tmpfs) — see Volume path resolution, Cache volumes, and Tmpfs mounts below.
dependencieslist of stringsnoNames of other containers to start (recursively, if they themselves have dependencies) before this one, reachable by name over a Docker network created for the duration of the task. Each dependency must become ready — healthy, with all its setup_commands completed — before its dependents start; see Dependency readiness below and the task lifecycle for the full model.
environmentmap of string → stringnoEnvironment variables to set in the container, e.g. FOO: bar. Values support expressions ($VAR, ${VAR:-default}, <name). A non-string scalar value (PORT: 8080, DEBUG: true) is accepted and coerced to its string form, matching Batect. A dependency container only ever gets its own environment — see TaskRun for how a task’s own container’s environment combines with run.environment.
run_as_current_userobject (enabled, home_directory)noRuns this container as the host’s own user/group instead of the image’s default (see User mapping below).
additional_hostnameslist of stringsnoExtra network aliases this container is reachable by, beyond its own name. No expression support.
additional_hostsmap of string → stringnoExtra /etc/hosts entries in this container, hostname: ip, Docker’s own --add-host mechanism. No expression support.
portslist of strings/objectsnoPublishes container ports to the host (see Port mappings below). No expression support. Suppressed entirely by --disable-ports, regardless of this field. See CLI reference.
health_checkobjectnoOverrides the health check configuration baked into the container’s image (see Dependency readiness below). No expression support.
setup_commandslist of objects (command, working_directory)noCommands run inside the started container after it becomes healthy but before its dependents start (see Dependency readiness below). No expression support.
working_directorystringnoOverrides the image’s own WORKDIR. No expression support. A task’s own container’s working_directory can be further overridden by the task-level run.working_directory — see TaskRun. A setup_commands entry with no working_directory of its own falls back to this, then to the image’s own default.
commandstringnoOverrides the image’s own default CMD. Tokenized into literal argv (quote/backslash-aware whitespace splitting, no shell involved — matching Batect’s own tokenizer exactly). No expression support. Applies as-is to a dependency/sidecar container; a task’s own container’s command can be further overridden by the task-level run.command — see TaskRun.
entrypointstringnoOverrides the image’s own ENTRYPOINT. Tokenized into literal argv the same way command is (quote/backslash-aware whitespace splitting, no shell involved — matching Batect’s own tokenizer exactly). No expression support. A task’s own container’s entrypoint can be further overridden by the task-level run.entrypoint — see TaskRun.
labelsmap of string → stringnoDocker labels applied to the container. Container level only — no task-level run override. No expression support.
capabilities_to_addlist of stringsnoLinux capabilities to add beyond Docker’s own default set (Docker’s --cap-add), e.g. NET_ADMIN. Validated at config-load time against a fixed list based on Batect’s own Capability enum plus BPF/CHECKPOINT_RESTORE/PERFMON (added to Docker after Batect’s last release) — an unknown name is rejected with a clear error. Container level only. No expression support.
capabilities_to_droplist of stringsnoLinux capabilities to drop from Docker’s own default set (Docker’s --cap-drop), e.g. CHOWN. Same validation/scope as capabilities_to_add.
privilegedbooleannoRuns the container with extended (nearly all host) privileges — Docker’s --privileged. Defaults to false. Container level only. No expression support.
shm_sizestring or integernoThe size of /dev/shm — Docker’s --shm-size. Accepts Batect’s own size-string format ("128", "128b", "128k", "128m", "128g" — a bare number means bytes) or a plain YAML integer (also bytes). Defaults to Docker’s own default (64 MiB). Container level only. No expression support.
deviceslist of strings/objectsnoHost devices to make available inside the container — Docker’s --device (see Volume path resolution for the similar volumes string form; devices use the same local:container[:options] shape and object form, but with no path resolution — no expression support either). options (cgroup permissions, e.g. "rwm") defaults to "rwm" when omitted, matching the docker CLI’s own default. Container level only.
enable_init_processbooleannoRuns Docker’s own init process as PID 1 ahead of the actual command (reaping zombie processes, forwarding signals) — Docker’s --init. Defaults to false. Container level only. No expression support.
log_driverstringnoDocker’s logging driver (Docker’s --log-driver), e.g. "json-file", "syslog", "none". Defaults to leaving Docker’s own daemon-configured default alone. Container level only. No expression support.
log_optionsmap of string → stringnoDriver-specific options (Docker’s --log-opt, repeatable) for log_driver — meaningless without it. Container level only. No expression support.

Note: if a container has neither image nor build_directory set, running a task against it is an error naming the container. A dependency container without either is also an error, since it needs to actually run to serve its purpose — build_directory works for dependency containers too, not just a task’s own.

Every container’s Docker hostname is always set to its own container name (matching Batect) — not just its network alias. Without this, a container is reachable by its name on the network, but hostname/$HOSTNAME inside it would resolve to Docker’s random short container ID instead, which is easy to be surprised by if anything logs or checks its own hostname.

Image building

A container with build_directory set is built (not pulled) the first time it’s needed, and reused for the rest of that ratect invocation if referenced again (as a task’s own container, as a dependency, or by more than one task) — but never reused across separate ratect invocations; each run builds fresh. A few things to know:

  • Builder selection: builds use the builder the Docker daemon itself advertises as its default — BuildKit on any modern daemon — exactly matching Batect. The DOCKER_BUILDKIT environment variable overrides this either way (1/true forces BuildKit, 0/false forces the classic builder; any other value is an error), the same variable the docker CLI honors. --enable-buildkit (see CLI reference) forces BuildKit on, taking precedence over DOCKER_BUILDKIT too — there’s no --disable-buildkit counterpart; force the classic builder via DOCKER_BUILDKIT=0/false instead. A daemon old enough not to advertise a default builder at all falls back to the classic builder. Note that build_secrets and build_ssh require BuildKit — combining them with a forced (or daemon-imposed) classic builder is a clear error rather than a silent build without them.

  • The Dockerfile built is dockerfile (a path relative to build_directory’s own root), defaulting to Dockerfile at build_directory’s own root when omitted.

  • build_target stops the build at that stage, for a multi-stage FROM ... AS <name> Dockerfile — Docker’s own --target mechanism.

  • A .dockerignore file at build_directory’s root, if present, excludes matching files from the build context — see .dockerignore semantics below for the (non-obvious) matching rules. No .dockerignore means the whole directory tree becomes the build context, unchanged from before this existed. dockerfile and .dockerignore itself are always included in the build context regardless of exclusion patterns, matching Docker’s own special-casing.

  • build_secrets exposes secrets to the build via BuildKit’s secret-mount mechanism (a Dockerfile’s RUN --mount=type=secret,id=<key>), without persisting them into the built image’s layers — keyed by the id such a RUN instruction references. Each entry is either {environment: NAME} (read from this ratect process’s own environment at build time) or {path: ...} (read from a file on the host, resolved like build_directory); exactly one of the two is required. Requires the BuildKit builder (see builder selection above). Using build_secrets disables the build cache for that build entirely — BuildKit deliberately excludes a secret’s value from its cache key (so it can’t leak into one), which would otherwise let an unrelated Dockerfile change reuse a cached layer built with a now-stale secret value.

  • build_ssh makes SSH keys available to the build, for a Dockerfile’s RUN --mount=type=ssh instructions. Each entry is one agent, named by an id a RUN instruction selects with --mount=type=ssh,id=<id>. id is required, and ids must be unique across the list. Write default for the agent a bare RUN --mount=type=ssh uses — BuildKit’s implicit id is not applied for you, because Batect requires id too and a config that omitted it would work here but not under batect. Each entry’s paths decides where its keys come from, following BuildKit’s own rules:

    build_ssh:
      - id: default             # required; `default` is the id a bare
                                # `RUN --mount=type=ssh` selects. No paths:
                                # forwards the host's own running
                                # ssh-agent, via its SSH_AUTH_SOCK
      - id: deploy
        paths:
          - ~/.ssh/deploy_key   # one or more private key files, served with no
          - keys/ci_ed25519     # agent running at all
      - id: other
        paths:
          - /tmp/other-agent.sock   # a path that *is* a Unix socket forwards
                                    # that agent instead; it must be the entry's
                                    # only path
    

    paths values support expressions and are resolved relative to the config file, the same way build_directory and build_secretspath are. Key files are served by an ssh-agent Ratect runs in-process for the duration of the build: the keys themselves never leave the ratect process — only signatures cross into the build — and the socket they’re served on lives in a directory only the current user can open. This is the form that works in CI, where a deploy key usually exists but no agent is running.

    Keys must not be protected by a passphrase, and must be Ed25519, RSA or ECDSA (Ratect rejects anything else up front, naming the file, rather than failing mid-build). RSA keys are signed with rsa-sha2-256/rsa-sha2-512; the legacy SHA-1 ssh-rsa algorithm, which OpenSSH has disabled by default since 8.8, is not offered.

    Requires the BuildKit builder (see builder selection above). Agents are proxied over the build’s session, not mounted as sockets — so this works unchanged on macOS/Windows, where Docker Desktop’s VM boundary otherwise blocks mounting host sockets into containers (no /run/host-services/ssh-auth.sock workaround involved).

  • The built image is tagged <project_name>-<container_name> (matching Batect’s own default), so it’s identifiable in docker images rather than showing up as an opaque generated name. That tag is reused/overwritten on every run, though — it’s for identification only, not caching or correctness (Ratect always runs the image it just built, regardless of what the tag currently points to by the time the container starts).

  • Built images aren’t cleaned up automatically — since the tag is reused, the image a build replaces becomes a dangling (<none>) image rather than disappearing, and accumulates until manually pruned (docker image prune), same as repeatedly running a plain docker build -t ... . would leave behind. Docker’s own build cache is likewise untouched by Ratect. Matches Batect exactly — its BuildImageStepRunner/ CleanupStagePlanner have no cache-control flag or image-removal step either.

  • Ratect has no --output mode yet, so build progress is logged rather than streamed to the console: each build log line is emitted at debug level (set RUST_LOG=info,ratect_core=debug for a live transcript without unrelated dependency noise — see filtering RUST_LOG), and if the build fails, the entire transcript is included in the error Ratect reports — not just Docker’s one-line failure summary — so a failing RUN step’s own output is always visible without needing RUST_LOG set.

.dockerignore semantics

Ratect’s .dockerignore handling is a from-scratch reimplementation of Docker’s own matching rules (github.com/moby/patternmatcher, which Docker’s documentation cites as the reference implementation), not a .gitignore-compatible matcher — the two are not the same, and the difference is easy to get surprised by:

  • A bare pattern with no wildcard only excludes at the build context root. node_modules excludes a top-level node_modules directory, but not a nested one like packages/foo/node_modules — unlike .gitignore, where a slash-free pattern matches at any depth by default. Use **/node_modules for that.
  • ** matches any number of directories (including zero), usable as a prefix, suffix, or standalone segment (**/dir2/*, dir/**, **).
  • Later lines take precedence over earlier ones — a !-prefixed line re-includes a path an earlier pattern excluded.
  • Leading and trailing slashes are no-ops (/foo/bar, foo/bar/, and foo/bar are all equivalent) — including a trailing slash not restricting a match to directories only, unlike .gitignore.
  • Dockerfile and .dockerignore themselves are always included in the build context regardless of exclusion patterns, matching Docker’s own special-casing (otherwise a broad * pattern would exclude the file the build needs).

Private registry credentials

Both pulling an image and building one (a Dockerfile FROM a private base image) resolve credentials from your Docker configuration (~/.docker/config.json by default, or DOCKER_CONFIG, or --docker-config) — running docker login once beforehand is enough, including a keychain-backed store (Docker Desktop/OrbStack’s default) or a cloud registry’s credential helper (ECR/GCR). Building resolves every registry your Docker config declares (auths and credHelpers), since Ratect doesn’t parse a Dockerfile’s FROM lines to scope this more precisely.

A registry whose credential helper fails to resolve doesn’t block the pull or build — a warning names the registry, so a problem with one registry’s helper can’t stop work that never needed it, but also doesn’t stay invisible until the day that registry actually matters.

Volume path resolution

Each volumes entry is a local bind mount (a host path), a cache volume (see Cache volumes below), or a tmpfs mount (see Tmpfs mounts below) — either the compact string form or the expanded object form:

containers:
  build-env:
    image: alpine:3.18
    volumes:
      - .:/code
      - local: ./logs
        container: /var/log/app
        options: ro
      - type: cache
        name: apk-cache
        container: /var/cache/apk
      - type: tmpfs
        container: /code/tmp

A bare string (local_path:container_path or local_path:container_path:options) is always a local mount — there’s no compact string form for cache/tmpfs. The expanded object form additionally accepts type: cache (with name instead of local) or type: tmpfs (with neither local nor name); type: local, the default when omitted, requires local and forbids name.

For a local mount, local (the host path) is resolved:

  • The path is interpolated first (see Expressions) — so a config variable that itself resolves to an absolute path is used as-is, not treated as a literal relative fragment.
  • A leading ~ is then expanded to the host user’s home directory, so ~/.cache/tool mounts the real cache directory. Only a whole leading ~ component expands: ~user/… (another user’s home) isn’t supported, and a ~ anywhere but the front is a literal character. Note that a bare ~ must be quoted (local: "~") — unquoted, YAML reads it as null.
  • After interpolation, if the result is relative, it’s resolved to an absolute path relative to the directory containing the config file (not the current working directory). If it’s already absolute (whether literally written that way, expanded from ~, or because that’s what an expression resolved to), it’s left unchanged.
  • This all happens once, after CLI-supplied config variable overrides (--config-var/--config-vars-file) are known — not at config-parse time.
  • build_directory is resolved the same way (it has no :container_path part to split off, obviously, but otherwise follows identical rules).

container/options are always used as literal strings — no expression support. A cache mount’s name/container are also plain strings, matching Batect — no expression support there either.

Cache volumes

ratect’s native format adds a scope field here, for a cache shared across projects. Everything below describes the project-scoped kind, which is the only one batect.yml has.

A cache name must start with a letter or digit and contain only letters, digits, underscores, dots and dashes — Docker’s own volume-name character set. The name becomes a host directory under --cache-type=directory, so a name like /etc or ../../.ssh would otherwise have an arbitrary host directory bind-mounted into the container; Batect performs no such check, and Ratect diverges here for the same reason it applies containment to Git includes. Under --cache-type=volume Docker already enforced this, so nothing that worked there is affected. Directory caches are a breaking change: they accepted any name, so name: my cache or name: node/modules loaded before and now fails. Rename the cache — the storage is rebuilt on the next run, which is what a cache is for.

A cache mount persists between separate ratect invocations — unlike local, its contents aren’t tied to a specific host path in batect.yml. name identifies it, combined with a per-project key into either a Docker named volume (the default) or a host directory, selected by --cache-type (see CLI reference):

containers:
  build-env:
    image: alpine:3.18
    volumes:
      - type: cache
        name: apk-cache
        container: /var/cache/apk
        options: rw   # optional
  • --cache-type=volume (the default): resolves to a Docker named volume, batect-cache-<project-key>-<name> — the exact naming convention Batect itself uses, deliberately, so a project already run under real Batect has its existing cache volumes recognized and reused rather than starting cold.
  • --cache-type=directory: resolves to a host directory under <project_directory>/.batect/caches/<name>/, created if it doesn’t exist yet. Same reasoning as above — this is where an existing Batect project already keeps its own directory-type caches.
  • <project-key> is a value unique to this project, generated once and persisted at <project_directory>/.batect/caches/key (created lazily — only the first time a cache volume is actually resolved, never eagerly) — without it, two unrelated projects that happen to declare a same-named cache (e.g. gradle-cache) would collide on the exact same Docker volume, since Docker volumes live in one flat, global namespace, not scoped by directory. If that file already exists (e.g. from a prior real-batect run), its existing key is read and reused as-is rather than regenerated — even though Ratect’s own generated keys look different (a full UUID, rather than Batect’s own shorter id): nothing depends on matching that format, only on reusing whatever key is already on record for this project. --clean removes every one of this project’s own cache volumes/directories (per --cache-type) and exits, without running anything; --clean-cache <NAME> (repeatable) restricts this to the named cache(s) instead of all of them — see CLI reference.

Tmpfs mounts

A tmpfs mount is an in-memory filesystem — its contents are lost when the container exits. Unlike local/cache, it has no host path or name — only the expanded object form is accepted:

containers:
  build-env:
    image: alpine:3.18
    volumes:
      - type: tmpfs
        container: /code/tmp
        options: size=64m   # optional

options is a standard Docker tmpfs mount options string (e.g. size=64m, mode=1770) forwarded verbatim to Docker — Ratect does no parsing or validation of its contents, matching Batect.

User mapping

containers:
  build-env:
    image: alpine:3.18
    run_as_current_user:
      enabled: true
      home_directory: /home/container-user

By default, a container runs as whatever user the image defaults to — often root — so files a task writes to a bind-mounted volume come back host-root-owned. Setting run_as_current_user.enabled: true runs the container as the host’s own user and group instead.

Note: enabled and home_directory are only ever valid together — this matches Batect’s own behavior exactly, not a Ratect-specific restriction. Setting enabled: true with no home_directory is an error (Ratect never guesses one, since the container’s own image has no home directory prepared for an arbitrary host uid/gid). The reverse is also an error: enabled: false (or omitted) with home_directory still set — e.g. simply flipping enabled back to false without also deleting home_directory fails config loading. Remove home_directory entirely to disable user mapping, not just enabled.

home_directory takes expressions (e.g. home_directory: /home/${USER:-container-user}). It is interpolated but, unlike build_directory or a volume’s host path, not resolved against the config file’s directory — it names a path inside the container, so a relative value would be meaningless there and is rejected as not absolute. A : or control character in the resolved value is also rejected: it is written into the generated /etc/passwd and /etc/shadow entries, where either would corrupt the line.

A few things happen automatically to make this actually work, not just set --user:

  • Any local mount’s host path (or a cache mount’s own host directory, under --cache-type=directory) that doesn’t exist yet is created before the container is even created, as the current host user. Otherwise Docker’s daemon (running as root) would auto-create it as root:root on first use, defeating the point for the common “mount my code directory, get build artifacts back with sane ownership” case. Doesn’t apply to a cache mount under the default --cache-type=volume — there’s no host path at all there, just a Docker volume name.
  • The container’s own image has no /etc/passwd//etc/group entry for an arbitrary host uid/gid — many programs misbehave or refuse to run at all without one (no $HOME, no username resolution). Minimal synthetic /etc/passwd, /etc/shadow, and /etc/group entries are uploaded into the container before it starts.
  • home_directory itself is created inside the container (owned by the mapped uid/gid) before it starts — it’s a path inside the container’s own filesystem, not host-mounted, so it doesn’t persist across runs, matching Ratect’s existing ephemeral-container model.
  • Every cache mount on the container gets the same ownership treatment. A Docker volume is created root-owned, so without this the container would mount its cache and then fail on the first write — the mount having succeeded, which makes it a confusing place to find out. Applies to a cache anywhere, including one nested inside home_directory.

Applies per-container, independently — a task’s own container and each of its dependencies can each set run_as_current_user on their own; it isn’t inherited or shared task-wide.

One limitation: host-side uid/gid lookup is Unix-only, and errors clearly on other platforms rather than guessing.

Port mappings

containers:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
      - "9000-9002:9100-9102/udp"
      - local: 8443
        container: 443

ports publishes container ports to the host — Docker’s own -p/--publish mechanism — and takes either form Batect itself supports, freely mixed within one list:

  • A string, "local:container[/protocol]" (protocol defaults to tcp), e.g. "8080:80" or "8080:80/udp".
  • A port range string, "from-to:from-to[/protocol]", e.g. "9000-9002:9100-9102" — each local port maps to the corresponding container port by position (90009100, 90019101, 90029102); local and container must cover the same number of ports.
  • The expanded object form, {local, container, protocol}local/container each accept a single port or a range (8443 or "8000-8002"), protocol is optional (defaults to tcp).

Validated at config-load time (unlike volumes, which is never format-checked): a malformed entry, a non-positive port, or mismatched-size local/container ranges are all rejected before anything runs. No expression support.

TaskRun.ports (see TaskRun) adds additional port mappings for a specific task’s run — combined with the container’s own ports as a union, not an override; there’s no concept of one replacing an entry from the other.

--disable-ports suppresses publishing of every container’s ports — from both Container.ports and any TaskRun.ports — regardless of what’s configured. See CLI reference.

Dependency readiness

containers:
  database:
    image: postgres:16
    health_check:
      command: pg_isready -h localhost
      interval: 2s
      retries: 5
      start_period: 3s
      timeout: 1s
    setup_commands:
      - command: ./apply-migrations.sh
      - command: ./seed-data.sh
        working_directory: /setup

A dependency container being started doesn’t mean it’s ready — a database accepts connections some time after its process launches. Matching Batect, a dependency must pass two gates, in order, before anything that depends on it (another dependency, or the task’s own container) starts:

  1. It must report healthy. If the container has a Docker health check — from its image’s own HEALTHCHECK, from the health_check field, or both — Ratect waits for Docker’s verdict: proceeds on healthy; fails the task on unhealthy (the error includes the last health-check run’s exit code and output) or if the container exits first. A container with no health check at all is immediately considered healthy — the pre-0.9.0 “started = ready” behavior, now just the no-health-check special case.
  2. Its setup_commands must succeed. Each runs inside the running container (via Docker’s exec mechanism), one at a time in declared order, with the container’s own environment and (under User mapping) the same user/group the container runs as. A command exiting non-zero fails the task, with its output in the error.

health_check overrides the image’s health check configuration — each field replaces that one aspect, and any field left out inherits the image’s own value:

FieldTypeDescription
commandstringThe command to run to check the container’s health, via the container’s default shell (a Dockerfile HEALTHCHECK CMD <string>). Exit code 0 means healthy.
intervaldurationTime between health check runs.
retriesintegerConsecutive failures needed before the container is considered unhealthy.
start_perioddurationTime during which failing checks don’t count against retries (a success during it still counts as healthy immediately).
timeoutdurationTime a single check may run before it’s considered failed.

Durations are strings in Batect’s (Go-style) format: one or more <number><unit> components — ns, us, ms, s, m, h, numbers optionally fractional — e.g. 2s, 500ms, 1m30s, 1.5h, or a bare 0.

How Docker reaches its verdict

This is Docker’s own behavior, not Ratect’s, but it’s what actually determines how long the gate waits and when it fails, so it’s worth spelling out:

  • A freshly started container with a health check isn’t unhealthy — it’s in a third state, starting, until Docker reaches a first verdict. Docker runs command every interval; the first success makes the container healthy, and only retries consecutive failures make it unhealthy. With the example above (interval: 2s, retries: 5), the earliest possible unhealthy verdict is about ten seconds in — a health check can’t “fail fast” on its first bad run.
  • Failures during start_period don’t count toward retries at all — that’s the grace period for slow-booting services — but a success during it still flips the container healthy immediately.
  • Ratect waits for that first verdict, and only the first: matching Batect, a dependency’s health is never re-checked once its dependents have started, even though Docker keeps running the check for the container’s whole lifetime and the state can flip later.

While a task appears to hang on a dependency, docker ps shows each container’s health state in its STATUS column (health: starting, etc.), and docker inspect --format '{{.State.Health.Status}}' <container> shows it directly — .State.Health.Log keeps the last few check runs’ exit codes and output, which is also where the detail in Ratect’s “did not become healthy” error comes from.

Each setup_commands entry takes:

FieldTypeRequiredDescription
commandstringyesThe command to run. Tokenized into literal argv the same way a container’s own command/entrypoint is (see TaskRun) — no shell involved. A command relying on shell operators (&&, $VAR expansion, etc.) needs an explicit sh -c '...' wrapper.
working_directorystringnoDirectory to run it in. Falls back to the container’s own working_directory when omitted, and then to the image’s own default when neither is set.

Ratect imposes no timeout of its own on the health wait (matching Batect) — Docker’s own interval/retries bound how long a verdict can take, so a health check configured to retry forever waits forever.

The task’s own container goes through this same readiness gate too (0.21.0), run concurrently with its main command rather than gating anything on it — matching Batect, which runs every container through identical per-container steps, task container included. A health-check or setup-command failure fails the task even if the main command already succeeded — but the main command itself is never cancelled early because of it (unlike Batect); it always runs to completion. See known simplifications for the one race this still leaves (a very fast main command can finish before a setup command gets a chance to run) and Differences from Batect.

Task

tasks:
  test:
    run:
      container: build-env
      command: echo "hello"
    prerequisites:
      - build
FieldTypeRequiredDescription
runTaskRunno*What to actually execute for this task.
prerequisiteslist of stringsno*Names of other tasks to run first, in order. Each prerequisite (and its own prerequisites, transitively) runs at most once per ratect invocation, even if shared by multiple tasks. A circular dependency is detected and reported as an error. A name containing * is a wildcard, expanded against every task name at run time (see Wildcard prerequisites below) rather than looked up literally.
dependencieslist of stringsno**Sidecar containers scoped to this task specifically — see below. Requires run.
descriptionstringnoShown next to the task’s name in --list-tasks output — see below. Purely informational.
groupstringnoGroups this task under a heading in --list-tasks output, together with every other task sharing the same group — see below. Purely a display grouping; has no effect on execution order or prerequisites.
customisemap of string → TaskContainerCustomisationnoPer-task environment/ports/working_directory overrides, keyed by container name — see below.

* At least one of run/prerequisites is required. A task with only prerequisites and no run is valid — its prerequisites still run, then Ratect stops, since there’s no container of the task’s own left to run.

** dependencies here is distinct from a container’s own dependencies field: this one is scoped to this task specifically, rather than to every task that uses the container. It’s unioned with the task’s own container’s dependencies — both start together — and can’t name run.container itself.

Wildcard prerequisites

A prerequisites entry containing * is expanded against every task name in the project, rather than looked up literally:

tasks:
  lint:go:
    run:
      container: build-env
      command: golangci-lint run
  lint:markdown:
    run:
      container: build-env
      command: markdownlint .
  ci:
    prerequisites:
      - "lint:*"

Running ratect-compat ci here runs both lint:go and lint:markdown (in alphabetical order) before ci itself. A single * matches zero or more characters — "lint:*" would also match a task literally named lint: — and a wildcard matching multiple tasks always runs them in alphabetical order, regardless of the project’s own tasks declaration order. A wildcard matching zero tasks is not an error — it simply contributes nothing. If a task is named both explicitly and by a wildcard in the same prerequisites list, it still only runs once. Quote a wildcard entry ("lint:*", not lint:*) to avoid YAML parsing * as an anchor reference.

TaskContainerCustomisation

Applies to a non-main container used somewhere in this task’s own container graph (a task-level or container-level dependency, at any depth) — keyed by container name under customise:

tasks:
  test:
    run:
      container: build-env
    dependencies:
      - queue
    customise:
      queue:
        environment:
          MODE: test
        working_directory: /custom
FieldTypeRequiredDescription
environmentmap of string → stringnoMerged with the container’s own environment (see Container): the container’s values apply first, and this overrides them on a key collision. Values support the same expressions as environment does.
portslist of strings/objectsnoAdded to the container’s own ports, not an override — see Port mappings.
working_directorystringnoOverrides the container’s own working_directory. No expression support.

A customise entry can’t target run.container itself — that’s a config error; set the equivalent property on run instead. It also can’t name a container that isn’t actually part of this task’s own container graph (whether because the name doesn’t exist at all, or exists but isn’t reachable from run.container via dependencies — container-level or task-level).

List-tasks output

With no task declaring a group, --list-tasks prints a single flat, alphabetically sorted list — each task’s name, plus its description if set:

Tasks in my-project:
- build: Builds the app
- test

Once any task in the project declares a group, every task is listed under a heading instead — one per distinct group value (sorted alphabetically), plus a trailing Ungrouped tasks: heading for any task that doesn’t set group:

Tasks in my-project:

compilation:
- build: Builds the app

verification:
- lint
- test: Runs the test suite

Ungrouped tasks:
- clean

With --output quiet, both forms are replaced by a machine-readable listing instead — one task per line, sorted by name, as name alone or name<TAB>description, with no header and no grouping:

build	Builds the app
clean
lint
test	Runs the test suite

TaskRun

FieldTypeRequiredDescription
containerstringyesName of a container defined under containers.
commandstringnoOverrides the container’s own command for this task’s run specifically (see Container). Tokenized the same way. If neither this nor the container’s own command is set, the image’s own default CMD runs instead. Any -- ADDITIONAL_ARGS from the CLI are appended as further literal argv entries — see CLI reference.
environmentmap of string → stringnoEnvironment variables to set for this task’s run specifically. Merged with the container’s own environment (see Container): the container’s values apply first, and run.environment overrides them on a key collision. Values support the same expressions as environment does.
portslist of strings/objectsnoAdditional port mappings for this task’s run specifically — see Port mappings. Added to the container’s own ports, not an override — there’s no concept of one replacing an entry from the other.
working_directorystringnoOverrides the container’s own working_directory for this task’s run specifically (see Container). No expression support.
entrypointstringnoOverrides the container’s own entrypoint for this task’s run specifically (see Container). Tokenized the same way. No expression support.

Interactive mode

There’s no config field for this — it’s automatic, matching Batect’s own behavior: running a task whose command drops you into a shell or otherwise needs your input (command: sh, for example) just works, with no interactive: true to remember to set anywhere.

The invoked task’s own container — never a prerequisite’s, a dependency’s, or a sidecar’s; only the task actually named on the command line is ever eligible — always gets its stdin forwarded and the host’s TERM environment variable propagated into its own environment (see below), independent of whether Ratect’s own stdin/stdout are real terminals. The one exception is --output all, whose line-prefixed output can’t host an interactive session: under it no container gets a TTY or stdin, and every container gets TERM=dumb instead — matching Batect. A real Docker TTY (raw mode locally, live terminal resizing) is additionally allocated when both Ratect’s own stdin and stdout are genuinely connected to a real terminal — piped output, a redirected non-terminal, or running in CI fall back to plain (non-TTY) stdin forwarding and streamed output instead, but stdin still reaches the container either way. Nothing extra to configure for either case.

The container’s TTY, when one is allocated, stays in sync with the local terminal’s size for the whole session (not just once at the start) — a local resize is forwarded live via a SIGWINCH handler. This tracking is Unix-only; on other platforms the size is still synced once, at the start of the session, but not tracked further (interactive mode itself works cross-platform either way).

One known, deliberate divergence from Batect remains: Batect’s own real-TTY gate checks only whether its output is a real terminal; Ratect’s requires both stdin and stdout to be real terminals before allocating one.

TERM propagation

Ratect’s own TERM environment variable is copied into the invoked task’s own container’s environment automatically, whenever that container is eligible (the top-level task, as above) — not gated on a real TTY actually being allocated, matching Batect’s own unconditional behavior. Never applied to a prerequisite’s, a dependency’s, or a sidecar’s container, and never applied to an image build. A container’s own explicit environment, or a task’s run.environment, both still override it on a key collision (see TaskRun for how those two combine with each other) — TERM is the lowest-precedence layer, the same tier proxy environment variables occupy.

Proxy environment variables

There’s no config field for this either — like interactive mode, it’s automatic, matching Batect’s own behavior. Whenever http_proxy, https_proxy, ftp_proxy, or no_proxy (in either case, e.g. HTTP_PROXY too) are set in the environment ratect itself runs in, they’re injected into every container’s environment and every image build’s build_args — so a task or a build that needs to reach the network through a proxy just works, without repeating proxy settings in environment/build_args by hand.

A few details worth knowing:

  • Precedence: injected proxy variables are the lowest-precedence layer — a container’s own environment, and a task’s run.environment, both override a proxy-derived value on a key collision (see TaskRun for how those two combine with each other). build_args works the same way for builds.

  • no_proxy is extended automatically: every container sharing a task’s network (the task’s own container and each of its dependencies) has its own name appended to no_proxy/NO_PROXY, so traffic between them isn’t sent through the proxy. Not done for image builds — nothing’s running yet during a build, so there’s nothing to exempt.

  • localhost rewriting: http_proxy/https_proxy/ftp_proxy values that point at localhost, 127.0.0.1, or ::1 are rewritten to host.docker.internal, since localhost from inside a container refers to the container itself, not the host machine running a proxy. A value that isn’t a http/https URL, or doesn’t refer to the local machine, is left unchanged.

    On every platform, including Linux — which is the part that used to be missing, since Docker Desktop supplies host.docker.internal itself and on Linux nothing does.

    So a run that rewrote a URL also adds host.docker.internal:host-gateway to every container it starts and every image it builds, using Docker’s own --add-host mechanism, which the daemon resolves to the machine running it. That entry is added on every platform too, not only the one that needs it: on macOS and Windows it names the same gateway Desktop would have answered with, so it is a new /etc/hosts line rather than a change in what the container can reach. What gates it is the rewrite, not the platform — no rewrite, no entry — and a container’s own additional_hosts entry for that name always wins over it.

    Rewriting the URL can’t make an unreachable proxy reachable. A proxy bound only to 127.0.0.1 — which is what cntlm and similar default to — still refuses a connection from a container, so on Linux Ratect checks /proc/net/tcp/tcp6 and warns, once per run and naming the port:

    The proxy on port 3333 is listening on loopback addresses only, so containers in this run cannot reach it even though its URL now names the host. Bind the proxy to 0.0.0.0 to make it reachable — which also exposes it to anything else that can reach this machine, so do that only on a network you trust. Use --no-proxy-vars if this run doesn’t need the proxy.

    It’s a warning, not a failure: the run may never use the proxy. Note the security cost it names — binding a proxy to 0.0.0.0 opens it to everything that can reach the machine, so weigh that rather than applying it reflexively. Some proxies offer the same thing as a narrower setting: cntlm, for instance, binds 127.0.0.1:3128 by default and listens on every interface only under its Gateway option.

    A host firewall can block the container’s traffic even when the proxy is bound wide enough, and that case Ratect can’t detect — so if the warning doesn’t fire and the proxy still isn’t reachable, check there next.

    What to look for: the connection arrives on the bridge interface of the user-defined network the run is using, never Docker’s default docker0 bridge, so a firewall rule written for docker0 won’t cover it. Ratect creates a network per task, or uses the one --use-network names — and a run can’t fall back to the default bridge even if you point --use-network at it, because Ratect gives every container a network-scoped alias and Docker only allows those on user-defined networks (docker run refuses with network-scoped aliases are only supported for user-defined networks).

    Match the rule on that network’s subnet, which docker network inspect <network> reports under IPAM.Config. Its interface name is generally not in that output — Docker only records one there when the network was created with com.docker.network.bridge.name, which is how docker0 itself gets its name — so a rule pinned to an interface is both harder to write and easier to get wrong.

  • --no-proxy-vars disables all of this. See CLI reference.

See also: TERM propagation — a similarly automatic, lowest-precedence-layer environment injection for the invoked task’s own container, but gated on interactive eligibility rather than --no-proxy-vars.

ConfigVariable

config_variables:
  environment_name:
    default: dev
FieldTypeRequiredDescription
defaultstringnoThe value used when nothing else provides one — see Expressions for the full precedence order (CLI --config-var > --config-vars-file > this default). Referencing a declared variable that has no default and no override from either CLI source is an error.
descriptionstringnoRecognized but inert — Batect surfaces this in its own generated docs/help output; Ratect has no such output to show one in.

Expressions

environment values (on both Container and TaskRun), a volume’s host_path (see Volume path resolution), build_directory, build_args, a build_secrets entry’s path (not its environment — that’s a literal host environment variable name, not itself interpolated), a build_ssh entry’s paths, and run_as_current_user’s home_directory (interpolated but not resolved against the config file, since it names a path inside the container) support two kinds of expression, resolved once — after CLI-supplied config variable overrides (--config-var/--config-vars-file) are known, so before any task runs but not at config-parse time itself. Everywhere else in the config, a string is used exactly as written, with no substitution — expression support is scoped to fields that can meaningfully take one; it’ll extend to more fields as they themselves get built, not automatically.

ratect.toml differs here. The native format also resolves expressions in image, so a tag can be chosen per run — see Expressions in image. It is rejected in a batect.yml rather than ignored, because Batect resolves nothing there and a file using one would stop working under batect itself. In this format, --override-image is the way to choose an image per run.

Literal text around an expression is left untouched ("prefix-$VAR-suffix" interpolates just $VAR), and a $/< not followed by a valid identifier (or an unterminated ${/<{) is treated as a literal character rather than an error.

FormResolves againstExample
$NAMEratect’s own host environment$HOME
${NAME}ratect’s own host environment${HOME}
${NAME:-default}ratect’s own host environment, falling back to default if unset${LOG_LEVEL:-info}
<nameA config_variables entry<environment_name
<{name}A config_variables entry<{environment_name}

A host variable referenced without a :-default fallback, and unset when ratect runs, is a hard error naming the variable — there’s no silent empty-string fallback. A config variable referenced via <name/<{name} must be declared under config_variables; an undeclared name is a hard error, and so is a declared one with no value from any source (see ConfigVariable’s precedence order). Config variable values themselves come from, highest precedence first: --config-var NAME=VALUE (repeatable), --config-vars-file (a flat YAML map), then the variable’s own default — see CLI reference.

Built-in config variable: batect.project_directory

<batect.project_directory/<{batect.project_directory} always resolves to the absolute path of the directory containing the config file — Batect’s one built-in config variable, so Ratect supports it without requiring (or allowing) it to be declared under config_variables. Declaring a config_variables entry named batect.project_directory, or supplying one via --config-var/--config-vars-file, is a hard error — it isn’t overridable.

Editor autocompletion and validation

Ratect ships a JSON schema describing exactly what this reference documents: schema/batect-config.schema.json. Pointing an editor at it gives autocompletion for field names, hover documentation, and a warning on a misspelled or unsupported field as you type — including the fields Batect supports and Ratect doesn’t, which is the whole reason this is a schema of Ratect’s own rather than Batect’s published one (that one describes Batect’s full field set, so an editor using it would quietly accept configuration Ratect then rejects at run time).

The simplest way to use it is a comment at the top of your config file, which the YAML extension for VS Code and JetBrains IDEs both honor:

# yaml-language-server: $schema=https://raw.githubusercontent.com/or1can/ratect/main/schema/batect-config.schema.json
project_name: my-project

A local path works the same way ($schema=./schema/batect-config.schema.json), and is worth preferring if you pin a Ratect version — the URL above tracks main, so it describes the development version, which may accept fields your installed Ratect doesn’t yet.

The schema describes a single file, include and all — not the merged result of following those includes — so it applies equally to a root batect.yml and to any file it includes. It’s generated from Ratect’s own configuration types, so it can’t drift from what Ratect accepts; the checks it can’t express are the cross-field ones (a task needing run or prerequisites, port ranges on both sides of a mapping covering the same number of ports, customise naming a container that’s actually in the task’s graph). Those are still reported by Ratect itself, when you run a task.

Not submitted to SchemaStore’s catalog itself — a possible later step, not done yet.

Full example

This mirrors the sample config used in the test suite (batect.yml in the repo root):

project_name: ratect-test
containers:
  build-env:
    image: alpine:3.18.2
    volumes:
      - .:/code
tasks:
  shared-prereq:
    run:
      container: build-env
      command: echo "I should only run once"
  prereq-task:
    run:
      container: build-env
      command: echo "I am a prerequisite"
    prerequisites:
      - shared-prereq
  list-volume-task:
    run:
      container: build-env
      command: ls /code
    prerequisites:
      - shared-prereq
  test-task:
    run:
      container: build-env
      command: echo "Hello from ratect!"
    prerequisites:
      - prereq-task
      - list-volume-task

Running ratect-compat test-task runs shared-prereq once (even though both prereq-task and list-volume-task depend on it), then prereq-task and list-volume-task, then test-task itself.

ratect CLI Reference

This documents the ratect binary — the forward-looking CLI, free to diverge from Batect’s interface. For the Batect-compatible binary, see the ratect-compat CLI reference instead; the two are described separately because they are deliberately different interfaces, not two spellings of one.

Status. From 0.3.0 ratect reads its own native TOML configuration (ratect.toml by default) rather than sharing ratect-compat’s batect.yml — see Releases and decisions/0003. Its full schema is the ratect.toml reference; it’s the same schema Configuration Reference documents for batect.yml, re-spelled in TOML, with extends in place of YAML anchors. A batect.yml is still readable by naming it with -f, so a project can migrate incrementally — ratect config convert translates one automatically. The native format is ratect’s alone — ratect-compat stays batect.yml-only, permanently.

The native config format

ratect.toml is batect.yml’s schema in TOML: named containers and tasks become tables, and list entries (volumes, ports, devices) become inline tables or [[...]] blocks. A small example:

project_name = "my-app"

[containers.base]
image = "rust:1.90"
volumes = [{ local = ".", container = "/code" }]

[containers.build-env]
extends = "base"
working_directory = "/code"

[tasks.build]
run = { container = "build-env", command = "cargo build" }

The native additions over batect.yml are extends — a container inherits one named parent’s fields (shallow, per-field, single-parent), replacing YAML anchors — and mixed includes: each include is parsed by its extension (.toml native, .yml/.yaml as YAML), and a pathless type: git bundle prefers ratect-bundle.toml over batect-bundle.yml. The full schema — the TOML spelling of every field, the extends rules, object shapes, includes, and local overrides — is the ratect.toml reference.

Local overrides

A ratect.local.toml beside your config file is loaded automatically when present — no --config-vars-file needed — supplying config variable values (a flat name = "value" map) for the current developer or machine. Gitignore it. See the reference for precedence and the reasoning.

Commands

Grouped by purpose. Within a command, the sub-verbs follow their natural workflow order (list before clean/refresh) rather than alphabetically. ratect --help lists the commands in this same order — clap can’t render the group headings there, so the order alone carries them.

Running tasks

CommandWhat it does
ratect run <task> [-- ARGS...]Runs a task. Anything after -- is appended to the task command’s own arguments.
ratect tasks listLists the tasks this project defines.

Managing resources

CommandWhat it does
ratect caches listLists the caches this project can see, project-scoped and shared, with the scope of each.
ratect caches clean [NAME...]Removes this project’s caches, or just the named ones — which may be shared.
ratect includes listLists the cached Git includes shared by every project on this machine.
ratect includes clean [--all]Removes cached Git includes.
ratect includes refreshRe-clones them, picking up a ref that has moved.
ratect resources listLists containers and networks left over from previous runs.
ratect resources cleanRemoves them.

Configuration & diagnostics

CommandWhat it does
ratect config validateChecks the configuration loads and is problem-free, without a daemon — a CI-friendly gate.
ratect config convertConverts a batect.yml (point -f at it) into a native ratect.toml.
ratect doctorChecks this project and this machine for problems, without running anything.

Shell integration

CommandWhat it does
ratect completions <shell>Prints a shell completion script (bash, zsh, fish, powershell, elvish) to stdout — see Shell completion.

There is deliberately no ratect <task> shorthand. ratect-compat takes a task name as a bare positional argument, which works only because it has no subcommands; as ratect grows verbs, “is doctor a task or a command?” becomes a question the interface can’t answer, so run is always explicit.

ratect run build
ratect run test -- --filter integration
ratect tasks list

ratect caches list
ratect caches clean gradle-cache
ratect includes list
ratect includes refresh
ratect resources list
ratect resources clean --older-than 1d

ratect config validate
ratect doctor

ratect completions zsh

Global options

These work with every command, before or after it — ratect -f custom.yml run build and ratect run build -f custom.yml are the same invocation.

OptionDefaultDescription
-f, --config-file <PATH>ratect.tomlThe configuration file. Parsed by extension — .toml as the native format, .yml/.yaml as Batect-format YAML — so -f batect.yml keeps reading a Batect config while migrating. caches uses it only to locate the project directory — it never reads the contents.
-o, --output <STYLE>autofancy, simple, all or quiet — see output styles, which behave identically here.
--no-colorNo color in Ratect’s own output (never affects a task’s own output). The NO_COLOR environment variable has exactly the same effect, if set. The CLICOLOR_FORCE environment variable does the opposite — forces color even when stdout isn’t a terminal, without affecting which output style is auto-selected — but NO_COLOR/--no-color always win over it if either is also set.

Narrower options attach to the commands that actually use them, rather than being global: a flag that’s accepted and then ignored reads as a promise. So the config-variable options below belong to run and tasks list (the commands that read configuration), and the Docker connection options to run and caches (the ones that reach a daemon).

OptionApplies toDescription
--config-var <NAME=VALUE>run, tasks listSets a config variable. Repeatable; wins over --config-vars-file and the variable’s own default.
--config-vars-file <PATH>run, tasks listA file of config variable values (a flat NAME = VALUE map), parsed as TOML or YAML by extension. Defaults to an auto-discovered ratect.local.toml beside the config file, when present.

Docker connection options

Taken by run and by caches (whose default storage is Docker volumes); never by tasks list, which reaches no daemon at all.

OptionDefaultDescription
--docker-host <HOST>DOCKER_HOST, then Docker’s defaultThe daemon to connect to. Mutually exclusive with --docker-context.
--docker-context <NAME>DOCKER_CONTEXT, then the CLI’s active contextThe Docker CLI context to connect through.
--docker-config <PATH>DOCKER_CONFIG, then ~/.dockerWhere the Docker CLI’s own configuration lives.
--docker-tls, --docker-tls-verifyConnect over TLS, always verifying the daemon’s certificate — see TLS with a private CA.
--docker-cert-path <PATH>DOCKER_CERT_PATH, then ~/.dockerDirectory holding ca.pem/cert.pem/key.pem.
--docker-tls-ca-cert, --docker-tls-cert, --docker-tls-keyfrom --docker-cert-pathIndividual TLS file overrides.

run options

OptionDefaultDescription
--enable-buildkitForce BuildKit for image builds, over the daemon’s default and DOCKER_BUILDKIT. Only run builds images, so only run takes it.
--use-network <NAME>Reuse an existing Docker network instead of creating one for the task.
--disable-portsNever bind container ports on the host.
--no-proxy-varsDon’t propagate proxy environment variables.
--skip-prerequisitesRun the task alone, without its prerequisites.
--override-image <CONTAINER=IMAGE>Replace a container’s image. Repeatable.
--tag-image <CONTAINER=TAG>Extra tag for an image a container builds. Repeatable.
--no-cleanup, --no-cleanup-after-success, --no-cleanup-after-failureLeave containers running for investigation.
--max-parallelism <N>unboundedCap concurrent image pulls/builds.
--cache-type <TYPE>volumevolume or directory — see cache volumes.

caches options

--cache-type <volume|directory> (default volume) selects which storage to act on, for both list and clean — a cache in one is invisible to the other, so this has to match how the project runs its tasks. Under directory, this project’s caches are host directories under <project>/.batect/caches/<name> and shared ones live at ~/.ratect/caches/<name>.

caches never reads the configuration file. A cache belongs to the project directory, so both commands work on a project whose configuration is broken or missing entirely — which is exactly when clearing a cache tends to be what’s needed.

caches list prints each cache under the name a volumes entry gives it, not the Docker volume it’s stored in; that name is what caches clean takes back. Under -o quiet it’s one name per line and nothing else, for scripting — and it prints this project’s caches only, unless --scope shared asks otherwise. What it emits is exactly what a caches clean carrying the same flags would act on, so the output can be piped straight back. Naming a cache that doesn’t exist warns on stderr rather than passing silently, since the likeliest cause is a typo.

--scope <project|shared> restricts both commands to one kind of cache. A shared cache is one every project on the machine can use, so the listing keeps them apart — a shared cache is not this project’s, and most of the ones shown will belong to other projects:

$ ratect caches list
Caches for this project:
- build-output

Shared caches on this machine:
- cargo-registry

Removing a shared cache always takes --scope shared — whether it is named or not, and whether or not this project has a cache of the same name. A shared cache holds storage every other project on the machine is using, so it is never reached by an unqualified clean:

$ ratect caches clean cargo-registry
Error: 'cargo-registry' is a shared cache, used by every project on this
machine. Re-run with '--scope shared' to remove it.

caches clean with no names therefore sweeps this project’s caches only.

Both scopes are read from storage, not from the configuration — a project cache is found by its batect-cache-<key>- prefix, a shared one by ratect-shared-cache-. That is what keeps these commands working on a project whose configuration is broken.

includes options

The Git include cache under ~/.ratect/incl — where a type: git include is cloned and kept.

$ ratect includes list
1 cached Git include(s), 16.4 MiB on disk:

  https://github.com/example/shared-tasks.git at v2.1.0
    16.4 MiB, last used 3 days ago

Unlike caches and resources, this cache is global — one directory shared by every project on this machine, keyed by (repo, ref). So there’s no project scoping, and clean reaches other projects’ includes as well as your own. That matters less than it sounds: everything here is re-cloneable, so the worst case is a fetch.

CommandDescription
includes cleanRemoves includes nothing has used for 30 days — the same threshold the automatic sweep applies, done on demand.
includes clean --older-than <AGE>A different threshold (30m, 2h, 7d).
includes clean --allEverything, regardless of age.
includes refreshDiscards every cached clone and fetches it again.

refresh is how you pick up a moved ref. A (repo, ref) pair is cloned once and then never re-fetched, so if ref is a branch — or a tag someone re-pushed — your project keeps using whatever it pointed at the first time, indefinitely. The automatic sweep doesn’t help, because it removes entries that go unused, and an include you’re actively using never becomes stale. Pinning ref to something immutable remains the better answer; refresh is for when it isn’t.

Under -o quiet, list prints repo<TAB>ref per line and nothing else.

resources options

Containers and networks outlive a run when something goes wrong — a crash, a docker kill, a --no-cleanup run, or a cleanup that failed. resources finds them by the labels Ratect stamps on everything it creates, so they’re identifiable however long ago they were made:

$ ratect resources list
2 left over from 1 previous run:

  integration-test (3 days ago, run a01df375-8365-4689-85e4-11b33dee70b8):
    - container database (running)
    - network ratect-a01df375-8365-4689-85e4-11b33dee70b8

Remove them with: ratect resources clean

Grouped by run, because that’s the unit a leftover belongs to: a run that was killed outright, or crashed, leaves a network and every container it started, and they only make sense together. (Ctrl+C, SIGTERM and SIGHUP aren’t those cases any more — each cleans up after itself. SIGKILL can’t be trapped by anything, so it still is; see Differences from Batect.) A container is named as your configuration names it (database), not by the random words Docker assigns.

OptionApplies toDescription
--all-projectslist, cleanEvery Ratect project’s leftovers, not just this one’s — never anything Ratect didn’t create. Also the way to use resources from outside a project directory, since the project scope is read from the configuration.
--older-than <AGE>list, cleanOnly leftovers older than AGE90s, 30m, 2h, 7d.

resources list is clean’s dry run. Both take the same options and select identically, so whatever list shows you is exactly what clean with those same options will remove — there’s no separate --dry-run because there’s nothing for it to do differently.

--older-than matters for clean. A task running right now carries exactly the same labels as a leftover, because until it finishes it is one. Ratect can’t tell the difference — the daemon can’t say whether some other ratect process still cares about a container — so a bare resources clean on a shared machine can tear down an in-flight run. --older-than 1h is the safe form when anything else might be running.

Under -o quiet, list prints resource ids one per line and nothing else, ready to pipe into docker rm. Removal takes containers before networks, since a network still holding an endpoint can’t be removed; a resource that fails to remove is reported and the rest still go.

Like caches, resources reads the configuration only for the project’s name — never for what to remove, which comes from the labels alone.

Nothing without Ratect’s own labels is ever listed or removed, --all-projects included: containers started by other tools, and Docker’s built-in bridge/host/ none networks, are invisible to both commands.

What this doesn’t cover: cache volumes/directories are caches’ territory, not resources’ — they’re a deliberate cache, not a leftover. Likewise the Git include cache under ~/.ratect/incl is includes’ own command. Built images are tagged <project>-<container> and reused/overwritten on every run rather than tracked as a resource — also a deliberate cache. Tmpfs mounts and exec instances die with their container, so there’s nothing left to find.

config

ratect config validate is doctor’s configuration half on its own — it loads the config, resolves it, and runs the same config-only checks (missing build_directory/Dockerfile, floating image tags, dependencies with no health_check), exiting non-zero on a problem. It never touches Docker, so it’s the gate to run in CI when all you want to know is “is the config valid?”, without a daemon. It takes the same --config-var/--config-vars-file options as run, since resolving the config can need them.

ratect config convert migrates a Batect-format batect.yml to a native ratect.toml — point -f at the batect.yml:

ratect -f batect.yml config convert          # writes ratect.toml beside it
ratect -f batect.yml config convert --stdout  # prints instead, to review or pipe

It’s one-directional (ratect-compat stays YAML; the reverse would be lossy) and writes ratect.toml only if one doesn’t already exist — pass --force to overwrite, or --stdout to print. The conversion preserves behaviour, not formatting: YAML anchors/aliases/merge keys are expanded inline, included files (Git bundles too) are flattened into the one result, and comments are dropped — so the output carries a header and is a starting point to review, not a blind drop-in. Before writing, the conversion is checked to round-trip losslessly back to the same configuration, so whatever it produces is guaranteed to behave identically to the original. (This first version emits the compact "8080:80" / .:/code string forms for ports/volumes rather than the object form; both are valid, and reformatting is a review step.)

doctor

Answers “why did that fail?”, or “will it?”, without running a task:

$ ratect doctor
Checking ratect.toml...
  ok      Docker daemon reachable (29.4.0)
  ok      ratect.toml loads (3 container(s), 1 task(s))
  warning container 'database' uses a floating image tag — pin it, or the same configuration will run a different image later
  warning dependency 'cache' has no health_check — unless its image defines one, it counts as ready the moment it starts
  problem container 'app' has build_directory '/project/missing-dir', which doesn't exist
  warning 4 resource(s) left over from previous runs — see `ratect resources list`

6 check(s): 1 problem(s), 3 warning(s).

A problem will fail a run — an unreachable daemon, a configuration that doesn’t load, a missing build_directory or Dockerfile. A warning works but is likely to bite: a floating image tag (latest, or no tag at all) means the same configuration runs a different image next week, and a dependency with no health_check counts as ready the moment it starts unless its image defines one, which is where “connection refused” on the first run comes from.

If you’re migrating from Batect, doctor also flags a leftover batect/batect.cmd wrapper script. Those aren’t harmless: ./batect still downloads and runs the unmaintained JVM binary, so you can think you’ve switched to Ratect while ./batect quietly runs the old tool.

Delete the wrapper and run ratect (or ratect-compat, for strict Batect compatibility) from your PATH. Batect’s committed wrapper was its installer — it fetched the right JVM version on demand — whereas Ratect is an ordinary binary you install once, so there’s nothing for a committed wrapper to do. (Don’t repoint the wrapper at Ratect by symlinking it: a committed symlink is machine-specific and doesn’t work for batect.cmd on Windows, and it still needs the binary on the PATH anyway.)

The one exception is a codebase with ./batect hardcoded across CI jobs, Makefiles and docs that you can’t change all at once: there, replacing the wrapper with a one-line transitional shim — exec ratect-compat "$@" — keeps those call sites working while you migrate them (it still needs ratect-compat on the PATH). A wrapper that no longer runs Batect isn’t flagged.

doctor exits non-zero if it found any problem, and zero for warnings alone, so it works as a CI step. Under -o quiet it prints only warnings and problems.

The environment checks run even when the configuration itself won’t load — “your config is broken and your daemon isn’t running” is more useful than fixing one to discover the other. It also reports leftovers unprompted, since the whole reason resources exists is that nobody thinks to look.

Shell completion

ratect completions <shell> prints a completion registration script to stdout for bash, zsh, fish, powershell or elvish. Source it from your shell’s startup file:

# bash — in ~/.bashrc
source <(ratect completions bash)

# zsh — in ~/.zshrc, *after* compinit has run (see the note below)
source <(ratect completions zsh)

# fish — in ~/.config/fish/config.fish
ratect completions fish | source

zsh needs its completion system initialized first. The zsh script ends with a compdef call, and compdef only exists once compinit has run — so the source line must come after autoload -U compinit && compinit in your ~/.zshrc (frameworks like oh-my-zsh already run compinit for you; just keep the source line after they load). Sourcing it in a bare shell that hasn’t run compinit fails with command not found: compdef; run autoload -U compinit && compinit first to try it interactively. bash and fish need no such initialization.

It completes command and flag names, their fixed values (-o fancy|simple|…, --cache-type volume|directory), file-path arguments, and — the part that earns its keep on a task runner — task names: ratect run <TAB> lists the tasks your config defines. That last one is dynamic: the installed script re-invokes ratect at completion time to read the config, always without cloning, pulling, or touching Docker, so a <TAB> is instant and safe.

Task completion honours an explicit -f, and follows the config’s includes — local files, and Git includes that are already cached. It never clones or pulls, so tasks from a Git include that hasn’t been fetched yet won’t appear until the first real run caches it.

Built on an unstable API. Task-name completion uses clap’s unstable-dynamic engine, so it may occasionally need a fix as that API settles. The static parts (commands, flags, values, paths) don’t depend on it.

Exit codes and diagnostics

Identical to ratect-compat: a task’s own container exit code becomes ratect’s exit code, a run ended by a signal exits 128 + that signal’s number (130 for Ctrl+C, 143 for SIGTERM, 129 for SIGHUP), anything else that fails exits 1, and the reason always reaches stderr — in every output style, including quiet. Any of those three signals abandons the run and then cleans up after it; a second one during that cleanup stops the cleanup too, and ratect resources list finds whatever that leaves. RUST_LOG controls Ratect’s own internal logging (default info, on stderr). Unlike ratect-compat there’s no --log-file; redirect stderr if you want one. A crash (a genuine bug) exits 101 and prints where to report it, ratect’s version and platform, and a reminder to re-run with RUST_BACKTRACE=1 if it isn’t already set — see ratect-compat’s own note on this, which applies identically here.

Differences from ratect-compat today

ratect-compatratect
Run a taskratect-compat <task>ratect run <task>
List tasksratect-compat --list-tasksratect tasks list
Cache cleanup--clean/--clean-cacheratect caches clean [NAME...]
Listing cachesnot availableratect caches list
Finding leftovers from a previous runnot availableratect resources list/clean
Checking a project without running itnot availableratect doctor
Managing the Git include cachenot available (only the automatic sweep)ratect includes list/clean/refresh
Batect-inert flags (--upgrade, --no-update-notification, --no-wrapper-cache-cleanup)accepted, no effectnot offered
--log-filesupportednot offered
Configurationbatect.ymlnative ratect.toml (with extends); batect.yml still readable via -f

ratect.toml Configuration Reference

This documents ratect.toml, the native configuration format the ratect binary reads by default (from 0.3.0). It is the same schema the Configuration Reference documents — the same containers, tasks, and fields, with the same meanings — re-spelled in TOML, with a few native additions (extends, an auto-discovered local overrides file) and a few YAML-isms removed (anchors, the compact string shorthands).

Because the field semantics are identical across both formats, this reference does not repeat them: for what a given field actually does, follow the links into config-reference.md. What’s covered here is the parts that are genuinely different — the TOML spelling, and the native-only rules.

The native format is ratect’s alone. ratect-compat reads batect.yml (YAML) permanently, for Batect compatibility — see Two Binaries. To migrate an existing batect.yml, run ratect config convert.

The file

A ratect.toml describes a project’s containers and tasks. Named containers and tasks map onto TOML tables, so a container build-env is [containers.build-env] and a task build is [tasks.build]:

project_name = "my-app"

[containers.build-env]
image = "rust:1.90"
working_directory = "/code"
volumes = [{ local = ".", container = "/code" }]
environment = { CARGO_TERM_COLOR = "always" }

[tasks.build]
description = "Compile the project"
group = "Development"
run = { container = "build-env", command = "cargo build" }

project_name is the only required top-level key (it’s taken from the root file only, and names the images and cache volumes the project creates — see Top level). ratect defaults -f to ratect.toml; point it at a differently-named file, or a batect.yml, with -f.

extends: inheritance instead of YAML anchors

batect.yml factors out a shared base container with YAML anchors/aliases/merge keys (&base/*base/<<:). Those are YAML syntax and don’t exist in TOML, so ratect.toml replaces them with an explicit extends field:

[containers.base]
image = "rust:1.90"
environment = { CARGO_TERM_COLOR = "always" }

[containers.build-env]
extends = "base"
working_directory = "/code"      # added
environment = { RUSTFLAGS = "-D warnings" }  # replaces base's entirely

The rules:

  • Single parent. extends names exactly one container.
  • Shallow, per field. A field the child sets replaces the inherited one outright — there is no deep-merging into nested maps. Above, build-env’s environment is { RUSTFLAGS } only; base’s CARGO_TERM_COLOR is not merged in. This matches how <<: already behaves, and Cargo’s profile inherits. To keep an inherited map and add to it, restate the whole map.
  • Chains. a may extend b which extends c; each level fills what the one below left unset. A cycle (including a container extending itself) is an error.
  • Base-only containers need no image. Only a container a task actually runs is required to have an image or build_directory, so a base that exists purely to be extended can omit both.
  • Resolved after paths. Inheritance happens after relative paths are made absolute, so an inherited build_directory or volume host path stays anchored to the file that declared it, not the child’s location — this matters when the parent came from an included file.
  • Overriding a build with an image. Because inheritance is per-field with no way to unset one, setting image on a child is how you override a parent’s build_directory: image wins, and the inherited build_directory is simply unused. ratect-compat rejects a container with both fields (Batect does, and has no extends that would need the override) — this is a deliberate difference, not an oversight. A container used only as an extends base likewise needs neither field; the requirement is enforced when a task actually runs a container, so no abstract marker is needed.
  • Containers only. Tasks do not extends (compose task behaviour with prerequisites/dependencies instead).

One shape per list entry

volumes, ports, and devices take one object shape per entry — not the compact "local:container" strings batect.yml also accepts. Use inline tables for the terse cases and [[...]] array-of-tables blocks for longer ones; they’re equivalent:

[containers.app]
image = "postgres:16"

# Inline tables — compact.
volumes = [
    { local = ".", container = "/code" },
    { local = "./secrets", container = "/run/secrets", options = "ro" },
]
ports = [{ local = 5432, container = 5432 }]

# Or the block form — readable when there are many fields.
[[containers.app.devices]]
local = "/dev/kvm"
container = "/dev/kvm"
  • A volumes entry is a host bind (local + container [+ options]), a named cache volume ({ type = "cache", name = "...", container = "..." }), or a tmpfs mount ({ type = "tmpfs", container = "...", options = "..." }).
  • A ports entry is { local, container } [+ protocol], with port ranges written as "6000-6010" — see Port mappings.
  • A devices entry is { local, container } [+ options].

The parser itself still accepts the string forms (which is what lets a .yml include keep using them), but the native schema, the docs, and config validate treat the object form as canonical.

Config variables and expressions

Config variables are declared under [config_variables] and referenced with the same <name / <{name} expression syntax as batect.yml — the syntax is values inside strings, so it carries across verbatim. Which fields resolve one is not identical: this format also resolves them in image, which a batect.yml refuses — see Expressions in image, and note that the image line in the example below is exactly that case. Otherwise see ConfigVariable and Expressions.

[config_variables.tag]
default = "latest"
description = "The image tag to run."

[containers.app]
image = "myapp:<{tag}"                 # a config variable
environment = { HOME = "${HOME}" }      # a host environment variable

Local overrides

A ratect.local.toml beside the config file is loaded automatically when present (no flag), supplying config-variable values for the current developer/machine — the native default for --config-vars-file:

# ratect.local.toml — gitignore this.
tag = "dev"

It holds values only, not configuration: a flat name = "value" map, nothing else. Anything you want to vary locally should be a config variable the tracked config interpolates, keeping what varies declared and visible rather than hidden in an untracked file. Precedence, lowest to highest: a variable’s default, then the config-vars file (ratect.local.toml, or whatever --config-vars-file names), then --config-var on the command line.

Includes

include is an array of entries, each a local file or a Git bundle. Formats may mix: each included file is parsed by its extension.toml as native, .yml/.yaml as Batect-format YAML — so a native project can still pull in an existing batect.yml fragment or bundle unchanged.

include = [
    { path = "ci/tasks.toml" },                              # local, native
    { path = "shared/legacy.yml" },                          # local, still YAML
    { type = "git", repo = "https://example.com/bundle.git", ref = "v2" },
]

A type = "git" entry with no path discovers its bundle file by looking for ratect-bundle.toml first, then batect-bundle.yml — so an unmigrated Batect bundle keeps working, and a bundle author can ship both files to support ratect and Batect at once. See Includes for how paths resolve, the containment rules for Git bundles, and the shared ~/.ratect/incl cache (ratect includes manages it).

An extends in a native file may inherit from a container defined in any included file, including a YAML bundle — the container namespace is flat once includes are merged.

Shared caches

A cache mount is private to the project by default: the storage carries the project’s own key, so two projects declaring cargo-registry get two different caches. scope = "shared" drops that key, so every project on the machine naming it gets the same storage.

[[containers.build-env.volumes]]
type = "cache"
name = "cargo-registry"
container = "/usr/local/cargo/registry"
scope = "shared"        # or "project", the default

[[containers.build-env.volumes]]
type = "cache"
name = "build-output"
container = "/build"    # no scope: private to this project

This exists because the alternative is worse. A bundle that wants one Cargo registry or npm cache across projects has, until now, had to spell it as a host path (local = "~/.cache/cargo"), which means granting the bundle access to your home directory — the thing allow_host_paths exists to permit and decisions/0004 would rather solve properly. A shared cache says the same thing directly, grants no host filesystem access at all, and keeps the location under Ratect’s control.

Where it is stored. A shared cache is the Docker volume ratect-shared-cache-<name>, or the directory ~/.ratect/caches/<name> under --cache-type=directory — beside ~/.ratect/incl, where Git includes are cloned, because both belong to the machine rather than to any one project. A project cache remains batect-cache-<project key>-<name>.

A name has one scope per project. Declaring cargo-registry as project in one container and shared in another is rejected when the file loads: one name would mean two different pieces of storage. Two containers naming the same cache is the ordinary way to share it between them, and is unaffected.

Removing one takes naming it. ratect caches clean with no arguments sweeps this project’s caches and never a shared one — discarding storage other projects are still using should not be a side effect. See the caches options.

batect.yml has no equivalent, so scope is rejected there rather than ignored — see Differences below.

Expressions in image

A container’s image takes expressions, so a pipeline can choose its image per run without a flag:

[config_variables.tag]
default = "latest"

[containers.app]
image = "my-repo/my-image:<{tag}"

[containers.tools]
image = "my-repo/tools:${IMAGE_TAG:-latest}"

Both forms work: <{tag} reads a config_variables entry (settable with --config-var tag=1.2.3), and ${IMAGE_TAG:-latest} reads the host environment with a fallback. The same rules apply as everywhere else — an unset host variable with no :-default is a hard error naming it, rather than a silent empty string that would produce a puzzling image reference.

Resolution happens before extends is applied, so a container inheriting an image inherits the resolved value, consistent with build_directory and volume host paths.

Resolution is eager and covers the whole file, not just the containers your task uses — again like every other expression-bearing field. So an unset variable with no :-default fails every task in the file, including tasks that never touch the container declaring it. Give a default where a variable is genuinely optional. The error names the container, so you are not left hunting for which one.

Rejected in a batect.yml, rather than resolved or ignored. Batect has no expression support in image, so a file using one would load here and fail under batect itself — and unlike an exotic capability, a parameterised image tag is something a pipeline would use on every run, so the lock-in would be routine rather than incidental. ratect-compat users have --override-image, which covers most of the same ground; what it can’t express is an in-config default, which is what this adds.

The rejection is on what you wrote, not on what it would resolve to, and it knows the difference between an expression and a literal $: alpine:3.18 and repo/img:1.2.3 load exactly as before.

Nested Git includes

A Git include fetches configuration from a repository and merges it into yours. That bundle can declare include entries of its own — and in a batect.yml those may be further type: git entries, naming any remote, with the same trust your own includes get.

In ratect.toml that is refused by default:

The bundle 'https://github.com/my-org/infra-bundle.git' at '1.2.3' declares a
Git include of its own ('https://elsewhere.example/other.git'), which would
fetch and run configuration from a remote you have not named. Set
'allow_nested_git_includes' to true on that bundle's own include entry to
accept this.

You chose the bundle; you did not choose whatever it decides to pull in next, and that choice can change under you the next time the ref moves. Opt in per bundle:

[[include]]
type = "git"
repo = "https://github.com/my-org/infra-bundle.git"
ref = "1.2.3"
allow_nested_git_includes = true

The entry doesn’t have to be in the ratect.toml itself — a native project can include a local .yml, and an entry declared there is just as much your own configuration, spelled allow_nested_git_includes: true. What makes a file yours is that it was not reached through a Git include, not its extension.

The grant is one level deep. It admits that bundle’s own Git includes; it does not let those bundles declare further ones. Like allow_host_paths, it counts only in configuration you control — written inside a Git-included file it is ignored, so a bundle can neither grant itself the permission nor pass on the one you gave it. If a bundle genuinely needs a chain deeper than that, include the second repository yourself, where you can see it.

Put it on the entry that reaches the file first. An included file is read once however many entries reach it, so the first entry to reach one decides what it may do — and this grant, on a losing entry, would do nothing at all. Every entry in your root file is reached before any bundle’s own, so declaring the include yourself beats a bundle to it; between two entries in the same file, the earlier one wins. Where two entries reach the same file and the losing one carries a grant, Ratect refuses to load and names the repository, rather than dropping it silently; the same rule covers allow_host_paths.

It is the file that races, not the repository: two entries naming the same repository with different paths pull in two different files, and each keeps the grant written on its own entry.

That is a different case from the paragraph above, which two words could easily blur. A grant written inside a bundle is ignored — accepted by the parser and worth nothing, because honouring it would let a bundle grant itself. A grant written in your own configuration that loses the race above is refused — the load stops, because you wrote something that cannot take effect and nothing else would tell you.

A nested include’s clone failure is reported without git’s own message. Whether a remote is unreachable, refusing connections, missing, or demanding credentials is a readout on a network — and for a nested include the remote was named by the bundle, not by you, so the answer is of more use to whoever wrote it than to you. In CI, where the log is often visible to anyone who can propose a change to that bundle, repeated attempts map an internal network one include at a time. The failure is still reported and still names both repositories; only the transport detail moves behind RUST_LOG=debug. An include you declared keeps git’s message in full — it describes a remote you wrote down, and hiding it would only make your own typo harder to find.

The field is rejected in a batect.yml rather than ignored, value and all: setting allow_nested_git_includes to false there would claim a restriction that format never applies.

Field reference

Every container and task field from config-reference.md applies, with the same meaning except where Where the semantics differ says otherwise. Scalars, string maps (environment, labels, build_args, …) and scalar lists (capabilities_to_add, additional_hostnames, …) are a direct 1:1 spelling; the only fields whose shape differs are the object-per-entry lists above. The container fields, by area:

AreaFieldsSemantics
Imageimage, image_pull_policy, build_directory, dockerfile, build_target, build_args, build_secrets, build_sshImage building
Mountsvolumes (host / cache / tmpfs)Volumes, caches, tmpfs. A cache also takes scope (native only) — the linked section describes project-keyed storage, which scope = "shared" deliberately does not use.
Runtimecommand, entrypoint, working_directory, environment, enable_init_process, privileged, shm_size, capabilities_to_add, capabilities_to_drop, devices, labels, log_driver, log_optionsContainer
Networkingports, additional_hostnames, additional_hosts, dependenciesPorts, readiness
Readinesshealth_check, setup_commandsDependency readiness
Userrun_as_current_userUser mapping
Inheritanceextendsabove (native only)

Where the semantics differ

Almost nothing: the two formats parse into the same model, so a field means what config-reference.md says it means. The exceptions fall into three groups: places where extends gives a combination a meaning it cannot have in a batect.yml, which has no inheritance; places where this format is deliberately stricter, having no Batect compatibility to preserve; and one place where it does more than Batect, which batect.yml then has to refuse rather than quietly accept.

Behaviourbatect.yml (ratect-compat)ratect.toml (ratect)
A Git-included bundle declaring a type: git include of its ownAlways allowed, matching BatectRefused unless the bundle’s own include entry sets allow_nested_git_includes
A nested Git include failing to cloneReports git’s own errorReports that it failed, with the transport detail behind RUST_LOG=debug — see Nested Git includes
An expression in imageRejected when the file loads — Batect resolves nothing thereResolved like any other expression — see Expressions in image
A container with both image and build_directoryRejected when the file loads, matching BatectAllowed — image wins, and this is the only way to override a build_directory inherited from an extends parent, since inheritance is per-field with no way to unset one
A container with neither image nor build_directoryRejected when the file loadsAllowed — a container used only as an extends base needs neither; the requirement is enforced when a task actually runs a container, so no abstract marker is needed
image alongside a build-only field (build_args, build_target, dockerfile, build_secrets, build_ssh)Rejected when the file loadsAllowed and ignored, for the same inheritance reason — a child overriding a build with an image still carries the parent’s build fields

The last row is the one to watch: setting build_secrets or build_ssh on a container that also has an image does nothing at all, and the native format cannot tell you so without forbidding the override above. If a build field looks like it is being ignored, check whether the container resolves to an image.

Task fields: run (a TaskRun table — container, command, entrypoint, environment, ports, working_directory), prerequisites, dependencies, description, group, and customise (see Task). A task needs at least one of run or prerequisites.

[tasks.integration-test]
description = "Run the integration suite"
prerequisites = ["build"]
run = { container = "test-runner", command = "pytest tests/integration" }

[tasks.integration-test.run.environment]
DATABASE_URL = "postgres://db/test"

Editor support

Ratect ships a JSON schema for the native format, schema/ratect-config.schema.json (generated from the config types, so it can’t drift). Pointing a TOML-aware editor extension at it — taplo / “Even Better TOML” for VS Code, or JetBrains’ TOML support — gives field-name autocompletion, hover documentation, and a red squiggle under a misspelled or unsupported field. It’s the native counterpart of the batect.yml schema: the same schema, adjusted to the native shape (object-only list entries, plus extends).

The simplest way to use it is a schema directive on the first line of your config, which taplo honors:

#:schema https://raw.githubusercontent.com/or1can/ratect/main/schema/ratect-config.schema.json
project_name = "my-project"

Structural validation only catches what the schema can express; for the rules it can’t (a task needing run or prerequisites, an extends cycle, a container with neither image nor build_directory), ratect config validate checks a ratect.toml without a Docker daemon, so it also works as a CI gate.

Differences from batect.yml, at a glance

batect.yml (ratect-compat)ratect.toml (ratect)
FormatYAMLTOML
Default filebatect.ymlratect.toml
Reuseanchors / aliases / merge keysextends
Cross-project cachescope = "shared" on a cache mount
List entriesstring shorthand or objectobject (inline table or [[...]])
Local overridesbatect.local.ymlratect.local.toml
Git bundle defaultbatect-bundle.ymlratect-bundle.toml, then batect-bundle.yml
IncludesYAMLTOML or YAML, by extension

Most field meanings are unchanged; the spelling and the format-level rules above are the bulk of the difference. The exceptions are the native-only fields (extends, a cache’s scope) and the handful of behaviours in Where the semantics differ, which exist because extends gives some combinations a meaning batect.yml has no way to express.

Differences from Batect

Ratect is a from-scratch Rust implementation inspired by Batect (which is itself no longer maintained — the upstream repository was archived in October 2023), not a wrapper or fork. It does not read Batect’s documentation or source at runtime.

Every Batect configuration field and CLI flag is supported, field-for-field and flag-for-flag, unless listed below — see config reference/ CLI reference for the full accepted schema and flags. This page lists the exceptions: a real behavioral divergence, an extension beyond what Batect does, or a restriction narrower than it. It doesn’t restate how a field or flag works — config reference/CLI reference/ task lifecycle are the authoritative source for that; this page only says what’s different and points there for the rest.

Unrecognized fields fail closed: Ratect’s YAML parsing rejects unknown keys — a typo’d field name, or (now unlikely, since Batect’s own include types beyond file/git are the only thing left genuinely unsupported here) a real gap — fails config loading with an error naming it, rather than silently ignoring it. There’s no partial/best-effort mode.

Configuration format

Top-level fields

Every other top-level field is supported field-for-field — see config reference for the full list. The exceptions:

FieldNotes
config_variablesdescription: is recognized but inert — Ratect has no help/usage output to show one in.
includeRatect enforces that a Git include’s path (and anything it transitively includes) stays within that repository’s own clone; Batect has no equivalent containment check. In ratect.toml specifically, a Git-included bundle also can’t declare further Git includes of its own unless allow_nested_git_includes is set — ratect-compat stays unrestricted, matching Batect. See Git includes.
forbid_telemetryRecognized, no effect — Ratect doesn’t collect telemetry, so there’s nothing to forbid.

Expressions

Matches Batect exactly, field-for-field — see Expressions for the full syntax and which fields support it. The one exception, image, is in Container fields below.

Container fields

Every other container field is supported field-for-field — see config reference for the full list. The exceptions:

FieldNotes
imageAn expression-looking value ($VAR) is rejected when the file loads rather than resolved or used as a literal — Batect resolves nothing here either, but silently treats it as a literal image name that then fails at pull time instead. ratect.toml does resolve them; see config reference.
volumesA cache mount’s name must use Docker’s own volume-name character set; Batect doesn’t validate it at all, so an unvalidated name could bind-mount an arbitrary host directory under --cache-type=directory. Breaking change for --cache-type=directory only — --cache-type=volume already enforced this via Docker itself. See Cache volumes.
capabilities_to_add / capabilities_to_dropAlso accepts BPF/CHECKPOINT_RESTORE/PERFMON — Docker capabilities added after Batect’s last release, so its own Capability enum predates them. A superset: every config Batect itself accepts here still parses identically.
health_check / setup_commandsThe task’s own container’s readiness gate can race a very fast main command — see task lifecycle.
log_driver / log_optionsAn absent value leaves the daemon’s own default alone; Batect’s config model bakes in a literal "json-file" default explicitly. Immaterial in practice — that’s Docker’s own out-of-the-box default too.
run_as_current_userHost-side uid/gid lookup only works on Unix — see User mapping.

Task fields

Every task field is supported field-for-field, with no divergence from Batect — see config reference for the full list.

run fields

Every run field is supported field-for-field, with no divergence from Batect — see TaskRun for the full list.

CLI flags

Every other flag from Batect’s own CLI reference is supported flag-for-flag — see CLI reference for the full list. The exceptions:

FlagNotes
--versionAlso gets a -V short form Batect doesn’t have (a clap default).
--output / -oAn explicit -o fancy on a non-interactive console fails up front with a clear error; Batect accepts it and crashes with an unhandled exception on the first repaint. all’s status lines also drop Batect’s inner Batect | prefix — the outer prefix already says whose line it is. See Output styles.
--no-colorA superset, not a gap: Batect rejects -o fancy --no-color at parse time (its console couples color and cursor movement under one flag); Ratect’s keeps them independent, so that combination renders colorless fancy instead.
--no-cleanup, --no-cleanup-after-failure, --no-cleanup-after-successBatect’s own DontCleanup still stops a started container, just skips removing it; Ratect leaves it genuinely running (not just present-but-stopped) for investigation.
--docker-cert-path, --docker-tls, --docker-tls-verify, --docker-tls-ca-cert, --docker-tls-cert, --docker-tls-keyBatect’s bare --docker-tls (without -verify) disables all server certificate verification, not just hostname matching. Ratect doesn’t support that mode at all — --docker-tls and --docker-tls-verify behave identically here, the daemon’s certificate always fully verified. See TLS with a private certificate authority for the supported alternative (your own CA).
--cache-typeUnlike Batect, not forced to directory for Windows containers — Ratect has no Windows support to special-case yet.
--max-parallelismBatect’s flag caps every setup/cleanup step via a step-scheduling model Ratect doesn’t have. Ratect’s caps a narrower set — image pulls/builds, a dependency’s create+start, and setup commands — the resource-intensive operations; health-check waits and cleanup teardown are deliberately excluded, and the task’s own container’s run is never gated, matching Batect’s own exemption for it.
--log-fileBatect’s own default (no --log-file) is a silent NullLogSink, nothing anywhere; Ratect always logs to stderr regardless, so --log-file here tees into a file in addition to stderr, not instead of it.
--no-update-notification, --upgrade, --no-wrapper-cache-cleanupRecognized, no effect — permanently inapplicable, since Ratect is a single native binary with no self-updating wrapper script to disable notifications for, clean caches for, or upgrade. Recognized rather than rejected so an existing Batect invocation carrying one doesn’t hard-fail outright. See CLI reference.

Runtime behavior gaps

Batect behavior not implemented in task execution, beyond what’s covered by the field tables above:

  • Cleanup on a termination signal (Ctrl+C, SIGTERM, SIGHUP): three deliberate differences from Batect, which traps SIGINT only:

    • Ratect also traps SIGTERM and SIGHUP, down the same cleanup path — a task runner is stopped by more than a keystroke (an editor closing its subprocess, docker stop, systemd, most CI cancel buttons), and each was previously a leaked container and network.
    • The exit code names the signal: 128 + the signal’s own number (130/143/129 for Ctrl+C/SIGTERM/SIGHUP). Batect returns -1/255 for every failure alike.
    • A second signal during cleanup stops the cleanup itself, immediately — Batect instead switches to printing manual cleanup commands. Whatever’s left still carries Ratect’s ownership labels, so ratect resources list/clean finds it (ratect-only; ratect-compat has no equivalent verb, so from it the sweep is docker itself, filtering the same labels).

    SIGKILL remains untrappable by either tool, same underlying OS limitation — the same labels are what a post-hoc ratect resources clean needs to find what it left.

  • Ownership labels: every container and network Ratect creates carries eu.orican.ratect.* labels; Batect labels nothing of its own. An additive divergence — changes no behavior. See decisions/0002.

  • Interactive mode: two known divergences. Batect’s real-TTY gate checks only whether its output is a real terminal; Ratect’s requires both stdin and stdout to be real terminals. Live terminal-resize tracking is also Unix-only — synced once at session start elsewhere, not tracked further. See Interactive mode.

  • Proxy support: two deliberate differences — see Proxy environment variables for the full mechanics.

    • A localhost proxy is rewritten on Linux too. Batect rewrites on macOS/Windows only; on Linux it propagates the URL verbatim, where localhost means the container itself. This is Batect’s oldest open issue, eight years old.
    • A proxy bound to loopback only is diagnosed, not left to fail — Batect’s own roadmap notes the same warning is needed and never shipped one.

    What stays an accepted gap is Batect’s Docker-version-gated hostname fallback chain, which reaches back to Docker 17.06 — not worth chasing for any actively-maintained daemon.

  • Private registry credentials: Batect’s own Go client swallows every credential-helper error unconditionally; Ratect warns instead, naming the registry that failed to resolve. See Private registry credentials.

  • A dependency exiting unexpectedly is reported, not silent. Batect has no equivalent: a dependency that dies after becoming ready — while the task’s own command, or a later dependency’s own health/setup wait, is still going — is otherwise invisible until whatever depended on it fails for a confusing, unrelated-looking reason (a connection refused, a timeout). Ratect prints a warning naming the container and its exit code instead, in every output mode. See task lifecycle.

  • all mode splits on a lone carriage return too, not just \n. Batect’s InterleavedContainerOutputSink splits on \n only, so a container using \r to redraw progress in place (pip/curl/apt-style) produces no output at all until the stream ends, then dumps everything as one giant concatenated line. A deliberate divergence: Ratect flushes on a lone \r (one not immediately followed by \n — a CRLF pair still folds to a single line break) the same way it already does on \n, so a real progress bar now prints one interleaved line per redraw tick instead of staying silent — spammier, but never silent-then-dumped.