Cloudflare Containers for Lunora: defineContainer, generated Container DO classes, and the ctx.containers action surface
Daniel Bannert's open source work is supported by the community on GitHub Sponsors
Cloudflare Containers for Lunora: defineContainer, generated Container Durable Object classes, and the ctx.containers action surface.
Part of the Lunora framework — a type-safe, real-time backend on Cloudflare Workers + Durable Objects with a Vite-first DX.
npm install @lunora/containeryarn add @lunora/containerpnpm add @lunora/containerDeclare containers in lunora/containers.ts:
import { defineContainer } from "@lunora/container";
export const transcoder = defineContainer({
image: "./containers/transcoder", // dir with a Dockerfile, or { registry: "docker.io/acme/transcoder:1.4" }
defaultPort: 8080,
instanceType: "standard-1",
maxInstances: 5,
sleepAfter: "5m",
secrets: ["TRANSCODER_API_KEY"], // forwarded from Worker secrets / .dev.vars
labels: { team: "media" }, // metadata attached to every instance for metrics/observability
});Codegen emits the Container Durable Object class into _generated/containers.ts (re-export it from your worker entry) and wires a typed handle onto ActionCtx:
// lunora/transcode.ts — `action` and `v` come from your generated server module.
import { action, v } from "@/lunora/_generated/server";
export const transcode = action.input({ videoId: v.id("videos") }).action(async ({ args: { videoId }, ctx }) => {
// one instance per entity (same id always routes to the same container)
const res = await ctx.containers.transcoder.get(videoId).fetch("/transcode", { method: "POST" });
// a random instance from a fixed pool, for stateless work
const probe = await ctx.containers.transcoder.any().fetch("/healthz");
// .pool() is like .any() but retries on another instance on a 5xx / thrown error
const out = await ctx.containers.transcoder.pool({ attempts: 3 }).fetch("/transcode", { method: "POST" });
return res.json();
});ctx.containers is action-only (container calls are external I/O, like ctx.fetch); .get(name) handles also expose start/stop/destroy/getState lifecycle control plus renewActivityTimeout() (keep a busy WebSocket's container awake) and egress.* (adjust the allow/deny lists at runtime).
The config layer (lunora dev / lunora deploy) reconciles the wrangler containers[] entry, the CONTAINER_* Durable Object binding, and the SQLite-class migration automatically; wrangler deploy builds the Dockerfile with local Docker and pushes it to the Cloudflare Registry.
Declare every port the container must be listening on with requiredPorts (start-up waits for all of them); defaultPort is the target when a request doesn't pick one. Route a single request to another port with .port(n) — it 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
await ctx.containers.app.get(tenantId).port(9090).fetch("/admin"); // → 9090env and secrets are runtime values; for build-time docker build --build-arg values (wrangler image_vars, exposed to the Dockerfile as ARG) use buildArgs. They apply only to an image Lunora builds and are ignored for a pre-built { registry } image.
export const worker = defineContainer({
image: "./containers/worker",
buildArgs: { NODE_VERSION: "22", BUILD_TARGET: "production" },
});Pair enableInternet: false with an allowedHosts allow-list (or layer a deniedHosts deny-list that overrides everything) to constrain a container's outbound traffic; interceptHttps: true extends the lists to TLS connections (the image must trust the Cloudflare CA). Codegen re-exports the ContainerProxy worker entrypoint the interception path needs automatically.
export const fetcher = defineContainer({
image: "./containers/fetcher",
enableInternet: false,
allowedHosts: ["*.stripe.com", "api.github.com"],
deniedHosts: ["*.evil.com"],
});
// tighten or relax one running instance at runtime:
await ctx.containers.fetcher.get(tenantId).egress.allow("hooks.slack.com");For advanced egress rewriting in worker code, @lunora/container/do re-exports Cloudflare's custom outbound-handler types (OutboundHandler, OutboundHandlers, outboundParams) — wire them onto a hand-authored LunoraContainer subclass to inject auth, route, or mock a container's outbound calls.
The platform health check waits for an open port, 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. Probes are declarative data (path + optional port/status), run in parallel at start, and probe the container's TCP port directly.
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
],
});sleepAfter caps idle time; hardTimeout caps total lifetime — a runaway-cost backstop measured from start, regardless of activity (same grammar as sleepAfter). When it elapses the generated class's onHardTimeoutExpired hook runs (default: stop()); the timer is run-generation-stamped so a stale timer from a slept/crashed run can't kill a fresh one.
export const job = defineContainer({
image: "./containers/job",
hardTimeout: "1h", // never run longer than an hour, busy or not
});Container code calls back into your app's functions with the bridge client (any JS runtime), over the Worker's HTTP RPC endpoint:
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 });The token is a bearer your Worker's resolveIdentity recognizes — pass it to the container as a secret. Non-JS containers can POST /_lunora/rpc with { functionPath, args } directly.
Secure the bridge in resolveIdentity: read request.headers.get("authorization"), strip the Bearer prefix, and compare the token against a Worker secret (e.g. env.LUNORA_CONTAINER_TOKEN) you also forward to the container. Return a { userId } identity only on a match and null otherwise — an unrecognised request then runs anonymously and is rejected by your functions' own authorization checks. See Securing the bridge for the full example.
@lunora/container— Node-safe:defineContainer, naming/normalization helpers,createContainerContext, and the Docker-freecreateContainerTestContexttest double.@lunora/container/do— workerd-only: theLunoraContainerbase class the generated DO classes extend (pulls in@cloudflare/containers).@lunora/container/bridge— runtime-agnostic:createContainerBridgefor calling Lunora functions from inside a container.
This README covers the basics. For the full API, options, and guides, see the documentation.
@lunora/server— defines the actions that drive containers viactx.containers.@lunora/config— reconciles the wranglercontainers[]entry and Durable Object binding.@lunora/runtime— the Worker runtime the bridge client calls back into.
Libraries in this ecosystem make the best effort to track Node.js' release schedule. Here's a post on why we think this is important.
If you would like to help take a look at the list of issues and check our Contributing guidelines.
Note: please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
This is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. Anolilab is a Development and AI Studio. Contact us at hello@anolilab.com if you need any help with these technologies or just want to say hi!
The Lunora container package is open-sourced software licensed under the FSL-1.1-Apache-2.0.