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>
| Field | Type | Required | Description |
|---|---|---|---|
project_name | string | yes | Used only for display (e.g. in --list-tasks output). |
containers | map of name → Container | yes | Container definitions, keyed by name. Referenced from tasks via run.container. |
tasks | map of name → Task | yes | Task definitions, keyed by name. Run by name via ratect-compat <task-name>. |
config_variables | map of name → ConfigVariable | no | Declares the config variables usable via <name/<{name} expressions. A name must be declared here before it can be referenced — see Expressions. |
include | list of string or Include | no | Splits configuration across multiple files — see Includes below. |
forbid_telemetry | boolean | no | Recognized 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, andconfig_variablesmay each be omitted entirely (they default to empty) — a file that exists only toincludefurther 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 include — not 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
| Field | Type | Required | Description |
|---|---|---|---|
repo | string | yes | A Git remote — anything git clone itself accepts (an HTTPS/SSH URL, or a local path). |
ref | string | yes | The 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. |
path | string | no, default batect-bundle.yml | The 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.tomldiffers here. The native format refuses a bundle’s own Git includes unless you opt in per bundle withallow_nested_git_includes— see Nested Git includes. This page describesbatect.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
| Field | Type | Required | Description |
|---|---|---|---|
image | string | one of image/build_directory | A 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_policy | IfNotPresent or Always | no | On 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_directory | string | one of image/build_directory | Builds 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_args | map of string → string | no | Build-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. |
dockerfile | string | no | The 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_target | string | no | The 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. |
volumes | list of strings/objects | no | Host bind mounts (local), named cache volumes (cache), and in-memory tmpfs mounts (tmpfs) — see Volume path resolution, Cache volumes, and Tmpfs mounts below. |
dependencies | list of strings | no | Names 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. |
environment | map of string → string | no | Environment 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_user | object (enabled, home_directory) | no | Runs this container as the host’s own user/group instead of the image’s default (see User mapping below). |
additional_hostnames | list of strings | no | Extra network aliases this container is reachable by, beyond its own name. No expression support. |
additional_hosts | map of string → string | no | Extra /etc/hosts entries in this container, hostname: ip, Docker’s own --add-host mechanism. No expression support. |
ports | list of strings/objects | no | Publishes 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_check | object | no | Overrides the health check configuration baked into the container’s image (see Dependency readiness below). No expression support. |
setup_commands | list of objects (command, working_directory) | no | Commands run inside the started container after it becomes healthy but before its dependents start (see Dependency readiness below). No expression support. |
working_directory | string | no | Overrides 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. |
command | string | no | Overrides 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. |
entrypoint | string | no | Overrides 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. |
labels | map of string → string | no | Docker labels applied to the container. Container level only — no task-level run override. No expression support. |
capabilities_to_add | list of strings | no | Linux 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_drop | list of strings | no | Linux capabilities to drop from Docker’s own default set (Docker’s --cap-drop), e.g. CHOWN. Same validation/scope as capabilities_to_add. |
privileged | boolean | no | Runs the container with extended (nearly all host) privileges — Docker’s --privileged. Defaults to false. Container level only. No expression support. |
shm_size | string or integer | no | The 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. |
devices | list of strings/objects | no | Host 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_process | boolean | no | Runs 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_driver | string | no | Docker’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_options | map of string → string | no | Driver-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
imagenorbuild_directoryset, 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_directoryworks 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_BUILDKITenvironment variable overrides this either way (1/trueforces BuildKit,0/falseforces 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 overDOCKER_BUILDKITtoo — there’s no--disable-buildkitcounterpart; force the classic builder viaDOCKER_BUILDKIT=0/falseinstead. A daemon old enough not to advertise a default builder at all falls back to the classic builder. Note thatbuild_secretsandbuild_sshrequire 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 tobuild_directory’s own root), defaulting toDockerfileatbuild_directory’s own root when omitted. -
build_targetstops the build at that stage, for a multi-stageFROM ... AS <name>Dockerfile — Docker’s own--targetmechanism. -
A
.dockerignorefile atbuild_directory’s root, if present, excludes matching files from the build context — see.dockerignoresemantics below for the (non-obvious) matching rules. No.dockerignoremeans the whole directory tree becomes the build context, unchanged from before this existed.dockerfileand.dockerignoreitself are always included in the build context regardless of exclusion patterns, matching Docker’s own special-casing. -
build_secretsexposes secrets to the build via BuildKit’s secret-mount mechanism (a Dockerfile’sRUN --mount=type=secret,id=<key>), without persisting them into the built image’s layers — keyed by theidsuch aRUNinstruction references. Each entry is either{environment: NAME}(read from thisratectprocess’s own environment at build time) or{path: ...}(read from a file on the host, resolved likebuild_directory); exactly one of the two is required. Requires the BuildKit builder (see builder selection above). Usingbuild_secretsdisables 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_sshmakes SSH keys available to the build, for a Dockerfile’sRUN --mount=type=sshinstructions. Each entry is one agent, named by anidaRUNinstruction selects with--mount=type=ssh,id=<id>.idis required, and ids must be unique across the list. Writedefaultfor the agent a bareRUN --mount=type=sshuses — BuildKit’s implicit id is not applied for you, because Batect requiresidtoo and a config that omitted it would work here but not underbatect. Each entry’spathsdecides 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 pathpathsvalues support expressions and are resolved relative to the config file, the same waybuild_directoryandbuild_secrets’pathare. Key files are served by an ssh-agent Ratect runs in-process for the duration of the build: the keys themselves never leave theratectprocess — 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-1ssh-rsaalgorithm, 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.sockworkaround involved). -
The built image is tagged
<project_name>-<container_name>(matching Batect’s own default), so it’s identifiable indocker imagesrather 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 plaindocker build -t ... .would leave behind. Docker’s own build cache is likewise untouched by Ratect. Matches Batect exactly — itsBuildImageStepRunner/CleanupStagePlannerhave no cache-control flag or image-removal step either. -
Ratect has no
--outputmode yet, so build progress is logged rather than streamed to the console: each build log line is emitted atdebuglevel (setRUST_LOG=info,ratect_core=debugfor a live transcript without unrelated dependency noise — see filteringRUST_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 failingRUNstep’s own output is always visible without needingRUST_LOGset.
.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_modulesexcludes a top-levelnode_modulesdirectory, but not a nested one likepackages/foo/node_modules— unlike.gitignore, where a slash-free pattern matches at any depth by default. Use**/node_modulesfor 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/, andfoo/barare all equivalent) — including a trailing slash not restricting a match to directories only, unlike.gitignore. Dockerfileand.dockerignorethemselves 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/toolmounts 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_directoryis resolved the same way (it has no:container_pathpart 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 ascopefield here, for a cache shared across projects. Everything below describes the project-scoped kind, which is the only onebatect.ymlhas.
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 acachevolume 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-batectrun), 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.--cleanremoves 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:
enabledandhome_directoryare only ever valid together — this matches Batect’s own behavior exactly, not a Ratect-specific restriction. Settingenabled: truewith nohome_directoryis 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) withhome_directorystill set — e.g. simply flippingenabledback tofalsewithout also deletinghome_directoryfails config loading. Removehome_directoryentirely to disable user mapping, not justenabled.
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
localmount’s host path (or acachemount’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 asroot:rooton first use, defeating the point for the common “mount my code directory, get build artifacts back with sane ownership” case. Doesn’t apply to acachemount 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/groupentry 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/groupentries are uploaded into the container before it starts. home_directoryitself 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
cachemount 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 insidehome_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]"(protocoldefaults totcp), 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 (9000→9100,9001→9101,9002→9102);localandcontainermust cover the same number of ports. - The expanded object form,
{local, container, protocol}—local/containereach accept a single port or a range (8443or"8000-8002"),protocolis optional (defaults totcp).
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:
- It must report healthy. If the container has a Docker health check — from its
image’s own
HEALTHCHECK, from thehealth_checkfield, 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. - Its
setup_commandsmust succeed. Each runs inside the running container (via Docker’sexecmechanism), one at a time in declared order, with the container’s ownenvironmentand (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:
| Field | Type | Description |
|---|---|---|
command | string | The 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. |
interval | duration | Time between health check runs. |
retries | integer | Consecutive failures needed before the container is considered unhealthy. |
start_period | duration | Time during which failing checks don’t count against retries (a success during it still counts as healthy immediately). |
timeout | duration | Time 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 runscommandeveryinterval; the first success makes the container healthy, and onlyretriesconsecutive 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_perioddon’t count towardretriesat 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:
| Field | Type | Required | Description |
|---|---|---|---|
command | string | yes | The 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_directory | string | no | Directory 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
| Field | Type | Required | Description |
|---|---|---|---|
run | TaskRun | no* | What to actually execute for this task. |
prerequisites | list of strings | no* | 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. |
dependencies | list of strings | no** | Sidecar containers scoped to this task specifically — see below. Requires run. |
description | string | no | Shown next to the task’s name in --list-tasks output — see below. Purely informational. |
group | string | no | Groups 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. |
customise | map of string → TaskContainerCustomisation | no | Per-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
| Field | Type | Required | Description |
|---|---|---|---|
environment | map of string → string | no | Merged 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. |
ports | list of strings/objects | no | Added to the container’s own ports, not an override — see Port mappings. |
working_directory | string | no | Overrides 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
| Field | Type | Required | Description |
|---|---|---|---|
container | string | yes | Name of a container defined under containers. |
command | string | no | Overrides 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. |
environment | map of string → string | no | Environment 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. |
ports | list of strings/objects | no | Additional 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_directory | string | no | Overrides the container’s own working_directory for this task’s run specifically (see Container). No expression support. |
entrypoint | string | no | Overrides 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’srun.environment, both override a proxy-derived value on a key collision (see TaskRun for how those two combine with each other).build_argsworks the same way for builds. -
no_proxyis extended automatically: every container sharing a task’s network (the task’s own container and each of its dependencies) has its own name appended tono_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. -
localhostrewriting:http_proxy/https_proxy/ftp_proxyvalues that point atlocalhost,127.0.0.1, or::1are rewritten tohost.docker.internal, sincelocalhostfrom inside a container refers to the container itself, not the host machine running a proxy. A value that isn’t ahttp/httpsURL, 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.internalitself and on Linux nothing does.So a run that rewrote a URL also adds
host.docker.internal:host-gatewayto every container it starts and every image it builds, using Docker’s own--add-hostmechanism, 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/hostsline 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 ownadditional_hostsentry 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 whatcntlmand similar default to — still refuses a connection from a container, so on Linux Ratect checks/proc/net/tcp/tcp6and 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-varsif 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.0opens 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, binds127.0.0.1:3128by default and listens on every interface only under itsGatewayoption.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
docker0bridge, so a firewall rule written fordocker0won’t cover it. Ratect creates a network per task, or uses the one--use-networknames — and a run can’t fall back to the default bridge even if you point--use-networkat it, because Ratect gives every container a network-scoped alias and Docker only allows those on user-defined networks (docker runrefuses withnetwork-scoped aliases are only supported for user-defined networks).Match the rule on that network’s subnet, which
docker network inspect <network>reports underIPAM.Config. Its interface name is generally not in that output — Docker only records one there when the network was created withcom.docker.network.bridge.name, which is howdocker0itself gets its name — so a rule pinned to an interface is both harder to write and easier to get wrong. -
--no-proxy-varsdisables 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
| Field | Type | Required | Description |
|---|---|---|---|
default | string | no | The 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. |
description | string | no | Recognized 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.tomldiffers here. The native format also resolves expressions inimage, so a tag can be chosen per run — see Expressions inimage. It is rejected in abatect.ymlrather than ignored, because Batect resolves nothing there and a file using one would stop working underbatectitself. In this format,--override-imageis 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.
| Form | Resolves against | Example |
|---|---|---|
$NAME | ratect’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} |
<name | A 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.