DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Docker Compose 5.6's jobs type only starts through docker compose run

Triggers cause config validation and up failures

Docker Compose 5.6.0 shipped on 2 October with a new top-level jobs: section, sitting next to services: in the compose file. I wanted to know what actually happens when you declare one, because the release notes say almost nothing beyond "partial support for jobs: only manually-triggered jobs are supported for now."

The first thing that happened was my compose file got rejected.

$ docker compose config
validating /home/user/compose-jobs-test/compose.yaml: jobs.migrate missing property 'triggers'
Enter fullscreen mode Exit fullscreen mode

A jobs: entry is not just a service with a different label. It needs its own triggers: block, and compose will not even parse the file without one. Once I added triggers: {manual: true}, it validated and I could start testing what a job actually does differently from a service.

What a job refuses to do

I wrote a small compose file: a long-running db service, and a migrate job that depends on it, sleeps a second and exits 0.

services:
  db:
    image: alpine:3.20
    command: ["sh", "-c", "echo db-up; sleep 600"]

jobs:
  migrate:
    image: alpine:3.20
    depends_on:
      - db
    triggers:
      manual: true
    command: ["sh", "-c", "echo migrating; sleep 1; echo done; exit 0"]
Enter fullscreen mode Exit fullscreen mode

docker compose up -d started db and said this about the job, then moved on:

time="...Z" level=warning msg="jobs are not started by up; trigger them with `docker compose run`: migrate"
Enter fullscreen mode Exit fullscreen mode

Exit code 0. docker compose ps -a afterwards shows only db. The job is simply absent, not failed, not pending, not listed anywhere up reports on.

create and start are less forgiving. Both refuse outright:

$ docker compose create migrate
job "migrate" can only be triggered with "docker compose run"
$ echo $?
1
Enter fullscreen mode Exit fullscreen mode

Same message, same exit code, for start. So of the five commands that normally bring a service into existence, four of them (up, create, start, and as far as I could find no fifth alternative) will not touch a job at all. Only run works.

The opposite reading: declaring the documented future breaks the present

The release notes are honest that scheduled jobs aren't supported yet, only manual ones. I wanted to see what "not supported yet" meant in practice, so I added a second job with a schedule trigger instead of manual:

jobs:
  nightly:
    image: alpine:3.20
    triggers:
      schedule: ["0 2 * * *"]
    command: ["sh", "-c", "echo nightly-run"]
Enter fullscreen mode Exit fullscreen mode

This is where the asymmetry shows up. A manual-only job that up can't start gets a warning and is skipped; the rest of the stack comes up fine. A job with a schedule trigger makes up refuse to run at all:

$ docker compose up -d
scheduled jobs are not supported in this version: nightly
$ echo $?
1
Enter fullscreen mode Exit fullscreen mode

Not a warning this time. The whole up fails, and nothing starts, not even the unrelated db service sitting in the same file. If you're migrating an existing stack to use jobs and you reach for the field that matches what you actually want (a job that runs on a schedule), you get a worse outcome than if you'd done nothing: the entire compose file stops coming up until you remove that trigger. Manual-only jobs are the only kind that currently coexist peacefully with the rest of your stack.

docker compose run --rm nightly still runs the scheduled job by hand without complaint, which matches what the Compose team's own notes say about manual execution being "always available... unless a job sets triggers.manual: false." I checked that half of the claim directly too: a job declared with triggers: {manual: false} is refused by run as well:

$ docker compose run --rm migrate_locked
job "migrate_locked" is declared with manual: false, it cannot be run manually
$ echo $?
1
Enter fullscreen mode Exit fullscreen mode

Since nothing can trigger it on a schedule yet either, a job with manual: false in this release is not deferred to a scheduler. It's unreachable. There is currently no path in Compose 5.6.0 that starts it.

What a job costs you to run

docker compose run on a job starts its declared dependencies first, same as it does for a one-off command on a service. I measured how much that costs by timing docker compose run --rm migrate three times with db already running, and three times after docker compose down -v so db had to be created and started from scratch each time.

Run Cold (db stopped first) Warm (db already up)
1 1.716s 1.512s
2 1.718s 1.467s
3 1.717s 1.481s
mean 1.717s 1.487s

The job's own work (a one-second sleep plus container overhead) is identical in both cases, so the roughly 230ms gap, about 15%, is what it costs to create and start the dependency container before the job can begin. That's a small number on a single Alpine container; it's the number to budget for if your job's real dependency is a database image with its own startup routine, multiplied by however many depends_on entries the job declares.

Exit codes pass through exactly. A job that ends with exit 17 hands that 17 straight back to the shell that ran docker compose run:

$ docker compose run --rm migrate_fail
failing
$ echo $?
17
Enter fullscreen mode Exit fullscreen mode

That matters if you're wiring a job into a CI step and checking $? afterwards, which is presumably the point of a dedicated one-off-task primitive.

Under concurrency

I ran the same job twice at once, both in the background, to see whether two manual triggers would collide:

$ docker compose run --rm migrate & docker compose run --rm migrate & wait
 Container compose-jobs-test-migrate-run-4fdc2a756110 Creating
 Container compose-jobs-test-migrate-run-f3445db266a6 Creating
migrating 1
migrating 1
done 1
done 1
Enter fullscreen mode Exit fullscreen mode

Two distinct container names, both ran to completion, both cleaned up by their own --rm. No lock, no queue, no contention on the shared db dependency either, since it was already running. Jobs don't serialise against each other; if you need that, you still have to build it yourself.

A claim I checked and found true

The one documented behaviour I could directly verify was profile activation: a job's own profiles: entry is meant to be enabled automatically when you run it, without needing --profile on the command line. I gave a job profiles: ["migrations"], ran it with no --profile flag, and it ran:

$ docker compose run --rm migrate
migrated-with-profile
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

That one held up exactly as documented.

What I got wrong on the way

My first attempt at the schedule trigger was triggers: schedule: "0 2 * * *", a bare string, because that's what cron usually looks like everywhere else. Compose rejected it: jobs.nightly.triggers.schedule must be a array. It wants a list of objects, each with a cron: key — schedule: [{cron: "0 2 * * *"}] once compose canonicalises it. I'd assumed the YAML shorthand I wrote would work because it reads naturally; the schema disagreed, and the error message was specific enough that I didn't need to go digging through compose-go source to fix it.

I also nearly reported "jobs leak containers that down can't clean up" as a jobs-specific finding, since a migrate container left over from a run without --rm survived both docker compose down and docker compose rm -f. Before writing that down as a cost of the new feature, I ran the identical test against an ordinary service behind a profiles: ["tools"] guard, the pattern people have used for one-off tasks in Compose for years. It leaked exactly the same way. Jobs didn't introduce this; they inherited it. It's worth knowing either way, but it would have been the wrong feature to blame.

Run it yourself

This needs Docker Compose 5.6.0 or later (docker compose version to check) and a working Docker daemon.

mkdir jobs-test && cd jobs-test
cat > compose.yaml <<'EOF'
services:
  db:
    image: alpine:3.20
    command: ["sh", "-c", "echo db-up; sleep 600"]

jobs:
  migrate:
    image: alpine:3.20
    depends_on:
      - db
    triggers:
      manual: true
    command: ["sh", "-c", "echo migrating; sleep 1; echo done; exit 0"]
EOF

docker compose up -d          # starts db, warns and skips migrate
docker compose create migrate # refused: "can only be triggered with docker compose run"
docker compose run --rm migrate   # this is the one that actually works
docker compose config --services  # lists db only, migrate is invisible here
Enter fullscreen mode Exit fullscreen mode

Add a second job with triggers: {schedule: [{cron: "0 2 * * *"}]} and run docker compose up -d again to see the whole file refuse to start, rather than just skip the one job.

What I'd do with this

If you're reaching for jobs: today, use triggers: {manual: true} and only that. A schedule trigger doesn't give you a scheduled job yet, it gives you a compose file that up refuses to run at all, and manual: false with no scheduler gives you a job nothing can start. Keep using run --rm rather than bare run, because the orphaned-container problem is the same one you already had with profile-gated one-off services, and jobs don't fix it. And if any script you have loops over docker compose config --services to do per-service work, remember it won't see your jobs at all; you'll need to list them separately until Compose grows a command that does.

Top comments (4)

Collapse
 
mno_tao_236ab4649edf4cf9f profile image
alex.toolkit •

The schedule trigger result is the one I'd point out to anyone adding jobs to an existing stack: a manual job gets a warning and is skipped, but a scheduled one takes the whole up down with it, including the unrelated db service.

Did docker compose config accept the file with the nightly job in it? If it does, a CI step that only validates the compose file would pass and the failure would only show up at deploy time.

Also good call re-running the leaked-container test against a profiles-guarded service before blaming jobs for it.

Collapse
 
alexgeorgiev17 profile image
Alex Georgiev •

Yeah, that asymmetry is the one that'd bite people. A manual job you can't start is a warning, a scheduled one you can't start takes db down with it.

Good question on config, and honestly I didn't check it for the nightly file. I only saw config reject the bare-string schedule (the "must be a array" error), which is a schema check. The "scheduled jobs are not supported in this version" error came from up, so my guess is config passes the file and the refusal only happens at up time, which is exactly the CI gap you're describing. But that's a guess, so I'll run docker compose config against it and post what it says here.

If it does pass, the cheap CI fix is a dry run: docker compose up --dry-run -d should hit the same check without starting anything. I haven't tried that on 5.6 yet either, so I'll test both together.

And thanks, the profiles rerun saved me from blaming jobs for a bug Compose has had for years.

Collapse
 
arhancanli profile image
Arhan Canli •

The cold/warm gap (about 230 ms) is the cost of creating and starting db, but for a migrate job the number that matters is how long until db accepts connections, and a plain depends_on: [db] doesn't wait for that. The default condition is service_started, so a job running alembic upgrade or psql against a real Postgres would fail on the first cold docker compose run and pass on the second, the same flaky shape as your warm vs cold rows.

The usual fix for services is a healthcheck (pg_isready) on db plus depends_on: {db: {condition: service_healthy}}. Does the jobs: schema accept the long depends_on form with condition, or only the list? If it does, run should block on the healthcheck and the cold time would then include the database's real startup, which is probably the number people budget for.

Collapse
 
alexgeorgiev17 profile image
Alex Georgiev •

Yeah, fair. My db was just alpine running sleep 600, so "started" and "ready" were the same moment and that gap could never show up. With a real Postgres behind the short form, run starts the job the second the db container is up, not when it accepts connections. So the first cold run of a real migration would probably just fail, which is way worse than 230ms slower.

That also makes the line in the post about budgeting for "a database image with its own startup routine" kinda wrong. With plain depends_on: [db] you don't pay for that startup at all, the job just doesn't wait for it. I'll fix that bit.

On the long form, honestly I didn't try it. Every job I tested used the list form. Gonna rerun it with postgres, a pg_isready healthcheck and condition: service_healthy, and check two things: does the jobs schema even accept it, and if it does, does run actually block on the healthcheck. If it does, the cold number becomes the real one people should budget for, like you said. Will post what I get.