Skip to content

Commit c4471dc

Browse files
conico974vicb
andauthored
[cloudflare] Add multi-worker setup documentation for Cloudflare (#178)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io> Co-authored-by: Victor Berchet <victor@suumit.com>
1 parent f59e96e commit c4471dc

File tree

2 files changed

+258
-1
lines changed

2 files changed

+258
-1
lines changed

pages/cloudflare/howtos/_meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@
88
"keep_names": "__name issues",
99
"workerd": "workerd specific packages",
1010
"skew": "Skew Protection",
11-
"assets": "Static assets"
11+
"assets": "Static assets",
12+
"multi-worker": "Multi-Worker Advanced Setup"
1213
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { Callout } from "nextra/components";
2+
3+
<Callout type="warning">
4+
This is an advanced feature and requires a good understanding of both OpenNext and Cloudflare Workers.
5+
This advanced setup **cannot** be used with:
6+
- Preview URLs (staging deployments)
7+
- Skew protection features
8+
- The standard `@opennextjs/cloudflare deploy` command
9+
10+
Consider these limitations carefully before proceeding.
11+
12+
</Callout>
13+
14+
OpenNext lets you split your application into smaller, lighter parts in several workers. This can improve performance and reduce the memory footprint of your application.
15+
It's a more advanced feature that doesn't support deploying through the standard `@opennextjs/cloudflare deploy` command.
16+
17+
As an example, we'll split the middleware into its own worker and the rest of the application into another worker. You could split the application further by creating additional workers for specific routes or features, but this won't be covered here.
18+
When referring to the middleware here, we talk about both the middleware you built, and the routing layer of OpenNext.
19+
20+
You can find an example of such a deployment in the [GitBook repository](https://github.com/GitbookIO/gitbook).
21+
22+
## When to use this setup
23+
24+
This multi-worker approach is beneficial when you need:
25+
26+
- Reduced memory footprint for individual workers
27+
- Improved cold start performance by splitting the light middleware into its own worker and serving ISR/SSG requests from there
28+
29+
### `open-next.config.ts`
30+
31+
Here we assume a configuration like that:
32+
33+
```ts
34+
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
35+
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
36+
import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache";
37+
import doShardedTagCache from "@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache";
38+
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
39+
import { purgeCache } from "@opennextjs/cloudflare/overrides/cache-purge/index";
40+
41+
export default defineCloudflareConfig({
42+
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: "long-lived" }),
43+
queue: doQueue,
44+
// This is only required if you use On-demand revalidation
45+
tagCache: doShardedTagCache({
46+
baseShardSize: 12,
47+
regionalCache: true, // Enable regional cache to reduce the load on the DOs and improve speed
48+
regionalCacheTtlSec: 3600, // The TTL for the regional cache of the tag cache
49+
regionalCacheDangerouslyPersistMissingTags: true, // Enable this to persist missing tags in the regional cache
50+
shardReplication: {
51+
numberOfSoftReplicas: 4,
52+
numberOfHardReplicas: 2,
53+
regionalReplication: {
54+
defaultRegion: "enam",
55+
},
56+
},
57+
}),
58+
enableCacheInterception: true,
59+
// you can also use the `durableObject` option to use a durable object as a cache purge
60+
cachePurge: purgeCache({ type: "direct" }),
61+
});
62+
```
63+
64+
### Custom workers
65+
66+
You'll need 2 custom workers in order for this to work:
67+
68+
```js
69+
// middleware.js
70+
import { WorkerEntrypoint } from "cloudflare:workers";
71+
72+
// ./.open-next/cloudflare/init.js
73+
import { runWithCloudflareRequestContext } from "./.open-next/cloudflare/init.js";
74+
75+
import { handler as middlewareHandler } from "./.open-next/middleware/handler.mjs";
76+
77+
export { DOQueueHandler } from "./.open-next/.build/durable-objects/queue.js";
78+
79+
export { DOShardedTagCache } from "./.open-next/.build/durable-objects/sharded-tag-cache.js";
80+
81+
export default class extends WorkerEntrypoint {
82+
async fetch(request) {
83+
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
84+
// Process the request through Next.js middleware layer and OpenNext routing layer
85+
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
86+
87+
// If middleware returns a Response, send it directly (e.g., redirects, blocks, ISR/SSG cache Hit)
88+
if (reqOrResp instanceof Response) {
89+
return reqOrResp;
90+
}
91+
92+
// Forward the modified request to the server worker
93+
// Version affinity ensures consistent worker versions
94+
// https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity
95+
reqOrResp.headers.set("Cloudflare-Workers-Version-Overrides", `server="${this.env.WORKER_VERSION_ID}"`);
96+
97+
// Proxy to the server worker with cache disabled for dynamic content
98+
return this.env.DEFAULT_WORKER.fetch(reqOrResp, {
99+
// We return redirects as is
100+
redirect: "manual",
101+
cf: {
102+
cacheEverything: false,
103+
},
104+
});
105+
});
106+
}
107+
}
108+
```
109+
110+
```js
111+
// server.js
112+
113+
// Replace with your actual build output directory, typically:
114+
// ./.open-next/cloudflare/init.js
115+
import { runWithCloudflareRequestContext } from "./.open-next/cloudflare/init.js";
116+
117+
import { handler } from "./.open-next/server-functions/default/handler.mjs";
118+
119+
export default {
120+
async fetch(request, env, ctx) {
121+
return runWithCloudflareRequestContext(request, env, ctx, async () => {
122+
// - `Request`s are handled by the Next server
123+
return handler(request, env, ctx);
124+
});
125+
},
126+
};
127+
```
128+
129+
### Wrangler configurations
130+
131+
```jsonc
132+
// Middleware wrangler file
133+
{
134+
"main": "middleware.js",
135+
"name": "middleware",
136+
"compatibility_date": "2025-04-14",
137+
"compatibility_flags": ["nodejs_compat", "allow_importable_env", "global_fetch_strictly_public"],
138+
// The middleware serves the assets
139+
"assets": {
140+
"directory": "../../.open-next/assets",
141+
"binding": "ASSETS",
142+
},
143+
"vars": {
144+
// This one will need to be replaced for every deployment
145+
"WORKER_VERSION_ID": "TO_REPLACE",
146+
},
147+
"routes": [
148+
// Define your routes here, not in server.js
149+
],
150+
"r2_buckets": [
151+
{
152+
"binding": "NEXT_INC_CACHE_R2_BUCKET",
153+
"bucket_name": "<BUCKET_NAME>",
154+
},
155+
],
156+
"services": [
157+
{
158+
"binding": "WORKER_SELF_REFERENCE",
159+
"service": "middleware",
160+
},
161+
{
162+
"binding": "DEFAULT_WORKER",
163+
"service": "main-server",
164+
},
165+
],
166+
"durable_objects": {
167+
"bindings": [
168+
{
169+
"name": "NEXT_TAG_CACHE_DO_SHARDED",
170+
"class_name": "DOShardedTagCache",
171+
},
172+
{
173+
"name": "NEXT_CACHE_DO_QUEUE",
174+
"class_name": "DOQueueHandler",
175+
},
176+
],
177+
},
178+
"migrations": [
179+
{
180+
"tag": "v1",
181+
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"],
182+
},
183+
],
184+
}
185+
```
186+
187+
```jsonc
188+
// Server wrangler file
189+
{
190+
"main": "server.js",
191+
"name": "main-server",
192+
"compatibility_date": "2025-04-14",
193+
"compatibility_flags": ["nodejs_compat", "allow_importable_env", "global_fetch_strictly_public"],
194+
"r2_buckets": [
195+
{
196+
"binding": "NEXT_INC_CACHE_R2_BUCKET",
197+
"bucket_name": "<BUCKET_NAME>",
198+
},
199+
],
200+
"services": [
201+
{
202+
"binding": "WORKER_SELF_REFERENCE",
203+
"service": "middleware",
204+
},
205+
],
206+
"durable_objects": {
207+
"bindings": [
208+
{
209+
"name": "NEXT_TAG_CACHE_DO_SHARDED",
210+
"class_name": "DOShardedTagCache",
211+
"script_name": "middleware",
212+
},
213+
{
214+
"name": "NEXT_CACHE_DO_QUEUE",
215+
"class_name": "DOQueueHandler",
216+
"script_name": "middleware",
217+
},
218+
],
219+
},
220+
}
221+
```
222+
223+
### Actual deployment
224+
225+
You cannot use `@opennextjs/cloudflare deploy` to deploy this setup, as it will not work with the multiple workers setup.
226+
227+
1. **Server Upload** → Get version ID
228+
2. **Middleware Preparation** → Update version reference
229+
3. **Middleware Upload** → Get version ID
230+
4. **Gradual Rollout** → Server (0%) → Middleware (100%) → Server (100%)
231+
232+
In order to make this work, you need to deploy each worker separately using the `wrangler` CLI and override the `WORKER_VERSION_ID` variable in the middleware wrangler configuration for **each deployment**.
233+
Note that we use gradual deployments as a solution for deploying new versions without affecting the currently running ones.
234+
235+
The steps to deploy without causing downtime to the already deployed ones are as follows:
236+
237+
1. First you'll need to upload a new version of the server worker `wrangler versions upload --config ./path-to/serverWrangler.jsonc`
238+
2. Then you'll need to extract the new version id of the server from the previous command's output. The value you need is displayed as `Worker Version ID: <ID>` in the console output. This value is referred to as `NEW_SERVER_VERSION_ID` in step 8.
239+
3. Before uploading the middleware, you'll need to replace the `WORKER_VERSION_ID` variable in the middleware wrangler configuration with the new server version id from the previous step.
240+
4. You then need to upload a new version of the middleware worker `wrangler versions upload --config ./path-to/middlewareWrangler.jsonc`. Retrieve the version id, you'll need it in step 9 (`NEW_MIDDLEWARE_ID`).
241+
5. And extract the new version id of the middleware from the previous command's output. The value you need is displayed as `Worker Version ID: <ID>` in the console output.
242+
6. Use `wrangler deployments status --config ./path-to/server-wrangler.jsonc` to get the currently deployed version id of the server
243+
7. Extract the version id of the server from the previous command's output. This value is referred to as `CURRENT_SERVER_ID` in step 8.
244+
8. You then use gradual deployment to deploy the server uploaded at step 1 to 0% `wrangler versions deploy <CURRENT_SERVER_ID>@100% <NEW_SERVER_VERSION_ID>@0% -y --config ./path-to/server-wrangler.jsonc`
245+
9. You then deploy the middleware at 100% `wrangler versions deploy <NEW_MIDDLEWARE_ID>@100% -y --config ./path-to/middlewareWrangler.jsonc`. At this stage you are already serving the new version of the website in production.
246+
10. To finish it off you deploy the server at 100% `wrangler versions deploy <NEW_SERVER_VERSION_ID>@100% -y --config ./path-to/server-wrangler.jsonc`.
247+
248+
You can find actual implementations of such a deployment in the GitBook repo using Github actions [here](https://github.com/GitbookIO/gitbook/blob/main/.github/composite/deploy-cloudflare/action.yaml).
249+
250+
#### Version Affinity Explained
251+
252+
Version affinity ensures that requests are routed to workers running compatible versions:
253+
254+
- The middleware sets `Cloudflare-Workers-Version-Overrides` header
255+
- This forces the request to go to the correct server worker version.
256+
- Prevents version mismatches during deployments

0 commit comments

Comments
 (0)