PackagesContainer

@lunora/container

Deploy Docker containers alongside your Lunora app, called from actions over ctx.containers.

@lunora/container runs Cloudflare Containers as part of a Lunora app: declare a container with defineContainer, and codegen emits the container-enabled Durable Object class while lunora dev / lunora deploy reconcile the wrangler.jsonc wiring. wrangler deploy builds the image with your local Docker engine and pushes it to Cloudflare's registry — there is no separate container platform to sign up for.

Reach for a container when a job doesn't fit the Workers runtime: an existing binary (ffmpeg, headless Chrome, a Python ML model), a long-running process, or anything that needs a full filesystem.

pnpm add @lunora/container
npm install @lunora/container
yarn add @lunora/container
bun add @lunora/container

Containers require the Workers Paid plan. Images must target linux/amd64 — on Apple Silicon, build with --platform linux/amd64 (the scaffolded Dockerfile does this for you). See Limits.

Declare a container

Containers live in lunora/containers.ts. Scaffold one — definition plus a starter Dockerfile under containers/<name>/ — with:

pnpm vis generate lunora-container --name=transcoder
// lunora/containers.ts
import { defineContainer } from "@lunora/container";

export const transcoder = defineContainer({
    image: "./containers/transcoder", // directory with a Dockerfile, or { registry: "docker.io/acme/transcoder:1.4" }
    defaultPort: 8080,
    instanceType: "standard-1", // lite | basic | standard-1..4 | { vcpu, memoryMib, diskMb }
    maxInstances: 5, // cap concurrent running instances (and the .any() pool)
    sleepAfter: "5m", // idle timeout — instances scale to zero
    secrets: ["TRANSCODER_API_KEY"], // Worker secrets forwarded into the container env
});

image is either a local path (a directory containing a Dockerfile, or a path to the Dockerfile itself) that wrangler deploy builds and pushes, or a pre-built registry reference ({ registry }) from the Cloudflare Registry, Docker Hub, or Amazon ECR.

Re-export the generated classes from your worker entry — wrangler requires every container's class to be exported by the deployed Worker:

// src/server/index.ts
export * from "../../lunora/_generated/containers";

lunora dev reminds you if this is missing, and reconciles the rest of wrangler.jsonc automatically: the containers[] entry, the CONTAINER_* Durable Object binding, the SQLite migration, and observability.enabled (so container logs are captured).

Call a container from an action

Container calls are external I/O, so — like ctx.fetch and ctx.aictx.containers lives on actions, never queries or mutations. Codegen types one handle per declared container.

import { action, v } from "@/lunora/_generated/server";

export const transcode = action.input({ videoId: v.id("videos") }).action(async ({ ctx, args: { videoId } }) => {
    // One instance per entity — same id always routes to the same container.
    const response = await ctx.containers.transcoder.get(videoId).fetch("/transcode", {
        method: "POST",
        body: JSON.stringify({ videoId }),
    });

    return response.json();
});

Routing: .get(name) vs .any()

  • .get(name) — one container instance per name. Use it for stateful, per-entity work: a sandbox per user, a room per game, a worker per job id. The same name always reaches the same instance.
  • .any(count?) — a random instance from a fixed pool (defaults to maxInstances, else 3). Use it for stateless, interchangeable work where any instance can serve the request.
  • .pool(options?) — like .any(), but resilient: each fetch picks a random instance and, on a thrown error or a retryable response (5xx by default), retries on a freshly-picked instance with exponential backoff.
// Stateless pool — load-balanced across instances.
const probe = await ctx.containers.transcoder.any().fetch("/healthz");

// Resilient: rides over a single cold/unhealthy instance.
const result = await ctx.containers.transcoder
    .pool({ attempts: 3, backoffMs: 100, retryOn: (response) => response.status >= 500 })
    .fetch("/transcode", { method: "POST", body });

fetch accepts a path string (resolved against the container) or a full Request, and proxies WebSocket upgrades — a real-time stream to a container works the same as any other fetch.

Cold-start retry

The first request to a sleeping or never-started instance can land while Cloudflare is still provisioning it — surfacing as a 503 "no Container instance available", a 500 "Failed to start container", a 429, or a thrown "not listening" error. .get() and .any() absorb that race: they retry the same instance a few times with exponential backoff (default 3 attempts, 500 ms base, 30 s ceiling) so transient provisioning blips don't reach your handler. Only those platform provisioning signals retry — an honest application 5xx is returned straight through.

// Tune or disable per call (attempts: 1 sends exactly once).
await ctx.containers.transcoder.get(jobId, { attempts: 5, backoffMs: 250 }).fetch("/transcode");

A pre-built Request is sent once and never retried — its body may be a one-shot stream that can't be replayed. Pass a path string (the common case) to opt into the retry, or set attempts: 1 to send a Request without it. (This is distinct from .pool(), which retries on a fresh instance for stateless load-balancing; cold-start retry sticks to the one instance you named.)

Managing a named instance

The .get(name) handle also exposes lifecycle control, for the per-entity pattern where you manage an instance rather than wait for sleepAfter:

const sandbox = ctx.containers.codeRunner.get(userId);

await sandbox.start({ envVars: { SESSION: userId } }); // explicit start (optional per-instance env)
const state = await sandbox.getState(); // inspect runtime state
await sandbox.stop(); // stop (can restart on next request)
await sandbox.destroy(); // tear down and discard the ephemeral disk

.any() and .pool() return fetch-only handles — lifecycle control is for named instances you own.

Cloudflare has no built-in autoscaling yet: pools are a fixed size and .any()/.pool() pick uniformly, ignoring location. .pool() is the recommended call for stateless work until then; for batch work, drive instances from a scheduler cron or runAfter.

Multi-port containers

A container that listens on more than one port (an app port plus an admin port, say) declares every one with requiredPorts — start-up waits for all of them to be listening. defaultPort is the target when a request doesn't pick a port; route a single request elsewhere with .port(n), which composes with .get(), .any(), and .pool():

export const app = defineContainer({
    image: "./containers/app",
    defaultPort: 8080,
    requiredPorts: [8080, 9090], // app + admin
});
// in an action:
await ctx.containers.app.get(tenantId).fetch("/work"); // → 8080 (defaultPort)
await ctx.containers.app.get(tenantId).port(9090).fetch("/admin"); // → 9090

Egress firewall

By default a container may open any outbound connection (enableInternet: true) — and egress is billed per GB. Constrain it by pairing enableInternet: false with an allowedHosts allow-list, or layer a deniedHosts deny-list that overrides everything, including enableInternet: true and allowedHosts. Glob patterns like *.stripe.com are supported.

export const fetcher = defineContainer({
    image: "./containers/fetcher",
    enableInternet: false,
    allowedHosts: ["*.stripe.com", "api.github.com"],
    deniedHosts: ["*.evil.com"],
    interceptHttps: true, // extend the lists to TLS traffic (image must trust the Cloudflare CA)
});

interceptHttps extends the allow/deny lists to HTTPS connections, not just plain HTTP; it requires the image to trust the Cloudflare CA at /etc/cloudflare/certs/cloudflare-containers-ca.crt. The interception path runs through the ContainerProxy worker entrypoint, which codegen re-exports from the generated container file automatically.

Tighten or relax a single running instance at runtime through its named handle — egress.allow / deny add one host, removeAllowed / removeDenied drop one, and setAllowed / setDenied replace a whole list:

await ctx.containers.fetcher.get(tenantId).egress.allow("hooks.slack.com");
await ctx.containers.fetcher.get(tenantId).egress.setDenied(["*.evil.com", "*.tracking.example"]);

For advanced egress rewriting in worker code (inject auth, route, or mock a container's outbound calls), @lunora/container/do re-exports Cloudflare's custom outbound-handler types (OutboundHandler, OutboundHandlers, outboundParams) — wire them onto a hand-authored LunoraContainer subclass.

Readiness gating

The platform health check waits for an open port (defaultPort) — and, if pingEndpoint is set, for an HTTP probe on that path on the same container — not necessarily a ready app. readyOn adds application-level probes that gate request proxying: a ctx.containers.<name> fetch holds until every probe responds with its expected status, so callers never hit a container still applying migrations or warming caches.

export const api = defineContainer({
    image: "./containers/api",
    defaultPort: 8080,
    readyOn: [
        { path: "/ready" }, // expect 200 on defaultPort
        { path: "/live", port: 9090, status: 204 }, // own port + expected status
    ],
});

Each probe declares a path (a leading slash is optional), an optional port (defaults to defaultPort), and an optional status (defaults to 200). Probes are declarative data — no handler functions — so codegen and the config layer read them without evaluating code. At start they run in parallel, poll the container's TCP port directly, and fail the start if any probe doesn't go ready within the readiness budget.

Hard timeout

sleepAfter caps idle time; hardTimeout caps total lifetime — a runaway-cost backstop that fires regardless of activity, measured from start. It uses the same grammar as sleepAfter ("30s", "5m", "1h", or a plain number of seconds):

export const job = defineContainer({
    image: "./containers/job",
    sleepAfter: "5m", // sleep after 5 min idle
    hardTimeout: "1h", // …but never run longer than an hour, busy or not
});

When it elapses, the generated class's onHardTimeoutExpired hook runs — the default action is stop(). The timer is armed through the container's own scheduler and stamped with a run generation, so a stale timer left over from a previous (slept or crashed) run never kills a fresh one. Advanced apps that hand-author their container class can override onHardTimeoutExpired (the base class lives in @lunora/container/do) to drain or checkpoint before stopping.

Calling Lunora from inside a container

Container code calls back into your app's functions with the bridge client (any JS runtime — Node, Bun, Deno), over the Worker's HTTP RPC endpoint — so a container reads/writes app state through the same queries/mutations the browser uses, instead of reaching into a database directly.

import { createContainerBridge } from "@lunora/container/bridge";

const lunora = createContainerBridge({ baseUrl: process.env.LUNORA_URL, token: process.env.LUNORA_TOKEN });

const pending = await lunora.query("jobs:listPending", { limit: 10 });
await lunora.mutation("jobs:markDone", { id: pending[0].id });

For full type-safety, pass a generated api reference to run() — args and result are inferred from it:

import { api } from "../lunora/_generated/api";

const job = await lunora.run(api.jobs.next, { queue: "transcode" }); // typed args + result

The token is a bearer your Worker's resolveIdentity recognizes — forward it as a container secret, never bake it into the image. Non-JS containers can POST /_lunora/rpc with { functionPath, args } directly (same contract).

Securing the bridge

The bridge sends its token as Authorization: Bearer <token>. Your Worker's resolveIdentity is what validates it and maps it to the identity the called functions run as — without that check, anyone who reaches /_lunora/rpc runs as whatever identity you return. Validate the bearer against a Worker secret and return null for anything that doesn't match (an unrecognised request runs anonymously and fails your functions' own authorization checks):

// in your worker options (createWorker / withLunora)
resolveIdentity: (request, env) => {
    const header = request.headers.get("authorization");
    const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;

    // Compare against a Worker secret you also forward to the container.
    if (!token || token !== env.LUNORA_CONTAINER_TOKEN) {
        return null; // anonymous — function-level authorization still applies
    }

    return { userId: "container:transcoder" }; // identity the bridge calls run as
},

Generate the token once, set it with wrangler secret put LUNORA_CONTAINER_TOKEN for production (and in .dev.vars locally), and pass the same value to the container as a secret so it can send it back. Never bake it into the image.

Building with Railpack (no Dockerfile)

Point image at a { build } source directory and Lunora builds the image with Railpack — Railway's Dockerfile-less builder — instead of you writing a Dockerfile:

export const transcoder = defineContainer({
    image: { build: "./services/transcoder" }, // Railpack detects the stack and builds it
    defaultPort: 8080,
});

On lunora deploy, each { build } container is built with Railpack and pushed to the Cloudflare Registry (under a deterministic lunora-<name>:build tag) before the Worker deploys. This needs the railpack CLI and a running BuildKit instance reachable via BUILDKIT_HOST, e.g.:

docker run --rm --privileged -d --name buildkit moby/buildkit
export BUILDKIT_HOST=docker-container://buildkit

lunora deploy preflights both and stops with setup directions if either is missing. The Dockerfile path remains the zero-extra-deps default — Railpack is purely opt-in.

Lifecycle logs

The generated container classes emit a structured event on start, stop, and error, tagged with the container name and per-instance id (the container's CLOUDFLARE_DURABLE_OBJECT_ID). lunora dev surfaces them inline with your ctx.log and RPC lines:

[lunora] container:transcoder#a1b2c3d4  start
[lunora] container:transcoder#a1b2c3d4  error  exited unexpectedly

The same events also appear in the Studio Logs panel: each lifecycle transition is best-effort pushed into the root shard's log buffer under the container:<name> source, so a crash-looping container shows up next to your function logs without leaving the dashboard. The push is best-effort by design — if it can't reach the shard, the lunora dev terminal above remains the source of truth.

Advisor lints

Two advisor lints flag container cost/security footguns in the Studio Advisors table and at codegen time:

  • container_oversized_instance — a large standard-3/standard-4 (or large custom) instance, whose provisioned memory + disk are billed while any instance runs.
  • container_public_internetenableInternet left at the default (enabled); set it explicitly (false to close egress, true to opt in).

Configuration

FieldPurpose
imageLocal Dockerfile path/directory, { registry } for a pre-built image, or { build } (Railpack). Required.
defaultPortPort the container listens on; fetch targets it. Must be EXPOSEd for local dev.
requiredPortsEvery port start-up must wait for (multi-port); route a request to one with .port(n).
instanceTypelite | basic | standard-1..4, or a custom { vcpu, memoryMib, diskMb }.
maxInstancesCap on concurrently running instances (and the default .any() pool size).
sleepAfterIdle timeout before an instance sleeps, e.g. "5m", "30s", or seconds. Default "10m".
hardTimeoutHard cap on total lifetime from start, ignoring activity. Same grammar as sleepAfter.
readyOnApplication-level readiness probes that gate request proxying until the app reports ready.
pingEndpointHTTP path the platform polls to decide an instance is healthy. Defaults to upstream's "ping".
envStatic environment variables passed to the container on every start (runtime). For secrets use secrets.
secretsNames of Worker secrets forwarded into the container env at start.
buildArgsBuild-time args for a built image (wrangler image_vars, like --build-arg); ignored for { registry }.
entrypointOverride the image's ENTRYPOINT/CMD for every start.
enableInternetWhether the container may open outbound connections. Default true — note egress is billed.
allowedHostsEgress allow-list (globs) — hosts reachable even with enableInternet: false.
deniedHostsEgress deny-list (globs) — overrides everything, including allowedHosts.
interceptHttpsExtend the egress lists to HTTPS too (image must trust the Cloudflare CA). Default false.
labelsKey-value metadata attached to every instance for metrics/observability.
nameOverride the wrangler containers[].name identifier.
rolloutRolling-deploy tuning: { stepPercentage, gracePeriodSeconds }.

Secrets and environment

env holds static values; secrets names Worker secrets — managed exactly like every other Lunora secret. List them in .dev.vars for local dev and wrangler secret put for production (see Deployment). A declared secret that isn't set fails fast at container start with a directed error, rather than launching the container without it.

export const worker = defineContainer({
    image: "./containers/worker",
    env: { LOG_LEVEL: "info" },
    secrets: ["STRIPE_API_KEY"], // resolved from env.STRIPE_API_KEY at start
});

To pull from a Cloudflare Secrets Store binding instead of a plain Worker secret, map container env-var name → Secrets Store binding name with secretsStore. Each binding is resolved with its async .get() the first time the instance starts (memoised thereafter) and injected as that env var:

export const worker = defineContainer({
    image: "./containers/worker",
    secretsStore: { STRIPE_KEY: "STRIPE_SECRET" }, // env.STRIPE_SECRET.get() → STRIPE_KEY inside the container
});

A name that collides with env/secrets, or a binding missing from the Worker env, is rejected (the collision at authoring time, the missing binding at start) — the same fail-closed stance as secrets. A per-instance start({ envVars }) replaces the env set wholesale and skips Secrets Store resolution entirely, so the injected values apply only to implicit starts or a bare start().

Both env and secrets are runtime values, available when the container starts. For build-time values — docker build --build-arg, exposed to the Dockerfile as ARG (wrangler's image_vars) — use buildArgs. They only apply to an image Lunora builds (a Dockerfile or Railpack { build } source) and are ignored for a pre-built { registry } image:

export const worker = defineContainer({
    image: "./containers/worker",
    buildArgs: { NODE_VERSION: "22", BUILD_TARGET: "production" }, // → docker --build-arg
});

Dockerfile

The scaffolded Dockerfile encodes the platform's requirements:

# linux/amd64 is required — keep it explicit so Apple Silicon builds don't
# produce an arm64 image that fails at deploy.
FROM --platform=linux/amd64 node:22-slim

WORKDIR /app
COPY . .

# Local dev needs the listening port EXPOSEd (production exposes all ports).
EXPOSE 8080

# Exec form (not shell form) so SIGTERM reaches the process — Cloudflare sends
# SIGTERM on rollouts and gives 15 minutes before SIGKILL.
ENTRYPOINT ["node", "server.mjs"]

Disk is ephemeral — every (re)start gives a fresh filesystem. For persistence, write to @lunora/storage (R2).

Local development

lunora dev (and vite dev) build and run containers locally through the Cloudflare Vite plugin, so a Docker-compatible engine (Docker Desktop, Colima) must be running — Lunora warns up front if it isn't. A few things differ from production:

  • Container code is not hot-reloaded. Edit the Worker and it reloads; press r to rebuild the container.
  • Ports must be EXPOSEd in the Dockerfile (production exposes all ports).
  • vite dev can't pull from the Cloudflare Registry — use a local Dockerfile, FROM-ing a registry image if you need one.

Testing

ctx.containers has a Docker-free test double, so action tests stay fast and deterministic — createContainerTestContext maps each container to a fetch handler that plays the container:

import { createContainerTestContext } from "@lunora/container";

const containers = createContainerTestContext({
    transcoder: async (request, { name }) => new Response(JSON.stringify({ ok: true, name })),
});

// Inject `containers` as ctx.containers when exercising the action.

Run the real container behind lunora dev for integration tests; keep those in a Docker-enabled CI job, separate from the default unit-test matrix.

CLI

Manage images and instances with lunora containers — thin wrappers over wrangler containers with a Docker preflight on the build/push paths:

lunora containers build ./containers/transcoder --tag transcoder:v1 --push
lunora containers push transcoder:v1
lunora containers images list
lunora containers images delete transcoder:v1

Splitting build/push from lunora deploy lets CI build the image in one job and deploy in another. lunora deploy itself runs a Docker preflight and stops with an actionable message when a Dockerfile-built container is declared but no engine is available.

Known platform limitations

Some constraints live in Cloudflare Containers itself, not in this package — worth knowing before you design around them. They track open issues on cloudflare/containers; Lunora papers over what it can (cold-start retry, WebSocket keep-alive) and surfaces the rest honestly rather than pretending they don't exist.

  • No autoscaling or location-aware routing. Pools are a fixed size and .any()/.pool() pick uniformly at random, ignoring instance region. Drive batch work from a scheduler cron and prefer .pool() for stateless requests. (cloudflare/containers#226)
  • Disk is ephemeral. Every (re)start gives a fresh filesystem — persist to @lunora/storage (R2). FUSE mounts, a writable tmpfs, and some low-level node:net socket modes are not available in the container sandbox. (cloudflare/containers#112, #160, #67)
  • Egress interception is HTTP-first. The allowedHosts/deniedHosts firewall gates HTTP egress out of the box; HTTPS needs interceptHttps: true plus the Cloudflare CA trusted in the image, and raw gRPC interception isn't supported yet. (cloudflare/containers#195)
  • Long jobs can be terminated on rollout. A new deploy moves instances after rollout.gracePeriodSeconds; a job longer than that may be sent SIGTERM (15 min before SIGKILL). Use hardTimeout as a cost backstop and make long work resumable. (cloudflare/containers#138)
  • Local dev can't pull from the Cloudflare Registry. lunora dev builds from a local Dockerfile; FROM a registry image inside it if you need one. (cloudflare/containers#155)

See also