diff --git a/.changeset/container-server-tier.md b/.changeset/container-server-tier.md
new file mode 100644
index 000000000..0e377ddae
--- /dev/null
+++ b/.changeset/container-server-tier.md
@@ -0,0 +1,5 @@
+---
+"gitbook": patch
+---
+
+Add a container server tier: an `@opennextjs/aws` node build of the app running inside a Cloudflare Container, reaching the cache worker through the container Durable Object's outbound handler. Local dev only for now (`bun run build:all`, `bun run dev:cf:container`).
diff --git a/bun.lock b/bun.lock
index 0d86f55ff..2b09ca847 100644
--- a/bun.lock
+++ b/bun.lock
@@ -113,6 +113,7 @@
"version": "0.27.2",
"dependencies": {
"@base-ui/react": "catalog:",
+ "@cloudflare/containers": "^0.3.7",
"@cloudflare/workers-types": "^5.20260716.1",
"@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*",
@@ -580,6 +581,8 @@
"@chevrotain/utils": ["@chevrotain/utils@11.1.1", "", {}, "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ=="],
+ "@cloudflare/containers": ["@cloudflare/containers@0.3.7", "", {}, "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw=="],
+
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
diff --git a/packages/gitbook/.gitignore b/packages/gitbook/.gitignore
index f7a6b3475..3047cd1e5 100644
--- a/packages/gitbook/.gitignore
+++ b/packages/gitbook/.gitignore
@@ -35,5 +35,6 @@ screenshots/
# cloudflare
.open-next
+.open-next-container
.wrangler
worker-configuration.d.ts
\ No newline at end of file
diff --git a/packages/gitbook/open-next.container.config.ts b/packages/gitbook/open-next.container.config.ts
new file mode 100644
index 000000000..c6ec62d91
--- /dev/null
+++ b/packages/gitbook/open-next.container.config.ts
@@ -0,0 +1,30 @@
+import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js';
+
+/**
+ * Build config for the container server tier: the same Next.js app packaged by `@opennextjs/aws`
+ * as a plain Node server, run inside a Cloudflare Container.
+ *
+ * The Cloudflare middleware worker stays the front door, so `middleware.external` mirrors
+ * `open-next.config.ts` and the middleware bundle emitted here is unused.
+ */
+export default {
+ default: {
+ override: {
+ wrapper: 'node',
+ converter: 'node',
+ // We ship our own Dockerfile (openNext/customWorkers/Dockerfile).
+ generateDockerfile: false,
+ queue: () => import('./openNext/container/queue').then((m) => m.default),
+ incrementalCache: () =>
+ import('./openNext/container/incrementalCache').then((m) => m.default),
+ tagCache: () => import('./openNext/container/tagCache').then((m) => m.default),
+ },
+ },
+ middleware: {
+ external: true,
+ },
+ dangerous: {
+ enableCacheInterception: true,
+ },
+ edgeExternals: ['node:crypto'],
+} satisfies OpenNextConfig;
diff --git a/packages/gitbook/openNext/container/client.test.ts b/packages/gitbook/openNext/container/client.test.ts
new file mode 100644
index 000000000..99bfe88c8
--- /dev/null
+++ b/packages/gitbook/openNext/container/client.test.ts
@@ -0,0 +1,111 @@
+import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
+
+const { GitbookContainerIncrementalCache } = await import('./incrementalCache');
+const { default: tagCache } = await import('./tagCache');
+const { default: queue } = await import('./queue');
+
+const cacheValue = {
+ type: 'page' as const,
+ html: '
cached
',
+ json: {},
+};
+
+const revalidationMessage = {
+ MessageDeduplicationId: 'dedup',
+ MessageBody: { host: 'example.com', url: '/docs', lastModified: 1, eTag: 'etag' },
+ MessageGroupId: 'group',
+};
+
+describe('container cache clients', () => {
+ const internalFetch = mock();
+ const originalInternalFetch = (globalThis as { internalFetch?: typeof fetch }).internalFetch;
+ const originalConsoleError = console.error;
+
+ const lastCall = () => internalFetch.mock.calls[internalFetch.mock.calls.length - 1] ?? [];
+ const lastUrl = () => new URL(String(lastCall()[0]));
+ const lastBody = () => JSON.parse((lastCall()[1] as RequestInit).body as string);
+
+ beforeEach(() => {
+ internalFetch.mockReset();
+ internalFetch.mockResolvedValue(new Response(null, { status: 204 }));
+ (globalThis as { internalFetch?: unknown }).internalFetch = internalFetch;
+ console.error = mock();
+ });
+
+ afterEach(() => {
+ (globalThis as { internalFetch?: unknown }).internalFetch = originalInternalFetch;
+ console.error = originalConsoleError;
+ });
+
+ it('reads through the intercepted cache host', async () => {
+ internalFetch.mockResolvedValue(Response.json({ value: cacheValue, lastModified: 123 }));
+
+ const result = await new GitbookContainerIncrementalCache().get(
+ 'key with / characters',
+ 'cache'
+ );
+
+ expect(result).toEqual({ value: cacheValue, lastModified: 123 });
+ const url = lastUrl();
+ expect(url.host).toBe('incremental-cache.internal');
+ expect(url.protocol).toBe('http:');
+ expect(url.pathname).toBe('/');
+ expect(url.searchParams.get('key')).toBe('key with / characters');
+ expect(url.searchParams.get('cacheType')).toBe('cache');
+ });
+
+ it('returns null for cache misses and failed reads', async () => {
+ internalFetch.mockResolvedValue(Response.json(null));
+ expect(await new GitbookContainerIncrementalCache().get('missing')).toBeNull();
+
+ internalFetch.mockResolvedValueOnce(new Response(null, { status: 503 }));
+ expect(await new GitbookContainerIncrementalCache().get('unavailable-response')).toBeNull();
+
+ internalFetch.mockRejectedValueOnce(new Error('unreachable'));
+ expect(await new GitbookContainerIncrementalCache().get('unavailable')).toBeNull();
+ });
+
+ it('posts writes and deletes to their own paths', async () => {
+ const cache = new GitbookContainerIncrementalCache();
+
+ await cache.set('entry', cacheValue, 'cache');
+ expect(lastUrl().pathname).toBe('/set');
+ expect(lastBody()).toEqual({ key: 'entry', value: cacheValue, cacheType: 'cache' });
+
+ await cache.delete('entry');
+ expect(lastUrl().pathname).toBe('/delete');
+ expect(lastBody()).toEqual({ key: 'entry' });
+ });
+
+ it('contains mutation failures', async () => {
+ internalFetch.mockRejectedValue(new Error('unreachable'));
+ const cache = new GitbookContainerIncrementalCache();
+
+ await expect(cache.set('entry', cacheValue, 'cache')).resolves.toBeUndefined();
+ await expect(cache.delete('entry')).resolves.toBeUndefined();
+ });
+
+ it('writes hard tags only', async () => {
+ await tagCache.writeTags([
+ 'content',
+ { tag: 'with-duration', stale: 100, expire: 200 },
+ '_N_T_/soft-tag',
+ ]);
+
+ expect(lastUrl().pathname).toBe('/write-tags');
+ expect(lastBody()).toEqual({
+ tags: ['content', { tag: 'with-duration', stale: 100, expire: 200 }],
+ });
+
+ internalFetch.mockReset();
+ await tagCache.writeTags(['_N_T_/soft-tag']);
+ expect(internalFetch).not.toHaveBeenCalled();
+ });
+
+ it('sends revalidations to the queue path', async () => {
+ await queue.send(revalidationMessage);
+
+ expect(lastUrl().pathname).toBe('/queue');
+ expect(lastBody()).toEqual({ msg: revalidationMessage });
+ });
+});
diff --git a/packages/gitbook/openNext/container/fetch.ts b/packages/gitbook/openNext/container/fetch.ts
new file mode 100644
index 000000000..43a4f3933
--- /dev/null
+++ b/packages/gitbook/openNext/container/fetch.ts
@@ -0,0 +1,12 @@
+/**
+ * Next.js monkey-patches the global `fetch` with its own data cache. Cache traffic must not go
+ * through it, or reading the cache would recurse back into the cache. The OpenNext server adapter
+ * stashes the pristine `fetch` on `globalThis.internalFetch` before Next loads.
+ */
+export function internalFetch(
+ input: Request | URL | string,
+ init?: RequestInit
+): Promise {
+ const untouchedFetch = (globalThis as { internalFetch?: typeof fetch }).internalFetch ?? fetch;
+ return untouchedFetch(input as RequestInfo, init);
+}
diff --git a/packages/gitbook/openNext/container/incrementalCache.ts b/packages/gitbook/openNext/container/incrementalCache.ts
new file mode 100644
index 000000000..d707f485d
--- /dev/null
+++ b/packages/gitbook/openNext/container/incrementalCache.ts
@@ -0,0 +1,83 @@
+import type {
+ CacheEntryType,
+ CacheValue,
+ IncrementalCache,
+ WithLastModified,
+} from '@opennextjs/aws/types/overrides.js';
+
+import { internalFetch } from './fetch';
+import {
+ CACHE_ORIGIN,
+ CACHE_PATH,
+ type DeletePayload,
+ type SetPayload,
+ getReadUrl,
+} from './protocol';
+
+/**
+ * Container counterpart of `openNext/incrementalCache/cacheWorkerClient.ts`: same cache worker,
+ * reached over the outbound handler instead of a service binding.
+ */
+export class GitbookContainerIncrementalCache implements IncrementalCache {
+ name = 'GitbookContainerIncrementalCache';
+
+ async get(
+ key: string,
+ cacheType?: CacheType
+ ): Promise> | null> {
+ try {
+ const response = await internalFetch(getReadUrl(key, cacheType));
+ if (!response.ok) {
+ console.error('Failed to get from cache worker', response.status);
+ return null;
+ }
+
+ return (await response.json()) as WithLastModified> | null;
+ } catch (error) {
+ console.error('Failed to get from cache worker', error);
+ return null;
+ }
+ }
+
+ async set(
+ key: string,
+ value: CacheValue,
+ cacheType?: CacheType
+ ): Promise {
+ const payload: SetPayload = {
+ key,
+ value: value as CacheValue,
+ cacheType,
+ };
+
+ try {
+ await this.post(CACHE_PATH.set, payload);
+ } catch (error) {
+ console.error('Failed to set to cache worker', error);
+ }
+ }
+
+ async delete(key: string): Promise {
+ const payload: DeletePayload = { key };
+
+ try {
+ await this.post(CACHE_PATH.delete, payload);
+ } catch (error) {
+ console.error('Failed to delete from cache worker', error);
+ }
+ }
+
+ private async post(path: string, payload: unknown): Promise {
+ const response = await internalFetch(new URL(path, CACHE_ORIGIN), {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Cache worker responded with ${response.status} for ${path}`);
+ }
+ }
+}
+
+export default new GitbookContainerIncrementalCache();
diff --git a/packages/gitbook/openNext/container/protocol.ts b/packages/gitbook/openNext/container/protocol.ts
new file mode 100644
index 000000000..93ef230d3
--- /dev/null
+++ b/packages/gitbook/openNext/container/protocol.ts
@@ -0,0 +1,59 @@
+import type {
+ CacheEntryType,
+ CacheValue,
+ NextModeTagCacheWriteInput,
+ QueueMessage,
+} from '@opennextjs/aws/types/overrides.js';
+
+/**
+ * Protocol spoken between the Next.js server running inside the container and the cache worker.
+ *
+ * The container has no Cloudflare bindings, so it issues plain `fetch` calls to this virtual host.
+ * They never reach the network: the container Durable Object registers an outbound handler for the
+ * host, and that handler runs in the Workers runtime where the `NEXT_INC_CACHE_WORKER` service
+ * binding is available.
+ */
+export const CACHE_HOST = 'incremental-cache.internal';
+
+// Outbound handlers only see ports 80 and 443, and intercepting HTTPS would require trusting a
+// per-instance CA inside the image. The handler restores the `https:` scheme before forwarding.
+export const CACHE_ORIGIN = `http://${CACHE_HOST}`;
+
+/**
+ * `read` deliberately keeps the URL shape used by the workerd tier so both tiers hit the same
+ * entry in the cache worker's edge cache.
+ */
+export const CACHE_PATH = {
+ read: '/',
+ set: '/set',
+ delete: '/delete',
+ writeTags: '/write-tags',
+ queue: '/queue',
+} as const;
+
+export type SetPayload = {
+ key: string;
+ value: CacheValue;
+ cacheType?: CacheEntryType;
+};
+
+export type DeletePayload = {
+ key: string;
+};
+
+export type WriteTagsPayload = {
+ tags: NextModeTagCacheWriteInput[];
+};
+
+export type QueuePayload = {
+ msg: QueueMessage;
+};
+
+export function getReadUrl(key: string, cacheType?: CacheEntryType): URL {
+ const url = new URL(CACHE_PATH.read, CACHE_ORIGIN);
+ url.searchParams.set('key', key);
+ if (cacheType) {
+ url.searchParams.set('cacheType', cacheType);
+ }
+ return url;
+}
diff --git a/packages/gitbook/openNext/container/queue.ts b/packages/gitbook/openNext/container/queue.ts
new file mode 100644
index 000000000..7d4ce1890
--- /dev/null
+++ b/packages/gitbook/openNext/container/queue.ts
@@ -0,0 +1,25 @@
+import type { Queue } from '@opennextjs/aws/types/overrides.js';
+
+import { internalFetch } from './fetch';
+import { CACHE_ORIGIN, CACHE_PATH, type QueuePayload } from './protocol';
+
+/**
+ * The ISR queue Durable Object lives in the cache worker, so revalidation messages travel the same
+ * outbound path as the cache itself.
+ */
+export default {
+ name: 'GitbookISRQueue',
+ send: async (msg) => {
+ const payload: QueuePayload = { msg };
+
+ try {
+ await internalFetch(new URL(CACHE_PATH.queue, CACHE_ORIGIN), {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ } catch (error) {
+ console.error('Failed to send revalidation to cache worker', error);
+ }
+ },
+} satisfies Queue;
diff --git a/packages/gitbook/openNext/container/tagCache.ts b/packages/gitbook/openNext/container/tagCache.ts
new file mode 100644
index 000000000..e8ac08a05
--- /dev/null
+++ b/packages/gitbook/openNext/container/tagCache.ts
@@ -0,0 +1,36 @@
+import type { NextModeTagCache, NextModeTagCacheWriteInput } from '@opennextjs/aws/types/overrides';
+import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
+
+import { internalFetch } from './fetch';
+import { CACHE_ORIGIN, CACHE_PATH, type WriteTagsPayload } from './protocol';
+
+export default {
+ name: 'GitbookContainerTagCache',
+ mode: 'nextMode',
+ // Do nothing.
+ getLastRevalidated: async () => {
+ return 0;
+ },
+ // Return false, everything handled at the incremental cache level in the do worker.
+ hasBeenRevalidated: async () => {
+ return false;
+ },
+ writeTags: async (tags: NextModeTagCacheWriteInput[]) => {
+ const tagsToWrite = tags.filter(softTagFilter);
+ if (tagsToWrite.length === 0) {
+ return;
+ }
+
+ const payload: WriteTagsPayload = { tags: tagsToWrite };
+
+ try {
+ await internalFetch(new URL(CACHE_PATH.writeTags, CACHE_ORIGIN), {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ } catch (error) {
+ console.error('Failed to write tags to cache worker', error);
+ }
+ },
+} satisfies NextModeTagCache;
diff --git a/packages/gitbook/openNext/customWorkers/Dockerfile b/packages/gitbook/openNext/customWorkers/Dockerfile
new file mode 100644
index 000000000..9ccdc283b
--- /dev/null
+++ b/packages/gitbook/openNext/customWorkers/Dockerfile
@@ -0,0 +1,15 @@
+FROM node:22-slim
+
+WORKDIR /app
+
+# Built by `bun run build:container` (@opennextjs/aws, node wrapper).
+COPY .open-next-container/server-functions/default /app
+
+ENV NODE_ENV=production
+ENV PORT=3000
+
+EXPOSE 3000
+
+# In this monorepo the bundle's root index.mjs re-exports packages/gitbook/index.mjs;
+# importing it boots the HTTP server.
+CMD ["node", "index.mjs"]
diff --git a/packages/gitbook/openNext/customWorkers/container.ts b/packages/gitbook/openNext/customWorkers/container.ts
new file mode 100644
index 000000000..ea0c837a6
--- /dev/null
+++ b/packages/gitbook/openNext/customWorkers/container.ts
@@ -0,0 +1,59 @@
+import { Container, type OutboundHandler, getRandom } from '@cloudflare/containers';
+import { WorkerEntrypoint } from 'cloudflare:workers';
+
+import { CACHE_HOST } from '../container/protocol';
+import { type ContainerOutboundEnv, handleCacheOutbound } from './containerOutbound';
+
+// Required by @cloudflare/containers: the outbound interception proxy is looked up on ctx.exports.
+export { ContainerProxy } from '@cloudflare/containers';
+
+type ContainerWorkerEnv = ContainerOutboundEnv & {
+ NEXT_SERVER_CONTAINER: DurableObjectNamespace;
+ CONTAINER_INSTANCES?: string;
+};
+
+const DEFAULT_INSTANCES = 3;
+
+function getStringVars(env: unknown): Record {
+ return Object.fromEntries(
+ Object.entries(env as Record).filter(
+ (entry): entry is [string, string] => typeof entry[1] === 'string'
+ )
+ );
+}
+
+export class NextServerContainer extends Container {
+ defaultPort = 3000;
+ sleepAfter = '10m';
+ // Host + path: the @opennextjs/aws `node` wrapper answers this without waking Next.
+ pingEndpoint = 'container/__health';
+ // The container is a separate process, so worker `vars` do not reach it on their own. Forward
+ // them so the app reads the same `process.env` the workerd tier does (GITBOOK_URL, STAGE, ...).
+ envVars = getStringVars(this.env);
+}
+
+// `Container.outboundByHost` is a static setter that registers the handlers; declaring it as a
+// static field on the subclass would shadow it with a plain property and the proxy would never
+// find the handler — it would fall through to real internet access instead.
+// `Cloudflare.Env` is generated from the root wrangler config and does not describe this worker's
+// bindings, hence the cast.
+//
+// Only the cache host is intercepted; everything else (the GitBook API, the icons CDN) goes out
+// normally. Note that loopback traffic cannot be intercepted at all — it never leaves the
+// container's network namespace — so anything the app fetches server-side must be a real host.
+NextServerContainer.outboundByHost = {
+ [CACHE_HOST]: (request, env) =>
+ handleCacheOutbound(request, env as unknown as ContainerOutboundEnv),
+} satisfies Record;
+
+export default class extends WorkerEntrypoint {
+ async fetch(request: Request): Promise {
+ const instances = Number.parseInt(this.env.CONTAINER_INSTANCES ?? '', 10);
+ const container = await getRandom(
+ this.env.NEXT_SERVER_CONTAINER,
+ Number.isNaN(instances) ? DEFAULT_INSTANCES : instances
+ );
+
+ return container.fetch(request);
+ }
+}
diff --git a/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts b/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts
new file mode 100644
index 000000000..76fb5629e
--- /dev/null
+++ b/packages/gitbook/openNext/customWorkers/containerOutbound.test.ts
@@ -0,0 +1,105 @@
+import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
+
+import { handleCacheOutbound } from './containerOutbound';
+
+const cacheValue = {
+ type: 'page' as const,
+ html: 'cached
',
+ json: {},
+};
+
+const revalidationMessage = {
+ MessageDeduplicationId: 'dedup',
+ MessageBody: { host: 'example.com', url: '/docs', lastModified: 1, eTag: 'etag' },
+ MessageGroupId: 'group',
+};
+
+const post = (path: string, payload: unknown) =>
+ new Request(`http://incremental-cache.internal${path}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+describe('handleCacheOutbound', () => {
+ const fetch = mock();
+ const set = mock();
+ const remove = mock();
+ const writeTags = mock();
+ const enqueueRevalidation = mock();
+ const originalConsoleError = console.error;
+
+ const env = () => ({
+ NEXT_INC_CACHE_WORKER: { fetch, set, delete: remove, writeTags, enqueueRevalidation },
+ });
+
+ beforeEach(() => {
+ for (const m of [fetch, set, remove, writeTags, enqueueRevalidation]) {
+ m.mockReset();
+ }
+ fetch.mockResolvedValue(new Response(null, { status: 204 }));
+ console.error = mock();
+ });
+
+ afterEach(() => {
+ console.error = originalConsoleError;
+ });
+
+ it('forwards reads to the cache worker with the https scheme restored', async () => {
+ fetch.mockResolvedValue(Response.json({ value: cacheValue, lastModified: 123 }));
+
+ const response = await handleCacheOutbound(
+ new Request('http://incremental-cache.internal/?key=entry&cacheType=cache'),
+ env()
+ );
+
+ expect(await response.json()).toEqual({ value: cacheValue, lastModified: 123 });
+ // The workerd tier sends the same URL, so both tiers share one edge cache entry.
+ expect((fetch.mock.calls[0]?.[0] as Request).url).toBe(
+ 'https://incremental-cache.internal/?key=entry&cacheType=cache'
+ );
+ });
+
+ it('maps write paths onto the service binding RPC methods', async () => {
+ await handleCacheOutbound(
+ post('/set', { key: 'entry', value: cacheValue, cacheType: 'cache' }),
+ env()
+ );
+ expect(set).toHaveBeenCalledWith('entry', cacheValue, 'cache');
+
+ await handleCacheOutbound(post('/delete', { key: 'entry' }), env());
+ expect(remove).toHaveBeenCalledWith('entry');
+
+ await handleCacheOutbound(post('/write-tags', { tags: ['content'] }), env());
+ expect(writeTags).toHaveBeenCalledWith(['content']);
+
+ await handleCacheOutbound(post('/queue', { msg: revalidationMessage }), env());
+ expect(enqueueRevalidation).toHaveBeenCalledWith(revalidationMessage);
+ });
+
+ it('returns 404 for unknown paths', async () => {
+ const response = await handleCacheOutbound(post('/unknown', {}), env());
+
+ expect(response.status).toBe(404);
+ });
+
+ it('returns 502 when the cache worker fails', async () => {
+ set.mockRejectedValue(new Error('service unavailable'));
+
+ const response = await handleCacheOutbound(
+ post('/set', { key: 'entry', value: cacheValue }),
+ env()
+ );
+
+ expect(response.status).toBe(502);
+ });
+
+ it('returns 503 when the service binding is missing', async () => {
+ const response = await handleCacheOutbound(
+ new Request('http://incremental-cache.internal/?key=entry'),
+ {} as never
+ );
+
+ expect(response.status).toBe(503);
+ });
+});
diff --git a/packages/gitbook/openNext/customWorkers/containerOutbound.ts b/packages/gitbook/openNext/customWorkers/containerOutbound.ts
new file mode 100644
index 000000000..2b7185df8
--- /dev/null
+++ b/packages/gitbook/openNext/customWorkers/containerOutbound.ts
@@ -0,0 +1,85 @@
+import type {
+ CacheEntryType,
+ CacheValue,
+ NextModeTagCacheWriteInput,
+ QueueMessage,
+} from '@opennextjs/aws/types/overrides.js';
+
+import {
+ CACHE_PATH,
+ type DeletePayload,
+ type QueuePayload,
+ type SetPayload,
+ type WriteTagsPayload,
+} from '../container/protocol';
+
+export type CacheWorkerBinding = {
+ fetch(request: Request): Promise;
+ set(
+ key: string,
+ value: CacheValue,
+ cacheType?: CacheType
+ ): Promise;
+ delete(key: string): Promise;
+ writeTags(tags: NextModeTagCacheWriteInput[]): Promise;
+ enqueueRevalidation(msg: QueueMessage): Promise;
+};
+
+export type ContainerOutboundEnv = {
+ NEXT_INC_CACHE_WORKER: CacheWorkerBinding;
+};
+
+const noContent = (): Response => new Response(null, { status: 204 });
+
+/**
+ * Handles the requests the container makes to the virtual cache host. It runs in the Workers
+ * runtime, so it can reach the cache worker through the service binding the container cannot see.
+ */
+export async function handleCacheOutbound(
+ request: Request,
+ env: ContainerOutboundEnv
+): Promise {
+ const worker = env.NEXT_INC_CACHE_WORKER;
+ if (!worker) {
+ console.error('Missing NEXT_INC_CACHE_WORKER service binding');
+ return new Response('Cache worker unavailable', { status: 503 });
+ }
+
+ const url = new URL(request.url);
+
+ try {
+ switch (url.pathname) {
+ case CACHE_PATH.read: {
+ // Rebuild the request the workerd tier sends, down to the scheme, so both tiers
+ // share one entry in the cache worker's edge cache.
+ url.protocol = 'https:';
+ return await worker.fetch(new Request(url));
+ }
+ case CACHE_PATH.set: {
+ const { key, value, cacheType } = (await request.json()) as SetPayload;
+ await worker.set(key, value, cacheType);
+ return noContent();
+ }
+ case CACHE_PATH.delete: {
+ const { key } = (await request.json()) as DeletePayload;
+ await worker.delete(key);
+ return noContent();
+ }
+ case CACHE_PATH.writeTags: {
+ const { tags } = (await request.json()) as WriteTagsPayload;
+ await worker.writeTags(tags);
+ return noContent();
+ }
+ case CACHE_PATH.queue: {
+ const { msg } = (await request.json()) as QueuePayload;
+ await worker.enqueueRevalidation(msg);
+ return noContent();
+ }
+ default:
+ return new Response('Not found', { status: 404 });
+ }
+ } catch (error) {
+ console.error('Cache outbound handler failed', url.pathname, error);
+ return new Response('Cache worker error', { status: 502 });
+ }
+}
diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc
new file mode 100644
index 000000000..bffa3971f
--- /dev/null
+++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc
@@ -0,0 +1,58 @@
+{
+ "main": "container.ts",
+ "name": "gitbook-open-v2-container",
+ "compatibility_date": "2026-06-14",
+ "compatibility_flags": [
+ "nodejs_compat",
+ "allow_importable_env",
+ "global_fetch_strictly_public",
+ ],
+ "observability": {
+ "enabled": false,
+ },
+ "env": {
+ "dev": {
+ "vars": {
+ "STAGE": "dev",
+ "CONTAINER_INSTANCES": "1",
+ "OPEN_NEXT_REQUEST_ID_HEADER": "true",
+ // Forwarded into the container by NextServerContainer.envVars. Note that the
+ // container cannot reach this host itself: anything the app fetches server-side
+ // (icons, assets) must resolve to a public URL, so build with the env loaded
+ // (`env-cmd -f ../../.env.local`) to get an absolute GITBOOK_ICONS_URL.
+ "GITBOOK_URL": "http://localhost:8771",
+ },
+ // `containers` is not inherited from the top-level config, it has to be repeated per env.
+ "containers": [
+ {
+ "class_name": "NextServerContainer",
+ "image": "./Dockerfile",
+ // Relative to this config file, so the Dockerfile can COPY .open-next-container.
+ "image_build_context": "../..",
+ "instance_type": "standard-1",
+ "max_instances": 1,
+ },
+ ],
+ "durable_objects": {
+ "bindings": [
+ {
+ "name": "NEXT_SERVER_CONTAINER",
+ "class_name": "NextServerContainer",
+ },
+ ],
+ },
+ "migrations": [
+ {
+ "tag": "v1",
+ "new_sqlite_classes": ["NextServerContainer"],
+ },
+ ],
+ "services": [
+ {
+ "binding": "NEXT_INC_CACHE_WORKER",
+ "service": "gitbook-open-v2-do-dev",
+ },
+ ],
+ },
+ },
+}
diff --git a/packages/gitbook/openNext/customWorkers/do.ts b/packages/gitbook/openNext/customWorkers/do.ts
index dbe062862..87301a1e9 100644
--- a/packages/gitbook/openNext/customWorkers/do.ts
+++ b/packages/gitbook/openNext/customWorkers/do.ts
@@ -1,15 +1,17 @@
-import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
import type {
CacheEntryType,
CacheValue,
NextModeTagCacheWriteInput,
+ QueueMessage,
} from '@opennextjs/aws/types/overrides.js';
import { getTagsFromValue } from '@opennextjs/aws/utils/cache.js';
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
+import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
// @ts-ignore Generated by the Cloudflare build.
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
import { GitbookIncrementalCache } from '../incrementalCache/incrementalCache';
+import queue from '../queue/middleware';
import tagCache from '../tagCache/middleware';
type CacheWorkerEnv = {
@@ -185,6 +187,16 @@ export default class IncrementalCacheWorker extends WorkerEntrypoint {
+ await this.#runRpcOperation(async () => {
+ await queue.send(msg);
+ });
+ }
+
async #runRpcOperation(operation: () => Promise): Promise {
await runWithCloudflareRequestContext(
new Request('https://incremental-cache.internal'),
diff --git a/packages/gitbook/openNext/customWorkers/middleware.js b/packages/gitbook/openNext/customWorkers/middleware.js
index 0a519d278..48b0958ff 100644
--- a/packages/gitbook/openNext/customWorkers/middleware.js
+++ b/packages/gitbook/openNext/customWorkers/middleware.js
@@ -43,13 +43,20 @@ export default class extends WorkerEntrypoint {
return reqOrResp;
}
+ // `container` routes to the Next server running inside a Cloudflare Container,
+ // `worker` (the default) to the workerd server.
+ const serverWorker =
+ this.env.SERVER_TIER === 'container'
+ ? this.env.CONTAINER_WORKER
+ : this.env.DEFAULT_WORKER;
+
if (this.env.STAGE !== 'preview') {
// https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity
reqOrResp.headers.set(
'Cloudflare-Workers-Version-Overrides',
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
);
- const response = await this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
+ const response = await serverWorker?.fetch(reqOrResp, {
redirect: 'manual',
cf: {
cacheEverything: false,
diff --git a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
index 5323b8a39..cd4060648 100644
--- a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
+++ b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
@@ -25,6 +25,8 @@
"NEXT_PRIVATE_DEBUG_CACHE": "true",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"GITBOOK_URL": "http://localhost:8771",
+ // Set to "container" to route to gitbook-open-v2-container-dev instead.
+ "SERVER_TIER": "container",
},
"r2_buckets": [
{
@@ -45,6 +47,10 @@
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-dev",
},
+ {
+ "binding": "CONTAINER_WORKER",
+ "service": "gitbook-open-v2-container-dev",
+ },
],
},
"preview": {
diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json
index b738635c0..feb531057 100644
--- a/packages/gitbook/package.json
+++ b/packages/gitbook/package.json
@@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"@base-ui/react": "catalog:",
+ "@cloudflare/containers": "^0.3.7",
"@cloudflare/workers-types": "^5.20260716.1",
"@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*",
@@ -127,11 +128,14 @@
"check:css-browser-compatibility:local": "bun run check:css-browser-compatibility --local origin/main",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "bun run generate:assets && GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
+ "build:container": "bun run generate:assets && GITBOOK_RUNTIME=cloudflare open-next build --config-path ./open-next.container.config.ts && rm -rf .open-next-container && mv .open-next .open-next-container",
+ "build:all": "bun run build:container && GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build --skipNextBuild",
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
- "dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
+ "dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9231 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"profile:cf:memory": "bun run build:cloudflare && bun ./scripts/profile-opennext-memory.ts",
"dev:cf:cache": "wrangler dev --env dev --config ./openNext/customWorkers/doWrangler.jsonc",
+ "dev:cf:container": "wrangler dev --port 8773 --env dev --config ./openNext/customWorkers/containerWrangler.jsonc",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts e2e/select.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"e2e-style-perf": "playwright test e2e/style-perf.spec.ts --project=chromium --reporter=list",
diff --git a/packages/gitbook/src/lib/data/cloudflare.ts b/packages/gitbook/src/lib/data/cloudflare.ts
index 333bc25dd..6f53e87c7 100644
--- a/packages/gitbook/src/lib/data/cloudflare.ts
+++ b/packages/gitbook/src/lib/data/cloudflare.ts
@@ -10,5 +10,10 @@ export function getCloudflareContext() {
return null;
}
- return getCloudflareContextOpenNext();
+ try {
+ return getCloudflareContextOpenNext();
+ } catch {
+ // The container tier shares the Cloudflare build, but runs on plain Node with no bindings.
+ return null;
+ }
}
diff --git a/packages/gitbook/src/lib/waitUntil.ts b/packages/gitbook/src/lib/waitUntil.ts
index 359e0100e..a3c3e47f3 100644
--- a/packages/gitbook/src/lib/waitUntil.ts
+++ b/packages/gitbook/src/lib/waitUntil.ts
@@ -23,6 +23,13 @@ export async function waitUntil(promise: Promise) {
context.ctx.waitUntil(promise);
return;
}
+
+ // The container tier shares the Cloudflare build but runs as a long-lived Node server,
+ // where a detached promise keeps running after the response is sent.
+ promise.catch((error) => {
+ console.error('Ignored error in waitUntil', error);
+ });
+ return;
}
await promise.catch((error) => {
diff --git a/turbo.json b/turbo.json
index d9031e75c..3d57eed25 100644
--- a/turbo.json
+++ b/turbo.json
@@ -25,6 +25,22 @@
"dependsOn": ["^build", "generate"],
"outputs": [".next/**", "!.next/cache/**", "dist", ".open-next/**"]
},
+ // Build the package for the Cloudflare container tier
+ "build:container": {
+ "dependsOn": ["^build", "generate"],
+ "outputs": [".next/**", "!.next/cache/**", "dist", ".open-next-container/**"]
+ },
+ // Build both the Cloudflare workers and the container, sharing a single `next build`
+ "build:all": {
+ "dependsOn": ["^build", "generate"],
+ "outputs": [
+ ".next/**",
+ "!.next/cache/**",
+ "dist",
+ ".open-next/**",
+ ".open-next-container/**"
+ ]
+ },
// Check the package for type errors
"typecheck": {
"dependsOn": ["^typecheck", "build"]