article
Astro SSR on Cloudflare Workers: one build, two 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.
Routes the request to the Worker bound to the hostname.
Astro SSR renders the page on demand. Production and preview are the same build, switched by one variable, STORYBLOK_PREVIEW.
Production reads published content with a public token. Preview reads draft content with a preview token.
preview.blog.mikepenz.dev runs the same code with STORYBLOK_PREVIEW=1, the Storyblok bridge for live updates, and the token gate described below.
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:
@storyblok/astrov11 has no Astro integration, so there is nothing to add tointegrations. Components are registered withdefineStoryblokBlocksinsrc/storyblok.ts, and the API client is@storyblok/api-client.With
trailingSlash: 'always', Astro answered the slashless form of a URL with a 404 before any middleware could redirect it. The old site's/a/<slug>redirected to/a/<slug>/, so the config uses'ignore'and a middleware sends the 301.
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',
});{
"name": "blog-mikepenz-dev",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true, "logs": { "invocation_logs": false } }
}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 |
|---|---|---|---|
| Public access token, published content only | Storyblok: Space > Settings > Access Tokens, type Public | production Worker |
| Preview access token, reads drafts | Same page, type Preview. Use a different token than the public one | preview Worker, local dev |
|
| Space settings | both Workers |
| Authorizes the purge webhook (part 4) |
| production Worker |
| The zone whose cache gets purged | Dashboard > your zone > Overview, right column | production Worker |
| API token that can only purge cache | Dashboard > My Profile > API Tokens > Create, permission Zone > Cache Purge > Purge, resources: your one zone | production Worker |
| Deploy token for wrangler | Dashboard > API Tokens (below) | deploy shell only |
| 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.
Mount it outside the project
Add a local .env destination at an absolute path outside the repo, for me ~/.config/blog.mikepenz.dev/env. ls -l shows a p for pipe.
Pass it to the container
./c below adds --env-file for that path, so commands see the values as environment variables and the container never sees a file.
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:
./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:
put() { ./c sh -c "printf %s \"\$$1\" | npx wrangler secret put $1 --name $2"; }
put STORYBLOK_PREVIEW_TOKEN blog-mikepenz-dev-previewTwo 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.
Deploy the preview Worker
./c npx wrangler deploy --name blog-mikepenz-dev-preview --var STORYBLOK_PREVIEW:1Then it sets the secrets STORYBLOK_PREVIEW_TOKEN and STORYBLOK_REGION on that Worker.
Deploy the production Worker
./c npx wrangler deploy --name blog-mikepenz-dev --var STORYBLOK_PREVIEW:0Set the production secrets
STORYBLOK_TOKEN, STORYBLOK_REGION, WEBHOOK_SECRET, CLOUDFLARE_ZONE_ID and CLOUDFLARE_PURGE_TOKEN, piped from the container's environment into wrangler secret put, so values never reach the terminal.
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 ...):
$ ./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:
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:
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;
}Fail closed. No valid cookie or token means 403 on every path and method, including
/rss.xml,/api/*and the live-preview POST. Any error in the check, or a missing preview token, denies. Do not use the SDK'sisInEditor()for this: it checks that the parameters exist, not the token.Session cookie.
__Host-pv(Secure; HttpOnly; SameSite=None; Partitioned, 8 hours) keeps clicks and live updates working without a fresh token. Its HMAC key is the preview token, so rotating the token ends every session.Partitionedis supported from Chrome 114, Firefox 141 and Safari 26.2 per CHIPS; I have not tried the editor iframe in Safari or Firefox yet.Replay. The token is a bearer value: a valid editor URL reads previews for the rest of its hour. That is the price of having no login.
Future skew. The gate also rejects timestamps more than 300 seconds in the future, so a far-future token cannot live forever.
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:
$ 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.