Skip to content

Building containers ​

Getting a container to work is a loop: write a Dockerfile, run it, read the logs, fix what broke, run it again. The queue makes that loop cheap — you never need a local Docker daemon, because the image is built on the worker.

author image  →  run  →  read logs  →  fix  →  run  →  ...  →  exit 0 + outputs

This page uses cq, a small helper that wraps the API. Everything it does is plain HTTP, so if you would rather call the endpoints directly, see REST API — the workflow is identical.

Setup ​

cq ships with the Agent Skill and needs node >= 18 or deno, with no dependencies to install.

sh
curl -fsSL https://container.mtfm.io/docs/skill/install.sh | sh
alias cq="node ~/.claude/skills/compute-queues/scripts/cq.mjs"

Point it at an API and a queue once:

sh
export CQ_API=https://container.mtfm.io
export CQ_QUEUE=public1

Then check a worker is actually listening. This is the first thing to do in any session — with no worker attached, jobs sit in Queued forever and nothing appears to happen:

sh
cq status
# queue public1: 0 job(s), 1 worker source(s)

If that reports no workers, start one (see Running workers):

sh
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock -v /tmp:/tmp \
  metapage/metaframe-docker-worker:latest run \
    --cpus=4 --max-job-duration=20m --data-directory=/tmp/worker-metapage-io \
    "$CQ_QUEUE"

Everything local

A worker started with --mode=local serves the same API itself on localhost:8000 and nothing leaves the machine. Point cq at it with --api http://localhost:8000 --queue local.

The first run ​

Start with the narrowest thing that can work — often a published image and a command, with no Dockerfile at all:

sh
cq run --image alpine:3.19.1 --command 'sh -c "echo hi; echo done > /outputs/result.txt"'
▶ job 9f2400a474880666da9f5e008812d0e61dddc3670c37ed09cb4a0dea5a01952f
  queue public1  ·  https://container.mtfm.io/q/public1/j/9f24…/result.json
◆ Queued
◆ Running
── build ──────────────────────────────
3.19.1: Pulling from library/alpine
Status: Image is up to date for alpine:3.19.1
── run ────────────────────────────────
hi
◆ Finished (Success)
✓ success (exit 0, 199ms, outputs: result.txt)

cq run submits, streams the logs, and exits with the container's own exit code — so if cq run …; then is a real test of whether the container works.

Building from a Dockerfile ​

When you need packages or setup, write a real Dockerfile and pass it. Its contents are sent inline, so there is no context to upload:

dockerfile
# Dockerfile
FROM python:3.12-slim
RUN pip install --no-cache-dir polars
sh
cq run --dockerfile ./Dockerfile --command 'python -c "import polars; print(polars.__version__)"'

Building with local source files ​

To copy your own files into the image, add a build context directory. cq tars it, uploads it, and points the build at it:

myproject/
  Dockerfile
  src/main.py
dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY src/ /app/src/
sh
cq run --dockerfile ./myproject/Dockerfile --context-dir ./myproject \
       --command 'python /app/src/main.py'

Building from a git repo ​

sh
cq run --context https://github.com/owner/repo/commit/9f3c1a2 \
       --command './build/bin/thing --version'

Pin to /commit/<sha> rather than a branch. A branch URL means the job's meaning changes silently over time, and the definition hash will not reflect it. If the repo already has a usable Dockerfile, name it with --filename and point at its subdirectory with --build-context.

Reading failures ​

Build logs and run logs are separate streams, and which one failed tells you what to fix.

sh
cq logs <jobId> --kind build     # the image never built — fix the Dockerfile
cq logs <jobId> --kind run       # it built and the program failed — fix the program

A failed build looks like this — the docker error names the step and the exit code:

#5 [2/2] RUN this-command-does-not-exist
#5 0.143 /bin/sh: this-command-does-not-exist: not found
#5 ERROR: process "/bin/sh -c this-command-does-not-exist" did not complete successfully: exit code: 127
◆ Finished (Error)
✗ job did not complete: Error

A failed program looks completely different — the job succeeded, the container did not:

── run ────────────────────────────────
Traceback (most recent call last):
  File "/app/src/main.py", line 3, in <module>
◆ Finished (Success)
✗ container exited 1

Two success checks

finishedReason: "Success" means the job completed. StatusCode: 0 means the program succeeded. A container that crashed still reports finishedReason: "Success". Check both — cq does.

Common signatures:

What you seeWhat it means
Job stays Queued, no logs at allNo worker on the queue. cq status.
Result returns instantly with old logsCached — the definition is unchanged. Add --nonce to force a re-run.
No build logs at allThe image was already built and cached. Expected.
Build log stops at Downloading contextBad context URL, a private repo, or an archive that is neither gz nor zip.
StatusCode: 137OOM-killed. Raise --memory.
finishedReason: "TimedOut"Exceeded maxDuration, or the worker's own ceiling.
exec format errorArchitecture mismatch. --platform linux/amd64.
Program cannot find its inputInputs land in /inputs/<name>, not the working directory.

You cannot shell into a job, so when something is confusing, make the container tell you:

sh
cq run --image <the same image> --command 'sh -c "ls -la /inputs /outputs; env | sort"'

Iterating ​

Change the Dockerfile or the command and run again. Two caches shape what happens next:

  • The job is keyed on the whole definition. Resubmitting an identical definition returns the cached result without running. --nonce forces a genuine re-run.
  • The image is keyed on the build block alone. Changing only the command re-runs against the already-built image — fast, and with no build logs.

Inputs and outputs ​

sh
cq run --dockerfile ./Dockerfile --context-dir . \
       --command 'python /app/src/main.py' \
       --input data.csv=@./data.csv \
       --output-dir ./outputs

Inputs appear at /inputs/<name>; anything the container writes to /outputs is collected into the result and downloaded by --output-dir. Files over ~200 bytes are uploaded to content-addressed blob storage automatically — see Files in & out for the details and for /job-cache, which persists between jobs on the same worker.

Command reference ​

cq run       submit, stream logs, report the result   (the usual one)
cq submit    submit only, print the jobId
cq logs      print logs; --follow to stream
cq wait      wait for completion, then report
cq result    print the result JSON
cq outputs   download output files
cq upload    upload a file, print its DataRef URL
cq status    queue status — are any workers attached?
cq url       print a browser URL for a definition

cq help lists every flag.

Doing this without cq ​

Every step above is one HTTP call against an unauthenticated API:

sh
API=https://container.mtfm.io; QUEUE=public1

JOB=$(curl -s -X POST "$API/q/$QUEUE" -H 'content-type: application/json' -d '{
  "definition": {"build": {"dockerfile": "FROM alpine:3.19.1\nRUN apk add --no-cache jq"},
                 "command": "jq --version"}
}' | jq -r .jobId)

curl -sN "$API/q/$QUEUE/j/$JOB/stream"                 # live: build-log, run-log, state, final
curl -s "$API/q/$QUEUE/j/$JOB/build-logs.json?since=0" # or poll with a cursor
curl -s "$API/q/$QUEUE/j/$JOB/result.json"             # {"data": null} until finished
curl -s "$API/q/$QUEUE/j/$JOB/outputs/result.txt"      # one output file

Full endpoint list and payload shapes: REST API.