@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/containernpm install @lunora/containeryarn add @lunora/containerbun add @lunora/containerContainers 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.ai —
ctx.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 tomaxInstances, else 3). Use it for stateless, interchangeable work where any instance can serve the request..pool(options?)— like.any(), but resilient: eachfetchpicks a random instance and, on a thrown error or a retryable response (5xxby 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"); // → 9090Egress 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 + resultThe 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://buildkitlunora 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 unexpectedlyThe 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 largestandard-3/standard-4(or large custom) instance, whose provisioned memory + disk are billed while any instance runs.container_public_internet—enableInternetleft at the default (enabled); set it explicitly (falseto close egress,trueto opt in).
Configuration
| Field | Purpose |
|---|---|
image | Local Dockerfile path/directory, { registry } for a pre-built image, or { build } (Railpack). Required. |
defaultPort | Port the container listens on; fetch targets it. Must be EXPOSEd for local dev. |
requiredPorts | Every port start-up must wait for (multi-port); route a request to one with .port(n). |
instanceType | lite | basic | standard-1..4, or a custom { vcpu, memoryMib, diskMb }. |
maxInstances | Cap on concurrently running instances (and the default .any() pool size). |
sleepAfter | Idle timeout before an instance sleeps, e.g. "5m", "30s", or seconds. Default "10m". |
hardTimeout | Hard cap on total lifetime from start, ignoring activity. Same grammar as sleepAfter. |
readyOn | Application-level readiness probes that gate request proxying until the app reports ready. |
pingEndpoint | HTTP path the platform polls to decide an instance is healthy. Defaults to upstream's "ping". |
env | Static environment variables passed to the container on every start (runtime). For secrets use secrets. |
secrets | Names of Worker secrets forwarded into the container env at start. |
buildArgs | Build-time args for a built image (wrangler image_vars, like --build-arg); ignored for { registry }. |
entrypoint | Override the image's ENTRYPOINT/CMD for every start. |
enableInternet | Whether the container may open outbound connections. Default true — note egress is billed. |
allowedHosts | Egress allow-list (globs) — hosts reachable even with enableInternet: false. |
deniedHosts | Egress deny-list (globs) — overrides everything, including allowedHosts. |
interceptHttps | Extend the egress lists to HTTPS too (image must trust the Cloudflare CA). Default false. |
labels | Key-value metadata attached to every instance for metrics/observability. |
name | Override the wrangler containers[].name identifier. |
rollout | Rolling-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
rto rebuild the container. - Ports must be
EXPOSEd in the Dockerfile (production exposes all ports). vite devcan'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:v1Splitting 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 writabletmpfs, and some low-levelnode:netsocket modes are not available in the container sandbox. (cloudflare/containers#112, #160, #67) - Egress interception is HTTP-first. The
allowedHosts/deniedHostsfirewall gates HTTP egress out of the box; HTTPS needsinterceptHttps: trueplus 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 sentSIGTERM(15 min beforeSIGKILL). UsehardTimeoutas a cost backstop and make long work resumable. (cloudflare/containers#138) - Local dev can't pull from the Cloudflare Registry.
lunora devbuilds from a local Dockerfile;FROMa registry image inside it if you need one. (cloudflare/containers#155)