Skip to content
AstroCraft Docs
On this theme

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:

  1. SITE_URL in your host’s build environment. The gate fails a production build without it.
  2. public/og.jpg — replace the placeholder with a real 1200×630 social image.
  3. src/config/siteData.json.ts — name, author block, real profile URLs in socials, and sameAs once those are real.
  4. src/config/legalData.json.ts — the terms and privacy copy are placeholders.
  5. Replace the demo copy — the table at the end of Configuration lists every file that holds persona prose.
  6. Decide about the three fonts. The design is drawn in Space Mono, JetBrains Mono and Courier Prime, and none of them ships. See Typography.
  7. Faviconspublic/favicon.svg and public/favicon.ico.
  8. The contact form — set RESEND_API_KEY, or accept that the form validates and then tells the reader to mail you directly.
  9. Delete the dev catalogsrc/components/Sections/UiCatalog/ and src/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.

NEXT STEPContent Collections