Deployment
TVfolio is a static site with one on-demand route. Every page in src/pages/ prerenders to flat HTML; POST /api/contact/ sets prerender = false because a Resend call needs a secret and a secret cannot live in a static page. That single route is the only reason an adapter is installed, and it shapes everything in this chapter.
The build
SITE_URL=https://your.domain pnpm build
The build emits 23 HTML pages plus five generated endpoints (robots.txt, llms.txt, rss.xml, cv.txt, cv.pdf) and the two sitemap files. The sitemap lists 22 URLs — the 404 is filtered out, as is the dev-only /examples/ catalog, because both are marked noindex in the markup and a sitemap entry would contradict that.
The split dist/, which is the thing that surprises people
The Cloudflare adapter does not leave dist/ flat:
dist/
├── client/ every page, every asset — what the edge serves
└── server/ the Worker bundle
If you have deployed an Astro site to Netlify before, this is the difference: that adapter left dist/ flat and put its bundle in .netlify/. Here the two live side by side under dist/, and wrangler.jsonc points its static-asset binding at dist/client specifically. Pointing it at dist would publish the Worker bundle as public files.
One small thing to know if you read the config file itself: the header comment in wrangler.jsonc describes the Worker as landing in dist/_worker.js/. The actual build writes dist/server/, and the assets.directory setting below it is correct. The comment is stale, not the config.
Two settings in astro.config.mjs worth understanding
The adapter is configured with two non-default options, and both are deliberate:
adapter: cloudflare({
imageService: "compile",
prerenderEnvironment: "node",
}),
imageService: "compile" optimizes images with sharp at build time, the way every other host does. The adapter’s default is Cloudflare’s Images binding — a paid, per-transform runtime service, and pointless here, because every <Image> sits on a prerendered page and so has a final width the build already knows.
prerenderEnvironment: "node" prerenders in Node rather than the adapter’s default workerd. The routes are built by sharp (a native module) and by @js/cv’s PDF writer, neither of which is a Worker’s job.
Below them, session: false is one line that matters more than it looks: left at its default the adapter binds a SESSION KV namespace and auto-provisions it on deploy. There are no sessions anywhere in this site — the one dynamic route is a stateless form POST — so this is the difference between a Worker with a store nothing reads and one without.
Deploying to Cloudflare Workers
pnpm exec wrangler login # once
SITE_URL=https://your.domain pnpm deploy # build (gates armed) + wrangler deploy
The deploy script sets DEPLOY_ENV=production for you, which arms both placeholder gates — the SITE_URL check in astro.config.mjs and the socials check in siteData.json.ts. That is intentional: pnpm deploy is the command that can reach a live domain, so it is the command that refuses to ship demo values.
Then set the secrets, once per Worker:
pnpm exec wrangler secret put RESEND_API_KEY
pnpm exec wrangler secret put CONTACT_TO # optional, defaults to siteData.author.email
pnpm exec wrangler secret put CONTACT_FROM # optional, needs a verified Resend domain
Build values versus request-time secrets
This is the distinction that causes the most confusion, so it is worth stating plainly.
SITE_URL is a build-time value. It is baked into canonical URLs, Open Graph tags, JSON-LD, the sitemap, robots.txt and llms.txt — all of which are HTML and text files written during the build. It belongs in the build’s environment, and it is deliberately not in wrangler.jsonc.
RESEND_API_KEY is the opposite. It is read at request time by the Worker, from process.env. Baking it in at build time would be exactly wrong — import.meta.env would inline whatever was present on the build machine, which is usually nothing. That is why the endpoint reads process.env through a small helper rather than import.meta.env.
The three load-bearing wrangler settings
wrangler.jsonc is commented in full, but three of its settings are correctness rather than taste:
assets.directory must be ./dist/client — the split-build reason above.
nodejs_compat in compatibility_flags is what lets the endpoint read process.env. From the pinned compatibility date the runtime populates process.env from vars and secrets, so the same code reads a secret on Cloudflare, Netlify or a plain Node server with no host-specific branch.
run_worker_first: ["/api/*"] is the one that will cost you an afternoon if it goes missing, because curl does not reproduce the failure. Cloudflare’s asset router sees a request before the Worker, and not_found_handling: "404-page" makes it claim every browser navigation that matches no file — which is exactly what a <form method="post"> submit is, since it carries Sec-Fetch-Mode: navigate. Measured on this theme’s own deploy: the contact POST came back 405 Method Not Allowed from the asset router, which serves GET and HEAD only, while the same POST from curl reached the Worker and returned its 303. One line, and it is the difference between a working contact form and one that fails for every real visitor while every scripted check passes.
If you ever change the contact endpoint’s path, change this glob with it.
Custom domains
workers_dev: true serves the site on <name>.<subdomain>.workers.dev. To move to a custom domain, add a route and rebuild with a matching SITE_URL:
"routes": [{ "pattern": "your.domain", "custom_domain": true }]
The domain is baked into the HTML, so changing the route alone is not enough — canonical tags, OG URLs and the sitemap would all still name the old host.
Deploying somewhere else
Swapping hosts is one line plus its import:
pnpm add @astrojs/netlify
import netlify from "@astrojs/netlify";
// …
adapter: netlify(),
Vercel and Node have their own adapters and the same shape. Whichever host you use, two things carry over: SITE_URL goes in the build environment, and RESEND_API_KEY goes in the runtime environment. Hosts other than Netlify and Vercel also need DEPLOY_ENV=production set in the production build environment — those two are the only ones isProductionDeploy() can detect on their own, and the gate is only as good as the signal it can see. Set it in production and nowhere else, or your preview deploys will start failing on the placeholder too.
Going fully static
If you do not want a contact endpoint at all, the theme drops to a pure static build with no adapter whatsoever:
rm -rf src/pages/api/
pnpm remove @astrojs/cloudflare
Then delete the adapter, session and (optionally) prerenderEnvironment lines from astro.config.mjs. dist/ goes flat again and every route is a file, deployable to any static host — S3, GitHub Pages, Netlify’s CDN, anything.
You will also want to edit Sections/Contact/ContactForm.astro, which posts to the route you just removed. The page already prints a direct mailto: under the button, so the least-work version is to keep that and drop the form.
Before you deploy
The theme’s own launch checklist, in the order the work actually falls:
SITE_URLin your host’s build environment. The gate fails a production build without it.public/og.jpg— replace the placeholder with a real 1200×630 social image.src/config/siteData.json.ts— name, author block, real profile URLs insocials, andsameAsonce those are real.src/config/legalData.json.ts— the terms and privacy copy are placeholders.- Replace the demo copy — the table at the end of Configuration lists every file that holds persona prose.
- Decide about the three fonts. The design is drawn in Space Mono, JetBrains Mono and Courier Prime, and none of them ships. See Typography.
- Favicons —
public/favicon.svgandpublic/favicon.ico. - The contact form — set
RESEND_API_KEY, or accept that the form validates and then tells the reader to mail you directly. - Delete the dev catalog —
src/components/Sections/UiCatalog/andsrc/pages/examples/. This one has a measured cost, covered next.
Deleting the catalog, and what it actually saves
/examples/ui is dev-only: its getStaticPaths returns [] in a production build, so no HTML ships. But that guard stops the pages, not the assets — the demo primitives’ styles and bundled scripts still reach the shared stylesheet and dist/_astro/. Measured on this repo, building with both directories moved aside and then restored:
| with catalog | without | |
|---|---|---|
shared BaseLayout stylesheet |
85,710 B | 66,339 B (−22.6%) |
@keyframes in that stylesheet |
94 | 25 |
JS chunks in dist/_astro/ |
24 | 6 |
So deleting it drops 69 of the 94 keyframes and 18 of the 24 script chunks. Keep the catalog while you are still picking primitives; delete it before launch.