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

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.