Docs · reference
Compose file and x-denv
The compose subset denv accepts, what it refuses and why, resource caps, the x-denv block, and the verifier contract.
An environment is described by a Docker Compose file plus an x-denv block. The control plane parses it at deploy time, rejects anything outside the subset below with the reason shown, and pins the result as the version's spec. The sidecar renders a new compose file from that spec; your file is never run verbatim.
Supported keys
Top level: services, volumes, x-denv. Everything else is ignored.
Per service:
| Key | Handling |
|---|---|
image |
Required. |
environment |
Map or KEY=VALUE list. |
ports |
Container ports only. "8080:80" is reduced to 80; a /tcp suffix is dropped. Host ports are never pinned because forks need unique ports. |
volumes |
"<volume>:<container path>" only, where <volume> is declared at top level. |
depends_on |
List or map (keys only; conditions ignored). |
command |
String (split on whitespace) or list. |
healthcheck |
test (string or list), interval, retries. |
user |
uid[:gid]. |
mem_limit, pids_limit, cpus |
Caps; see below. |
deploy.resources.limits.{memory,cpus,pids} |
Alternative spelling of the caps; used only where the shorthand is absent. |
Refused
| Condition | Error |
|---|---|
| No services | compose: no services |
Service without image |
compose: service "x" has no image (build is not supported in v0; push an image) |
network_mode: host |
compose: service "x" uses network_mode: host, which cannot be forked |
privileged or cap_add |
compose: service "x" asks for privileged or cap_add, which is refused |
Bind mount (/path:… or ./path:…) |
compose: service "x" bind-mounts "/path"; only named volumes are forkable |
| Undeclared volume | compose: service "x" uses undeclared volume "v" |
| Volume without a container path | compose: service "x" volume "v" must be <volume>:<path> |
x-denv.egress not deny/allow |
compose: x-denv.egress must be deny or allow, got "…" |
build: is not supported: a service needs a pushed image.
Resource caps
Every service gets memory, pid, and CPU caps. Zero in the file means the sidecar default.
| Cap | Compose keys | Default |
|---|---|---|
| Memory | mem_limit (2g, 512m, 1024k, bytes) or deploy.resources.limits.memory |
4096 MiB |
| Processes | pids_limit or deploy.resources.limits.pids |
2048 |
| CPUs | cpus or deploy.resources.limits.cpus |
2.0 |
The sidecar renders these as mem_limit, pids_limit, cpus on every service of the generated compose file.
Network
With x-denv.egress: deny (the default) the stack's default network is rendered internal: true: no route out, and published ports are not reachable from the host. A probe container on that network tries http://1.1.1.1/ before the environment is reported healthy; the result is denied (verified). If the probe reaches the internet the deploy fails with egress is denied but the stack reached the internet. With allow, ports are published on 127.0.0.1:<random> and reported as the environment's Endpoints.
x-denv
x-denv:
kind: rl-environment # informational; the project's kind is authoritative
egress: deny # deny (default) | allow
warm: # argv run in the first service after health, before the snapshot
- psql
- -f
- /seed/orders.sql
agent:
service: opsbox # the service the agent runs in; must not be root
verifier:
image: ghcr.io/you/grader:1 # run as its own container on the fork's network …
# service: opsbox # … or inside this service of the fork, as root (exactly one of image/service)
command: ["/grade.sh"] # required
collect: ["/home/ubuntu/answer.txt"] # absolute paths in the agent service
timeout_sec: 600 # default 600
| Key | Rule |
|---|---|
kind |
Parsed, not enforced. |
egress |
deny or allow. |
warm |
List of strings; also settable per deploy by the API's warm field, which overrides the file. Runs in the first service (sorted by name), timeout 600 s; a non-zero exit fails the deploy. |
agent.service |
Must name a service. That service must set a non-root user (an empty user counts as root). Error: compose: x-denv.agent.service "x" must set a non-root user (user: "1000:1000"); the agent is never root. |
verifier |
Exactly one of image or service (compose: x-denv.verifier needs exactly one of image or service). command required (compose: x-denv.verifier.command is required). collect paths must be absolute (compose: x-denv.verifier.collect path "x" must be absolute). Requires agent.service (compose: x-denv.verifier requires x-denv.agent.service, the service whose paths are collected). |
Services are processed in sorted name order; the "first service" (for warm and for the post-snapshot health check) is the alphabetically first.
Verifier contract
denv verify runs the verifier on a fork of the environment, after the agent service is frozen and the collect paths have been copied to a root-owned directory on the host.
Image verifier. docker run --rm --user 0 on the fork's network, with:
| Mount / env | Value |
|---|---|
/submission |
the collected copy, read-only; the collected paths keep their absolute layout under it (/submission/home/ubuntu/answer.txt) |
/logs/verifier |
writable; write the reward here |
SUBMISSION |
/submission |
DENV_VERIFY_ID |
the verify deployment id |
Service verifier. The copy is placed at /submission inside the named service of the fork, /logs/verifier is created there, and the command runs with docker exec --user 0 and the same two environment variables. reward.txt is copied back out afterwards.
Reward. Read, in order: /logs/verifier/reward.txt parsed as a float; else the last whitespace-separated token of stdout parsed as a float. Neither present: the grade phase fails with verifier wrote no reward (exit N). The verdict records source as reward.txt, stdout, refused, or error.
Refusal. The copy is checked before any grader runs. Anything below sets the reward to 0 with source: refused, drops the whole copy, and skips the grader:
| Finding | Recorded as |
|---|---|
| symlink | <path>: symlink |
| FIFO | <path>: fifo |
| device | <path>: device |
| socket | <path>: socket |
| deeper than 32 levels | <path>: deeper than 32 |
| more than 256 MiB in total | <path>: submission larger than 256 MiB |
| more than 20,000 files | <path>: more than 20000 files |
A declared path that does not exist is recorded under missing and does not cause a refusal. The grader's run is limited to timeout_sec (default 10 minutes); exceeding it fails the grade phase.
Example
x-denv:
kind: rl-environment
egress: deny
agent: { service: opsbox }
verifier:
image: busybox:1.36
command: ["sh", "-c", "mkdir -p /logs/verifier; [ \"$(cat /submission/home/agent/answer.txt)\" = hello ] && echo 1 > /logs/verifier/reward.txt || echo 0 > /logs/verifier/reward.txt"]
collect: ["/home/agent/answer.txt"]
services:
opsbox:
image: python:3.12-slim
user: "1000:1000"
command: ["sleep", "infinity"]
environment: { HOME: /home/agent }
volumes: ["home:/home/agent"]
mem_limit: 2g
pids_limit: 512
db:
image: redis:7-alpine
volumes: ["rd:/data"]
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 2s, retries: 15 }
volumes: { home: {}, rd: {} }
Not yet available
x-denv.clock, x-denv.verifier for hosted sandbox providers (E2B, Daytona refuse verify), and host-side image builds are described in the blog but not implemented. A compose file with no declared volumes still deploys; its snapshot is an empty tree.