What Is Cloudflare Containers, the “Dream Service”
Cloudflare Containers is a container service recently released by Cloudflare in beta. From a user’s perspective, it feels similar to Cloud Run, but with the ability to directly invoke, start, and shut down containers from Workers. Since it is container-based, there are effectively no runtime restrictions.
What’s Good About It
Workers are excellent, but they come with many limitations and often feel like they don’t quite reach the itchy spots. Nowadays, standard Node.js APIs are available, making them far more usable than before. Still, due to their edge nature, there are memory limits and execution time limits. Trying to run an entire application using only Cloudflare Workers can be challenging in some scenarios.
That’s where Cloudflare Containers come in. Heavy or long-running tasks can be offloaded to Containers, where they can run for an extended period and eventually return results. Lighter processing can be handled entirely within Workers. This separation significantly improves the overall developer experience.
Running SvelteKit on Cloudflare Workers
Because SvelteKit follows Web Standards, you can easily deploy it to Cloudflare Containers or Pages (now deprecated) using an adapter.
What We Want to Achieve This Time
We assume an application that is already running on Cloudflare Workers. When a request hits a specific endpoint, a Container is launched to perform heavy processing and ultimately return a response.
(In practice, Workers still have execution time limits. If you wait for the Container to finish, the Worker execution will be forcibly terminated. So the idea is to return a response immediately, let the Container do its work, and then perform further processing inside the Container.)
To achieve this, the Worker and the Container share the same codebase. The Worker is built with adapter-cloudflare, while the Container is built with the Node adapter.
The Problem
To run Cloudflare Containers, you must use Durable Objects.
If you are only running SvelteKit on Workers, there is no particular issue. You can bind R2, D1, and similar services directly and they will work as expected. However, once Durable Objects are involved, things change. The reason is that adapter-cloudflare does not support Durable Objects.
✘ [ERROR] Your Worker depends on the following Durable Objects, which are not exported in your entrypoint file: Container.
You should export these objects from your entrypoint, .svelte-kit/cloudflare/_worker.js.
What to Do
Since there is no choice but to write a custom adapter, we extend adapter-cloudflare and create our own wrapper adapter.
More specifically, we create DurableObject.server.ts and define the Durable Object class there. We transpile only that file on its own and then inject the generated code into _worker.js, which is the SvelteKit build output. In short, this is handled via a workaround.
Let’s Get Started
First, the things required to run Cloudflare Containers are:
- A Dockerfile
-
wrangler.jsonc(ortoml) configuration
Creating the Dockerfile
Let’s start with the Dockerfile.
FROM node:24-slim
WORKDIR /app
# First, copy only dependency files
COPY package.json package-lock.json ./
# Copy the entire project source code and configuration files
COPY . .
RUN rm -rf node_modules
# 👆 You must either keep this or add node_modules to .dockerignore,
# otherwise you may encounter esbuild binary errors (this was a gotcha)
# This allows Docker layer caching to work efficiently
RUN npm ci
ENV ADAPTER=node
# 👆 Defined so svelte.config.js can determine the environment
ENV PORT=8080
# Perform a production build once
RUN bun run build:node
EXPOSE 8080
CMD ["npm", "run", "dev:node"]
// omitted
"scripts": {
"dev:node": "ADAPTER=node vite dev --host 0.0.0.0 --port 8080"
}
// omitted
Adding Configuration to wrangler.json
Next is the wrangler.jsonc configuration.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "http2",
"main": "src/index.ts",
"compatibility_date": "2025-04-03",
"migrations": [
{
"new_sqlite_classes": [
"Container"
],
"tag": "v1"
}
],
"containers": [{
"image": "./Dockerfile",
"class_name": "Container",
"max_instances": 2
}],
"durable_objects": {
"bindings": [
{
"class_name": "Container",
"name": "CONTAINER"
}
]
},
"observability": {
"enabled": true
}
}
The migrations, containers, and durable_objects settings are required. In addition, the class_name defined in durable_objects, the class_name in containers, and the name in new_sqlite_classes must all match.
This part was based on the example repository:
https://github.com/cloudflare/containers-demos/blob/main/http2/wrangler.jsonc
Defining DurableObject.server.ts
In the example, the Durable Object is extended directly.
https://github.com/cloudflare/containers-demos/blob/main/http2/src/index.ts
There is actually a package called @cloudflare/containers, which allows you to write this more cleanly, so we’ll use that here.
https://github.com/cloudflare/containers
We define it under lib.
lib/DurableObject.server.ts
import { Container as CFContainer } from '@cloudflare/containers';
import type { Env } from '../worker-configuration';
import type { env as svEnv } from '$env/dynamic/private';
import type { env as svPublicEnv } from '$env/dynamic/public';
export class Container extends CFContainer<Env> {
defaultPort = 8080;
sleepAfter: string | number = '10m';
enableInternet = true;
startAndWaitForPorts(
portsOrArgs?: number | number[] | Record<any, any>,
cancellationOptions?: unknown,
startOptions?: unknown
): Promise<void> {
// Configure cancellationOptions with increased timeout values
const enhancedCancellationOptions = {
instanceGetTimeoutMS: 30000,
portReadyTimeoutMS: 60000,
waitInterval: 500,
...(cancellationOptions ?? {})
};
// @ts-expect-error Ignore for now due to type mismatch
return super.startAndWaitForPorts(portsOrArgs, enhancedCancellationOptions, startOptions);
}
constructor(ctx: DurableObjectState<{}>, env: Env & typeof svEnv & typeof svPublicEnv) {
super(ctx, env);
this.envVars = {
...this.envVars
// You can spread environment variables here
};
this.entrypoint = env.APP_MODE !== 'development' ? ['node', 'build'] : undefined;
// Since the entrypoint can be overridden,
// you can switch between prod and dev depending on the environment
}
async fetch(req: Request) {
return super.fetch(req);
}
}
Some settings are overridden, but it should work even without these changes.
Creating a Custom Adapter
This time the logic is not very complex, but there is official documentation on how to write a custom adapter, so it’s worth checking out.
adapters/custom-adatper.js
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { execSync } from 'child_process';
import cloudflareAdapter from '@sveltejs/adapter-cloudflare';
export default function customAdapter(options = {}) {
const baseAdapter = cloudflareAdapter(options);
return {
name: 'custom-cloudflare-adapter',
async adapt(builder) {
await baseAdapter.adapt(builder);
await addDurableObjectDef(builder);
},
supports: baseAdapter.supports,
emulate: baseAdapter.emulate
};
}
async function addDurableObjectDef(builder) {
const __dirname = new URL('.', import.meta.url).pathname;
execSync(
'tsc src/lib/DurableObject.server.ts --outdir ./dist --target esnext --module esnext --noResolve --noCheck'
);
console.log('Built Durable Object definitions');
const dbPath = join(__dirname, '../', 'dist/DurableObject.server.js');
const workerPath = join(builder.getBuildDirectory('cloudflare'), '_worker.js');
try {
let content = readFileSync(workerPath, 'utf-8');
const durableObjectDefs = readFileSync(dbPath, 'utf-8');
content = content + '\n' + durableObjectDefs;
writeFileSync(workerPath, content);
builder.log.info('Added Durable Object exports');
} catch (error) {
throw new Error(`Failed to add hooks import: ${error.message}`);
}
}
What this does is simple: as described above, it builds DurableObject.server.ts on its own and appends its contents to _worker.js.
Use the custom adapter in svelte.config.js.
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import customAdapter from './adapters/custom-cloudflare-adapter.js';
import nodeAdapter from '@sveltejs/adapter-node';
const config = {
preprocess: vitePreprocess(),
kit: {
adapter:
process.env.ADAPTER === 'node'
? nodeAdapter({
out: 'build',
precompress: true
})
: customAdapter()
}
};
export default config;
Note:
The main application runs on the Worker runtime, while the same application runs on the Node runtime inside the Container. For that reason, the Node adapter is also required here.
Try building the custom adapter once.
Run ADAPTER=worker vite build and check that the Container definition code has been appended at the end.
Running the Dev Server
The Vite dev server does not seem to support Durable Objects yet, so you can only run this via wrangler.
To run a dev server, you need to use wrangler dev, but HMR does not work with it. As a result, you need to split your terminal: run vite build --watch in one, and wrangler dev in the other.
ADAPTER=worker npm run build
Then:
npx wrangler dev --port 5173
If things work correctly, Docker image downloads should start.
Sending a Request to the Container
First, define the binding types.
src/app.d.ts
import type { Container } from '$lib/DurableObject';
import { KVNamespace } from '@cloudflare/workers-types';
declare global {
namespace App {
interface Locals {}
interface Platform {
env: {
CONTAINER: DurableObjectNamespace<Container>;
};
cf: CfProperties;
context: ExecutionContext;
}
}
}
As a test, create routes/api/dev/+server.ts and call the Container from there.
routes/api/dev/+server.ts
import { json } from '@sveltejs/kit';
export const GET = async ({ platform }) => {
try {
const res = await platform?.env.CONTAINER
.get(platform?.env.CONTAINER.idFromName('unique-name'))
.fetch(
new Request('http://localhost/api/dev/container', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hello: 'world' })
})
);
return res;
} catch (error) {
console.log(error);
return json('error');
}
};
Create another endpoint, which will receive the request on the Container side.
routes/api/dev/container/+server.ts
import { json } from '@sveltejs/kit';
export const GET = async ({ request }) => {
try {
const body = await request.json();
console.log(body);
return json(body);
} catch (error) {
console.log(error);
return json('error');
}
};
Sending the Request
Start the server.
It appears to have started successfully.
For some reason, only the first request fails (possibly because it’s still in beta).
However, the Container itself is running.
So if you send the request again, it should succeed.
Since the request was successfully forwarded, try sending a POST request as well.
Thoughts
This time, I took an approach where the same application also runs inside a Container. Because Containers are not tied to a specific environment, that flexibility is very appealing. You can choose whatever runtime or environment you like, such as Node.js, Bun, or Deno.
Local development still requires several workarounds, but I expect this to improve over time. There is also still room to optimize the Dockerfile further, both in terms of build performance and image size.
I also use Cloud Run frequently. With Cloud Build, if you keep the region and machine specs unchanged, deployments can still take around five minutes even after aggressively shrinking the image. Cloudflare, on the other hand, seems to handle caching well—for example, skipping builds when there are no Worker-side changes—so deployments feel somewhat faster. With this Dockerfile, builds took roughly two minutes.







Top comments (0)