← Back to API docs

Docs

Containers API

Containers run a Docker image you choose as a background job, for as long as the work takes. This is where the work goes that a function cannot do: a video transcode, a large import, a PDF pipeline, an ML batch — anything needing minutes of CPU or a real toolchain rather than a second or two of JavaScript.

A job is started, not awaited

POST /v1/container/{instance} returns as soon as the machine exists. Nothing is streamed back and there is no response body to wait on.

const job = await fetch(`${API}/v1/container/jobs`, {
  method: "POST",
  headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
  body: JSON.stringify({
    image: "ghcr.io/me/transcode:v4",
    cmd: ["/bin/transcode", "--input", "blob:9f3c…:7a1b…"],
    env: { QUALITY: "high" },
    size: "medium",
    timeout_ms: 600000,
  }),
}).then((r) => r.json());

// job.job.id — keep it. That is how you find out what happened.

You find out how it ended one of two ways: poll GET /v1/container/{instance}/{id}, or have the instance call a function when the job finishes. The callback carries the exit code and what the job cost, and is sent only after the result has been recorded, so a callback that fails cannot lose the result or re-run the job. A failed callback is retried on its own.

The job itself writes whatever it produces to blob, your datastore, or anywhere else it can reach. Nothing is captured for you.

The launch body

Fields of a launch request
FieldRules
imageRequired. An image reference such as ghcr.io/you/worker:v2, at most 512 characters, that the instance's allowed images match. Images from localhost, an IP address, the platform's own registry or a private-network name are refused on every instance.
sizesmall (default), medium or large.
cmdAn array of strings: at most 64, each at most 4,096 characters. Omit it to run the image's own command.
envAn object of string values: at most 32 variables, names matching [A-Za-z_][A-Za-z0-9_]*, names and values together under 8,192 bytes. Reserved prefixes are refused.
timeout_msAn integer, at least 1000 and at most the instance's maximum job time. Defaults to that maximum.

Anything the caller needs to hand a job that does not fit in those caps belongs in a blob file whose key goes in cmd or env.

The reply, and every read of a job, is {job}:

{ "job": { "id": "j3f9c…", "status": "running", "image": "ghcr.io/me/transcode:v4", "size": "medium",
           "exit_code": null, "timeout_ms": 600000, "error": null,
           "started_at": 1791561600000, "ended_at": null, "cost_usd": 0 } }
Launch refusals
StatusWhen
400 INVALID_ARGUMENTA field breaks a rule above, or the worst case for this size and timeout is over the instance's cost ceiling — the message names a timeout that would fit.
402 PAYMENT_REQUIREDThe organization has no payment method and the job would pass its monthly limit.
403 FORBIDDENThe image is not in the allowed list, or the list is empty.
429 RESOURCE_EXHAUSTEDThe instance already has its maximum jobs running.
429 RATE_LIMITEDMore launches than the instance's rate limit: 120 a minute by default.

Job status

Job statuses
statusMeans
runningThe machine exists. The only status that changes.
doneThe command exited 0. A machine that ended without recording an exit code is also done, with exit_code: null and error saying so.
failedA non-zero exit (exit_code holds it), or error says why: timed out, or a failure to start or to watch the machine.
canceledCanceled through the API or the console (error: "canceled"), or the instance was deleted while it ran ("instance deleted").

The completion callback

With Call on completion set, the named function receives a POST with x-ae-trigger: container and this body:

{ "job_id": "j3f9c…", "instance": "6b0e…", "status": "done", "exit_code": 0,
  "image": "ghcr.io/me/transcode:v4", "size": "medium",
  "started_at": 1791561600000, "ended_at": 1791561912000, "cost_usd": 0.0123, "error": null }
  • instance is the container instance's id, not its name.
  • The body is not signed. Check x-ae-trigger instead: the platform strips that header from every inbound request, so a request to the function's public URL cannot claim to be a callback.
  • Any 2xx is delivered. Anything else, or no answer within 30 seconds, is retried about once a minute, five attempts in all. An attempt that timed out may still have run, so a callback can arrive more than once — key what it does on job_id.
  • A job ended by deleting its instance sends no callback.
Containers endpoints
EndpointWhat it does
POST /v1/container/{instance}Start a job. Returns immediately with its id.
GET /v1/container/{instance}List jobs, newest first: {jobs, cursor}. Filter with ?status=running; limit is 25 by default, at most 100. For the next page pass the reply's cursor as ?before=; it is null on the last page.
GET /v1/container/{instance}/sizesWhat this instance will accept: sizes, allowed images, limits.
GET /v1/container/{instance}/{id}One job: status, exit code, how long it ran, what it cost.
GET /v1/container/{instance}/{id}/logsWhat the job printed, oldest first, with a cursor for more.
POST /v1/container/{instance}/{id}/cancelStop a running job and destroy its machine now. Returns {job, canceled}; canceled is false when the job had already ended.

Logs, and how long things are kept

A job's output is available while it runs and for about a week afterwards. Open a run in the console, or call the logs endpoint. If logs cannot be retrieved the response is empty with a note rather than an error — a job's record (exit code, timing, cost) is kept separately and never depends on them.

The job list is paginated newest-first. Finished jobs are kept for 30 days and then removed. A running job is never removed on age.

Only your server can start a job

Containers take an organization API key and nothing else. An end-user identity token is rejected.

If a page needs to start a job, have it call a function, which can decide whether this particular user should be starting this particular job.

A function does not need a key to do that. Grant it container and it gets env.container, the same way it gets env.datastore:

export default {
  async fetch(request, env) {
    const token = (request.headers.get("authorization") || "").replace("Bearer ", "");
    const user = await env.auth.verifyToken({ instance: "users" }, token);
    if (!user?.claims?.canExport) return new Response("no", { status: 403 });

    const job = await env.container.run({ instance: "jobs" }, {
      image: "ghcr.io/me/export:v3",
      cmd: ["/bin/export", "--user", user.uid],
      timeout_ms: 600000,
    });
    return Response.json({ job_id: job.id });
  },
};

The grant is the bound: a function with no container grant has no env.container at all, and one granted container:jobs cannot reach any other instance. Every limit on this page still applies — the image allowlist, the timeout, the concurrency cap and the cost ceiling are checked before the machine starts, whoever asked for it.

Sizes

Container machine sizes
SizeMachineBilled rate
small1 CPU, 512 MB×1
medium2 CPU, 2 GB×4
large4 CPU, 8 GB×12

What a job cannot do

A job has no inbound network: nothing can connect to it, and it has no address to be reached at. It has no disk that survives it — write anything you want to keep to blob or datastore before it exits. It runs once and disappears; a crash is not restarted.

What it is given in its environment without asking is AE_JOB_ID, its own id, and — when the instance names a blob store — AE_BLOB_URL and AE_BLOB_TOKEN. Names beginning AE_ and FLY_ are reserved and rejected rather than ignored.

Reaching blob without a key in the job's env

Name a blob instance as the container instance's blob store, and every job launched there starts with two more variables:

AE_BLOB_URL     https://api.altengine.net/v1/blob/{store}
AE_BLOB_TOKEN   a bearer for that store, and nothing else
# in the job: ask for a link, then PUT the bytes straight to storage
curl -sX POST "$AE_BLOB_URL/uploads" \
  -H "authorization: Bearer $AE_BLOB_TOKEN" -H "content-type: application/json" \
  -d '{"name":"out.csv","size":'$(stat -c%s out.csv)',"content_type":"text/csv"}'

From there it is the blob API as any other client uses it, so the bytes go to storage directly and never through altengine. The token puts and gets — it cannot delete, cannot publish, and cannot list the store. It expires with the job's own time limit, and it is minted by the platform, so it is not something you can issue, widen or point at a different store. Leave the setting unset and a job gets no blob credential at all.

Naming a store here delegates it. Anything that can launch a job on this instance can write to that store for the life of the job — including the image itself, whoever wrote it. That is the same shape as call on completion, which lets a launch invoke a function holding grants the launcher does not have.

Limits, and what each one is for

Every one of these is set on the instance in the console, and each bounds a different way a job can cost more than you meant.

Container instance settings
SettingDefaultWhat it bounds
Allowed imagesemptyWhich images may run, at most 50 entries. An exact tag matches only that tag; repo:* allows any tag of one repository; * allows any image. An empty list runs nothing.
Maximum job time5 minutesThe longest any job may run, from 1 second up to an hour. A job reaching it is killed and billed for the time it ran. Jobs may ask for less, never more.
Jobs at once21 to 50. The real spend bound — the timeout caps one job, this caps the bill. A launch past the limit is refused, not queued.
Cost ceiling per job$1.00$0.01 to $100. Checked before the machine starts, against the worst case for the size and timeout asked for. A job that could exceed it is refused up front, and the refusal says what would fit.
Call on completionnoneA function to invoke when a job ends, as {functions-instance}/{function} — see the callback.
Blob storenoneWhich blob instance a job may reach without a key of its own — see above.
Rate limit120 a minuteLaunches per minute. A value you set can only lower it. If the limit cannot be checked, a launch is refused rather than let through.

A new instance runs nothing until you say what it may run. The allowed-images list starts empty.

What it costs

Two things: the time a machine ran, scaled by its size, and a small fee per job that covers starting and watching it. A second on a large counts as twelve on a small, so one rate covers every size — see pricing for the numbers.

A job is billed only for the time its machine actually existed, and never for longer than the timeout it was started with. Nothing accrues while an instance sits idle.

Without a payment method, an organization's jobs are limited to $3 a month. Each launch reserves the most the job could cost (its size and timeout), and what it does not use is released when it ends. A launch that would pass $3 is refused with 402 PAYMENT_REQUIRED. With a payment method on file there is no such limit.

Every job pays for its own start-up. Pulling the image, booting the machine and tearing it down afterwards take roughly ten seconds even when your command finishes instantly, and that time is charged for.

So containers are for work measured in minutes, where ten seconds is a rounding error. For work measured in milliseconds, use a function: it starts in single-digit milliseconds and is billed by CPU time rather than wall-clock.

From an AI agent

The MCP server can start a job, list them, check one, and cancel one. It can also change an instance's settings — the limits above included — with patch_instance_config, which takes a credential holding instances control scope, and full data access to that instance for the fields that decide where a job's output goes. Give an agent a narrower credential if you want the allowed-images list to be yours alone.