Dexio / how-we-build-dexio / site
The dexio.wiki stack, and how to build it
The marketing site at dexio.wiki. The app at app.dexio.wiki is a separate build, covered in app-server-and-releases; how agents connect to it is in remote-mcp-server-with-oauth.
The stack
- Astro 7 with
output: "static": every page is finished HTML at build time. - Content is Markdown in git (
content/blog,content/guides), typed with Astro content collections. One file is one URL. - Share cards are drawn at build time with satori and resvg. The home page demo graph is built from the engine on every build.
- Hosting is one AWS CDK stack (Python) in us-east-1: a private S3 bucket behind CloudFront,
ACM, Route 53, and one Lambda for
/api/contact. - Agents write pages in git worktrees and deploy. Cron jobs publish scheduled pages, ping IndexNow and audit Search Console. Nobody edits through a browser.
A full build of 26 pages took 1.63 seconds on 2026-10-01.
Why not Next.js + Payload
Payload is a CMS that installs inside a Next.js app. What it adds is a browser editor for people, plus content in a database. Our site is written by agents, so the editor goes unused and the database costs more than it gives.
- No server and no database. Payload needs Postgres, MongoDB or SQLite and an always-on Node runtime. We serve files from S3.
- Nothing to attack. A Next.js server with a CMS login can be exploited; static files cannot. CVE-2025-55182 (December 2025) was a no-login remote code execution flaw in React Server Components, scored 10 of 10, affecting Next.js.
- Agents work best on files. Diffs, review, revert, searching every page at once, and the batch skill's checks of existing pages and internal links all run against the repo. A CMS API puts all of that behind requests.
- Static HTML is already what search engines want. Lighthouse mobile scored 98 to 100 on 2026-10-01.
- Drafts, scheduled publishing and share cards, the CMS features we would want, already exist here in a few dozen lines each.
Payload would win if a person who will not use git starts editing the site regularly, if the site moves to thousands of pages generated from data, or if it needs app-like pages sharing a backend with something else. Even the second would not force a rewrite: Astro can read content from a database or API at build time.
How to build it
Prerequisites: Node 22, Python 3.12 with uv, the AWS CDK CLI, an AWS account bootstrapped for
CDK in us-east-1, and a Route 53 hosted zone for the domain.
-
Scaffold with
npm create astro@latest, then setastro.config.mjs:export default defineConfig({ site: "https://dexio.wiki", output: "static", compressHTML: false, // compression ate spaces between lines and tags trailingSlash: "ignore", build: { format: "directory" }, // /guides/x/index.html, served as /guides/x/ }); -
Define the content in
src/content.config.ts: aglobloader per folder and a zod schema (title, description, dates,draft, apublishdate for guides,sources). A missing field fails the build, so an agent cannot ship a page without a description. -
Decide what is published in one place (
src/data/blog.ts,src/data/guides.ts). The index, the pages, RSS and the sitemap all call the same getter, so they cannot disagree. Drafts show inastro devand in aDEXIO_DRAFTS=1build, never in a normal one. Scheduling is one function:const now = process.env.DEXIO_NOW ? new Date(process.env.DEXIO_NOW) : new Date(); export function isDue(d?: Date) { return !d || d.valueOf() <= now.valueOf(); } -
Put the head tags in one layout (
src/layouts/Base.astro): title, description, canonical, Open Graph and Twitter tags with a 1200x630 image, an RSS link, and a link toagents.md. Every URL, canonical included, ends in a slash. Page templates add JSON-LD (TechArticleon guides; Organization, WebSite and SoftwareApplication on home). -
Write
src/pages/sitemap.xml.tsby hand from the same getters, plus a fixed list of the other pages. Generatellms.txtfrom a route (src/pages/llms.txt.ts); keepagents.mdandrobots.txtinpublic/. -
Draw share cards in
src/data/card.ts: build the card as a satori element tree, render to SVG with the brand font, rasterize with resvg.src/pages/og/[slug].png.tsemits one PNG per page at build time. -
Write the CDK stack (
infra/site_stack.py):- S3 bucket, all public access blocked, read through origin access control with READ and LIST (LIST makes a missing key a true 404, not a 403).
- ACM certificate for the apex and
www, validated through Route 53, in us-east-1. - CloudFront: price class 100, HTTP/2 and 3, TLS 1.2 minimum, the managed security headers policy, caching optimized, a 404 error page, logs to a bucket kept 400 days.
- A viewer-request CloudFront function that 301s
wwwto the apex, 301s/xto/x/, serves real 301 redirects from a table, and rewrites/x/to/x/index.html. - A and AAAA alias records for both names.
- A
BucketDeploymentfromdist/withprune=Trueand an invalidation of/*.
-
For a form, put a Lambda function URL with IAM auth behind CloudFront at
/api/*, signed by origin access control, with caching off. Store each message in a retained DynamoDB table, then send it through SES with the sender locked by anses:FromAddresscondition. -
Build and deploy:
npm install && npm run build cd infra && uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest tests -q cdk deploy -c domain=dexio.wiki -c hostedZoneId=<zone id> -c siteDir=../distAlways deploy from an up-to-date main. The stack carries the contact API, so a deploy from an old checkout drops its routes.
-
Add the publishing loop as scheduled agent jobs: a daily job that rebuilds and deploys when a scheduled page comes due, an hourly IndexNow ping for new or changed sitemap URLs (the IndexNow key file is served from
public/), and a monthly Search Console audit that rewrites the titles of pages with low click-through.
Verify
curl -sIthe apex,www, a page without its slash and a redirect: expect 200, 301 to the apex, 301 to the slash form, and 301 to the target.- A missing path returns 404 with the 404 page, and
/api/contactwith GET returns 405. - Each page's HTML carries its canonical,
og:imageand description in the first response. - Lighthouse mobile, and Search Console URL Inspection on a new page.
Rollback: revert the commit on main, build and deploy again. The buckets and tables are retained if the stack is ever deleted.
Pitfalls we hit
- Function URLs created since late 2025 need
lambda:InvokeFunctionas well aslambda:InvokeFunctionUrl. CDK's OAC helper grants only the second; every request came back 403 until the stack added the first. - CloudFront error pages apply to every behavior. Mapping 403 to the 404 page hid the API's 403s, which is why the bucket gets LIST instead.
- Origin access control on POST needs the body hash, so the browser form sends
x-amz-content-sha256. - Astro's
redirectsoption writes meta-refresh pages in a static build. Google Ads' site review read one as a deceptive redirect, so redirects live in the CloudFront function as real 301s. - Serving both
/xand/x/as 200 split Search Console's impressions across two URLs per page; the function now 301s to the slash form. - HTML compression joined words across line breaks ("OpenClaw,Grok Bot"), hence
compressHTML: false. - A bare frontmatter date is midnight UTC, so format dates in UTC or they show a day early west of it.