Deploy container server tier with CI integration for preview, staging, and production environments

This commit is contained in:
Nicolas Dorseuil
2026-08-31 14:43:04 +02:00
parent 4e93131235
commit 60bc6914f9
8 changed files with 242 additions and 68 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"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`).
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. Build it with `bun run build:all` and run it locally with `bun run dev:cf:container`.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Deploy the container server tier from CI to preview, staging and production. Preview and staging serve their traffic from the container; production deploys it but keeps serving from the workerd tier.
@@ -21,63 +21,63 @@ outputs:
description: 'Deployment URL'
value: ${{ steps.deploy_middleware.outputs.deployment-url }}
runs:
using: 'composite'
steps:
- id: wrangler_status
name: Check wrangler deployment status
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
using: 'composite'
steps:
- id: wrangler_status
name: Check wrangler deployment status
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
# This step is used to get the version ID that is currently deployed to Cloudflare.
- id: extract_current_version
name: Extract current version
shell: bash
run: |
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
# This step is used to get the version ID that is currently deployed to Cloudflare.
- id: extract_current_version
name: Extract current version
shell: bash
run: |
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- id: deploy_server
name: Deploy server to Cloudflare at 0%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- id: deploy_server
name: Deploy server to Cloudflare at 0%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
name: Deploy middleware to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
name: Deploy middleware to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
@@ -63,8 +63,10 @@ runs:
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
# `build:all` shares a single `next build` between the workerd tier (.open-next) and the
# container tier (.open-next-container), so both are built from the same bundle.
- name: Build worker
run: bun run turbo build:cloudflare
run: bun run turbo build:all
env:
GITBOOK_RUNTIME: cloudflare
VERCEL_TARGET_ENV: ${{ inputs.environment }}
@@ -94,6 +96,19 @@ runs:
environment: ${{ inputs.environment }}
command: ${{ format('deploy --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }}
# `versions upload` never builds or pushes the container image, so the container tier uses
# `deploy` like the DO worker. It runs after the DO worker (which it binds to) and before
# the middleware (which binds to it).
- name: Deploy the container worker
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.122.0'
environment: ${{ inputs.environment }}
command: deploy --config ./packages/gitbook/openNext/customWorkers/containerWrangler.jsonc
- id: upload_server
name: Upload server to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
+4
View File
@@ -0,0 +1,4 @@
# The image only needs the `@opennextjs/aws` node bundle; without this the whole package
# (.next, .open-next, public, node_modules) would be sent to the Docker daemon on every deploy.
*
!.open-next-container/server-functions/default
@@ -54,5 +54,135 @@
},
],
},
"preview": {
"vars": {
"STAGE": "preview",
"CONTAINER_INSTANCES": "3",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
},
"containers": [
{
"class_name": "NextServerContainer",
"image": "./Dockerfile",
"image_build_context": "../..",
"instance_type": "standard-1",
"max_instances": 3,
},
],
"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-preview",
},
],
"observability": {
"traces": {
"enabled": true,
"head_sampling_rate": 1,
},
"logs": {
"enabled": true,
"head_sampling_rate": 1,
},
},
},
"staging": {
"vars": {
"STAGE": "staging",
"CONTAINER_INSTANCES": "5",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
},
"containers": [
{
"class_name": "NextServerContainer",
"image": "./Dockerfile",
"image_build_context": "../..",
"instance_type": "standard-1",
"max_instances": 5,
},
],
"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-staging",
},
],
"tail_consumers": [
{
"service": "gitbook-x-staging-tail",
},
],
},
"production": {
"vars": {
"STAGE": "production",
"CONTAINER_INSTANCES": "10",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
},
"containers": [
{
"class_name": "NextServerContainer",
"image": "./Dockerfile",
"image_build_context": "../..",
"instance_type": "standard-1",
// The middleware does not route production traffic here yet (`SERVER_TIER` is
// unset there), this is headroom for when it does.
"max_instances": 10,
},
],
"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-production",
},
],
"tail_consumers": [
{
"service": "gitbook-x-prod-tail",
},
],
},
},
}
@@ -45,17 +45,21 @@ export default class extends WorkerEntrypoint {
// `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;
const isContainerTier = this.env.SERVER_TIER === 'container';
const serverWorker = isContainerTier
? 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}"`
);
// The container worker is deployed, not versioned, so it has no per-version preview
// URL and version affinity does not apply to it — always go through the binding.
if (isContainerTier || this.env.STAGE !== 'preview') {
if (!isContainerTier) {
// 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 serverWorker?.fetch(reqOrResp, {
redirect: 'manual',
cf: {
@@ -25,7 +25,7 @@
"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.
// Unset (or any other value) routes to DEFAULT_WORKER instead.
"SERVER_TIER": "container",
},
"r2_buckets": [
@@ -58,6 +58,7 @@
"STAGE": "preview",
"PREVIEW_HOSTNAME": "TO_REPLACE",
"WORKER_VERSION_ID": "TO_REPLACE",
"SERVER_TIER": "container",
},
"r2_buckets": [
{
@@ -78,6 +79,10 @@
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-preview",
},
{
"binding": "CONTAINER_WORKER",
"service": "gitbook-open-v2-container-preview",
},
],
"durable_objects": {
"bindings": [
@@ -114,6 +119,7 @@
"STAGE": "staging",
"WORKER_VERSION_ID": "TO_REPLACE",
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"SERVER_TIER": "container",
},
"routes": [
{
@@ -144,6 +150,10 @@
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-staging",
},
{
"binding": "CONTAINER_WORKER",
"service": "gitbook-open-v2-container-staging",
},
],
"tail_consumers": [
{
@@ -217,6 +227,12 @@
"binding": "NEXT_INC_CACHE_WORKER",
"service": "gitbook-open-v2-do-production",
},
// Bound but unused: production keeps `SERVER_TIER` unset so it serves from
// DEFAULT_WORKER. Setting the var here is all it takes to switch.
{
"binding": "CONTAINER_WORKER",
"service": "gitbook-open-v2-container-production",
},
],
"tail_consumers": [
{