Hello.

article

Astro SSR on Cloudflare Workers: one build, two Workers

2026-11-06 · Storyblok, Astro and Cloudflare Workers

TL;DR One Astro build runs as two Cloudflare Workers: published content on the public host, drafts on a preview host. Protect the preview host with Storyblok's signed _storyblok_tk check, not Cloudflare Access: the Access login page refuses to load inside the Visual Editor's iframe.

This is part 2 of the series on moving this blog from a Nuxt 2 static export to Astro on Cloudflare Workers. Part 1 is the overview; this part is the structure underneath: the Workers, the dev loop and the preview gate.

What runs where

Astro 7 renders every page on demand inside a Cloudflare Worker (output: 'server', with @astrojs/cloudflare) and fetches content from the Storyblok Content Delivery API. blog-mikepenz-dev serves published content on blog.mikepenz.dev; blog-mikepenz-dev-preview serves drafts on preview.blog.mikepenz.dev.

Request path, production and preview

Requests the page. In the Visual Editor it is an iframe inside Storyblok.

1 / 5

The button below calls /api/edge and shows which Cloudflare data center rendered this page for you.

Live request to this site's own edge

Adapter and config

Two details of the config came from failures, not from the docs:

import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'server',
  adapter: cloudflare({ imageService: 'passthrough' }),
  build: { inlineStylesheets: 'never' }, // CSP: no inline <style>/<script>
  session: false, // no KV session binding
  trailingSlash: 'ignore',
  vite: { build: { assetsInlineLimit: 0 }, define: { __BUILD_ID__: JSON.stringify(Date.now().toString(36) + Math.random().toString(36).slice(2, 8)) } },
  site: 'https://blog.mikepenz.dev',
});

Comments are removed. __BUILD_ID__ gives every build its own id for the cache key in part 4. Invocation logs are off because a fetch invocation log message is <Method> <URL>, and some URLs here carry secrets in the query string: the webhook ?key= and the preview token.

Secrets: what you need and where each one lives

The site needs eight values. Six end up as Worker secrets, two only exist in the shell that deploys. Names only, never values:

Name

What it is

Where you create it

Lives on

STORYBLOK_TOKEN

Public access token, published content only

Storyblok: Space > Settings > Access Tokens, type Public

production Worker

STORYBLOK_PREVIEW_TOKEN

Preview access token, reads drafts

Same page, type Preview. Use a different token than the public one

preview Worker, local dev

STORYBLOK_REGION

eu, us and so on; the region of your space

Space settings

both Workers

WEBHOOK_SECRET

Authorizes the purge webhook (part 4)

openssl rand -hex 32

production Worker

CLOUDFLARE_ZONE_ID

The zone whose cache gets purged

Dashboard > your zone > Overview, right column

production Worker

CLOUDFLARE_PURGE_TOKEN

API token that can only purge cache

Dashboard > My Profile > API Tokens > Create, permission Zone > Cache Purge > Purge, resources: your one zone

production Worker

CLOUDFLARE_API_TOKEN

Deploy token for wrangler

Dashboard > API Tokens (below)

deploy shell only

CLOUDFLARE_ACCOUNT_ID

Account wrangler deploys to

Dashboard > Workers & Pages > Overview

deploy shell only

Each Worker gets only what it needs. The production Worker holds no preview token, so it cannot read drafts, and the preview Worker holds no Cloudflare API token. The deploy token is the most valuable value in the table, because whoever holds it can overwrite the live Worker. It never becomes a Worker secret, and the purge token is a separate token for exactly that reason.

The Storyblok MCP from part 1 is not in the table: it signs in with OAuth in the browser, so no token goes anywhere near it.

Keep the values in a 1Password Environment

I do not keep these in a .env or .dev.vars file. They live in a 1Password Environment named blog.mikepenz.dev and are mounted as a local .env file that is a named pipe: 1Password serves the values to whoever reads it, and nothing sits on disk.

Step 1 of 3

Create it and add the eight names

In the 1Password app: Environments, new Environment, add the variables from the table. Mac or Linux with the desktop app; I have not tried Windows.

Two things to know. The mount must stay outside the project: with the pipe inside the bind-mounted folder, my container build hung until I killed it. And it is hygiene, not isolation: while 1Password is unlocked, any process of yours can read the pipe.

You do not need 1Password for any of this. The code reads plain environment variables and Worker secrets, so a shell profile, direnv or your CI's secret store works. If you use a .dev.vars file anyway, gitignore .dev.vars* with a star, since my own slip was a .dev.vars. with a trailing dot, covered further down.

The two Cloudflare tokens

wrangler's browser login cannot call back into a container, so deploys use an API token instead. Create it at Dashboard > My Profile > API Tokens. The one I use is owned by the account and carries Workers Scripts edit, Zone read, Zone Settings edit and Cache Purge edit, and it does not touch DNS. Workers Scripts edit plus Zone read is probably the minimum, so start there and add permissions only when a deploy fails.

The purge token is the narrow one from the table, on its own. To check a token without printing it, ask Cloudflare and print only the status:

bash
./c node -e 'fetch(`https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/tokens/verify`,{headers:{authorization:`Bearer ${process.env.CLOUDFLARE_API_TOKEN}`}}).then(r=>r.json()).then(j=>console.log(j.success,j.result?.status))'

That endpoint is for account-owned tokens. A token owned by your user verifies at /client/v4/user/tokens/verify, and mine returned 401 on that one.

Put secrets on a Worker without printing them

The deploy script defines one helper. It reads the variable inside the container and pipes it straight into wrangler secret put, so the value never reaches the terminal or the shell history:

bash
put() { ./c sh -c "printf %s \"\$$1\" | npx wrangler secret put $1 --name $2"; }
put STORYBLOK_PREVIEW_TOKEN blog-mikepenz-dev-preview

Two Workers from one build

The adapter writes dist/server/wrangler.json without the env blocks, so one config cannot describe production and preview. scripts/deploy.sh deploys the same build twice instead, with a different --name and --var STORYBLOK_PREVIEW:

Step 1 of 4

Build once

./c npx astro build produces one dist/. ./c is the container wrapper from below.

The production Worker holds only the public Storyblok token, so it cannot read drafts. The preview Worker holds no Cloudflare API token. The deploy token (CLOUDFLARE_API_TOKEN) never becomes a Worker secret, because whoever holds it can overwrite the live Worker. A dry run shows what gets uploaded (module table cut at ...):

wrangler deploy --dry-run, preview Worker (trimmed)
$ ./c npx wrangler deploy --dry-run --name blog-mikepenz-dev-preview --var STORYBLOK_PREVIEW:1
⛅️ wrangler 4.158.0
Using redirected Wrangler configuration.
 - Configuration being used: "dist/server/wrangler.json"
 - Original user's configuration: "wrangler.jsonc"
 - Deploy configuration file: ".wrangler/deploy/config.json"
Attaching additional modules:
...
│ Total (23 modules)                                        │      │ 2933.89 KiB │
✨ Read 22 files from the assets directory /app/dist/client
Total Upload: 2970.20 KiB / gzip: 499.34 KiB
env.ASSETS                              Assets                    
env.STORYBLOK_PREVIEW ("(hidden)")      Environment Variable      
--dry-run: exiting now.

I attached both custom domains once in the Cloudflare dashboard, and I run every deploy myself. The domains survive redeploys because the script passes no --domain.

The container dev loop

There is no Node on my host. Every JavaScript command goes through ./c, a short shell wrapper around Apple's container CLI:

bash
exec container run --rm $T -v "$PWD":/app -w /app -m 4G -p 4321:4321 -p 8787:8787 -v /etc/ssl/cert.pem:/etc/ssl/cert.pem:ro -e SSL_CERT_FILE=/etc/ssl/cert.pem $E blog-node "$@"

$T is -it only when stdin is a terminal, so scripts and non-interactive callers work too. $E passes the secrets from a 1Password Environment mounted outside the project, so the container only sees environment variables. blog-node is built from node:26-slim.

Why Cloudflare Access did not work

The preview host serves drafts, and at first it did so publicly, along with its workers.dev twin. Nothing linked to it, but noindex is not access control.

The first fix was a Worker-level Cloudflare Access policy, which covers routes, Custom Domains and workers.dev. After a few minutes every path returned a 302 to the Access login, and the Visual Editor broke: Chrome showed a blank "refused to connect" page. The login page sends x-frame-options: DENY and frame-ancestors 'none', so it cannot render in the editor's iframe, and the Worker never runs for that request, so it cannot change the headers.

No Access variant helps. Policy selectors cannot match a query parameter, so a bypass would expose whole paths. Service tokens need request headers, which an iframe cannot add. Logging in first in a normal tab leaves the Access cookie third-party inside the iframe, which Safari blocks and Firefox partitions in its strict and private modes (CHIPS).

The signed _storyblok_tk gate

Storyblok already answers "did this request come from my editor?". The Visual Editor appends _storyblok_tk[space_id], [timestamp] and [token] to the preview URL. Per the Visual Editor docs, the token is the SHA-1 of space_id:preview_token:timestamp, valid when it matches and the timestamp is less than an hour old, checked on the server. The gate runs in the preview Worker's middleware, right after the security headers:

typescript
export async function tkValid(url: URL, o: GateConfig): Promise<boolean> {
  const p = url.searchParams;
  const sid = p.get('_storyblok_tk[space_id]'), ts = p.get('_storyblok_tk[timestamp]'), tok = p.get('_storyblok_tk[token]');
  if (!o.spaceId || !o.tokens.length || !sid || !ts || !tok || sid !== o.spaceId || !/^\d{1,12}$/.test(ts)) return false;
  const t = Number(ts);
  if (t <= o.now - TK_MAX_AGE || t > o.now + TK_FUTURE_SKEW) return false;
  let ok = false;
  for (const pt of o.tokens) ok = same(await sha1(`${sid}:${pt}:${ts}`), tok.toLowerCase()) || ok; // no early exit
  return ok;
}

A unit test (56 cases) and an integration test against the built Worker (46 cases, scripts/test-preview-gate-live.mjs) run in the container with a fake token; the latter asserts that denied requests never reach Storyblok. A request without credentials, headers trimmed:

unauthenticated request to the preview host (trimmed)
$ curl -sI https://preview.blog.mikepenz.dev/
HTTP/2 403
content-type: text/plain; charset=utf-8
cache-control: no-store
vary: Cookie
x-robots-tag: noindex, nofollow
content-security-policy: default-src 'self'; ...; frame-ancestors https://app.storyblok.com
server: cloudflare

No redirect, and frame-ancestors allows only Storyblok's editor. When you switch from Access, remember that a curl proves nothing about the gate while Access is on: remove it, curl every path at once, and be ready to turn it back on if anything returns more than a 403.

The token that did not match

With Access gone, the Visual Editor loads the preview with live updates. On the first try it showed "preview requires the Storyblok editor": the space had two preview tokens and the wrong one was configured. Recomputing the hash from the editor's URL against both configured tokens gave zero matches. Fail closed did its job: a wrong token gets a refusal, not a draft.

One more slip

Besides the public preview host: a file named .dev.vars. (trailing dot) holding all the secrets was picked up by a git add. It was never committed and the objects were pruned, but the old .gitignore did not cover it. It now ignores .dev.vars* except the example file.

Part 3 moves the 20 old stories into the new content model.