QAOA vs. 75,000 Nodes: Building a Hybrid Architecture to Solve NP-Hard Problems When Quantum Simulators Hit a Wall

Quantum computing today is firmly in the NISQ (Noisy Intermediate-Scale Quantum) era. In theory, everything sounds brilliant: quantum advantage, exponential speedup, and the ability to solve problems far beyond the reach of classical computers. However, in practice, anyone diving into algorithms like QAOA (Quantum Approximate Optimization Algorithm) eventually hits a “wall”—usually around 20–30 qubits.

But what if your task involves analyzing a social graph with tens of thousands of nodes? Take the Epinions dataset, for example, which contains over 75,000 users linked by thousands of trust relationships. Classical simulators simply “choke” on memory when attempting to process such a state vector.

In this article, I will show you how I turned this limitation into an engineering challenge. Instead of trying to “stuff the unstuffable” into a quantum processor, I developed a hybrid orchestrator that decomposes massive networks into quantum-accessible fragments. We’ll walk through the entire pipeline: from loading a GZIP archive to generating an optimized CSV report.

Architecture: Divide and Optimize
The main problem with large graphs is their connectivity. My approach relies on three stages, the logic of which is illustrated in the diagram above:

  1. Decomposition: We use classical community detection algorithms (Greedy Modularity). We break the massive graph into clusters where nodes are tightly connected internally but loosely connected between clusters. This localizes the MaxCut problem.

2.Quantum Solver (QAOA Core): Each cluster is passed to an optimizer based on PennyLane. Here, we run QAOA to find the configuration of states that maximizes the cut weight.

3.Orchestrator (Aggregation Layer): This is the “heart” of the system. It tracks the mapping between local and global IDs and aggregates the results of individual quantum circuits into a single report.

Technical Implementation: Code that “Cuts” Graphs
The foundation of our orchestrator is a streamlined pipeline for graph partitioning.

Simplified Orchestrator Logic

def run_orchestrator(graph_path):
# 1. Load and decompose
graph = load_graph(graph_path)
clusters = decompose_graph(graph, algorithm=’greedy_modularity’)

results = {}
for cluster_id, nodes in clusters.items():
    # 2. Map global IDs to local indices
    mapping = create_node_mapping(nodes)
    subgraph = graph.subgraph(nodes)

    # 3. Execute Quantum Solver
    if len(nodes) <= 20:
        results[cluster_id] = run_qaoa_solver(subgraph, mapping)
    else:
        # Recursive handling for large clusters
        results[cluster_id] = handle_large_cluster(subgraph)

return aggregate_results(results)

Key components:
decompose_graph: Breaks the graph down so the task becomes solvable on accessible hardware.

run_qaoa_solver: The quantum “brain.” Here, the MaxCut problem is mapped onto quantum gates.

run_orchestrator: A manager that iterates through all clusters and reconstructs the global picture.

Engineering Note: In real-world scenarios, cluster sizes vary. It is crucial to include a check like if len(nodes) > 20: to force the orchestrator to split overly large clusters, preventing memory overflow.

Hurdles: Why It Wasn’t Easy
Implementation was a battle against data limitations. Here are three problems I had to solve on the fly:

  1. Indexing: NetworkX reindexes nodes within a subgraph. I added a layer of mapper dictionaries (node_mapping) that preserves the link between a cluster’s local index and the original graph’s global ID.

2.Simulator Overflow: If the modularity algorithm produces a cluster that is too large, I implemented recursive partitioning. This turns the system into a hierarchical “tree-like” optimizer.

3.GZIP Handling: For 75k nodes, I use streaming (via yield) so the orchestrator processes the graph in parts without loading the entire file into RAM.

Conclusion
This project is not the finish line, but a proof of concept that hybrid systems are already capable of handling workloads that exceed the capacity of “off-the-shelf” quantum simulators. My next steps include integration with a real quantum backend and further optimization of the orchestrator.

I’d love to hear your feedback: How do you solve scalability issues in your quantum tasks?

Repository link: github

One Tool That Cuts Token Costs 40-80% for Claude Code, Codex, opencode, and openclaw

The problem isn’t your prompts

If you’re running Claude Code, Codex, opencode, or openclaw and the API bill keeps climbing, you’ve probably tried writing tighter prompts. That’s not where the waste is.

Four structural patterns account for most of the token spend in a typical session:

Screenshots at full resolution. The agent reads whatever images you paste or reference. A 3.3 MB screenshot from a high-DPI display lands in the model at full size. The model doesn’t need native resolution to understand what’s on screen.

Repeated file reads. The agent re-reads files it already touched earlier in the session. A 600-line file read three times costs 1,800 lines of tokens. There’s no built-in session memory to prevent the second or third read from running the full price.

Compaction that loses context. When a session compacts, the summary doesn’t know which files were actively edited or which symbols mattered, so the next request starts with the wrong picture and prompts more reads.

Bash output floods. Every pytest, npm install, docker build, or git log dumps hundreds of lines of passing-test names, deprecation warnings, and progress bars. The model processes all of it at full token cost.

These compound. On a session with 10+ file reads, a few images, and a test run, you’re easily burning 3x the tokens you actually need.

token-goat fixes all four

token-goat (https://github.com/DFKHelper/token-goat) is a hook daemon for Claude Code, Codex CLI, opencode, and openclaw. Install once; it handles the rest.

Image shrinking. Intercepts screenshots before they reach the model and compresses them. A 3.3 MB PNG becomes 84 KB, 97.4% smaller.

Session-aware read hints. Tracks every file the agent reads in the session. When it’s about to re-read one, it gets: “you read lines 1–420 of auth.py 12 minutes ago.” Most re-reads stop.

Compaction assist. Before the session compacts, a hook builds a structured manifest — edited files, accessed symbols, key reads — and injects it into the compaction context. The next request starts with the right picture.

Bash output compression. Filters long-running command output before it hits the model. pytest goes from 150 passing-test lines to a failures-first view, 80–97% smaller. npm install collapses warnings by package. docker build keeps step headers and errors, drops the rest.

It’s all automated, but you can also pull individual functions instead of whole files:

_ token-goat read “src/auth.py::login”_

On a 2,000-line module, that’s 85% fewer tokens than reading the full file.

The numbers

100K wasted tokens per session runs about $0.30. Five sessions a week is $450/year. AI coding cost reduction at that scale comes from eliminating structural waste, not from writing shorter prompts. token-goat is free.

4 hours of use on my machine: 59.7 MB of data that never hit the model, 11.5 million tokens avoided. And that was just version 0.1.

Install

Requires uv (https://docs.astral.sh/uv/).

uv tool install token-goat
token-goat install

Works with Claude Code, Codex CLI, opencode, and openclaw. Windows, Linux, WSL, and macOS.

https://github.com/DFKHelper/token-goat

Static site search for Astro in 2026: why I picked Pagefind over Algolia and Lunr

I added search to all three of my AI-curated directory sites last month. The choice wasn’t obvious — there are at least four options with real adoption — so here’s the breakdown I actually ran through before landing on Pagefind.

The four options I considered

Pagefind is a Rust-based static search library. It runs at build time, generates an index in /_pagefind/, and serves everything as static files. No backend, no API key, no per-query billing. It ships a prebuilt UI (PagefindUI) that you can mount on any element, and it supports WebAssembly for in-browser querying.

Algolia DocSearch is free for open-source documentation sites, $49/month for commercial sites below a certain crawl limit. It indexes your content via their crawler (or an API push), stores it on Algolia’s infrastructure, and gives you a hosted search widget. Fast, polished, and battle-tested — it’s what most major docs sites use.

Lunr.js is a client-side search library. You build the index at build time, serialize it to JSON, and ship it with the page. The browser loads the entire index on first search. Works offline, no external dependency, but the index size grows linearly with content, and there’s no incremental loading.

FlexSearch is a newer alternative to Lunr with better performance characteristics and smaller bundle size, but the same core trade-off: you ship the whole index to the browser upfront.

Why Pagefind won

The decisive factor was index size management. My directories have 500-1,000 entries per site, each with a multi-paragraph generated description. A Lunr index for 1,000 entries would be 2-4MB shipped with every page load. Pagefind shards its index and loads chunks lazily as the user types — so the initial load is under 30KB (the WASM binary + a small manifest), and individual chunk fetches happen on demand.

The second factor was cost. Algolia DocSearch’s commercial tier runs $49/month per site. I’m running three sites on a total infrastructure budget of roughly $25/month. Pagefind is free.

The third factor was the deploy model. Because everything in /_pagefind/ is a static file, Cloudflare Pages caches it at the edge with no configuration. There’s no API to rate-limit, no service availability to depend on, no API key to rotate.

The SearchDialog implementation

The search component is a <dialog> element with a Pagefind UI mounted inside it. I load the pagefind-ui.js script lazily — only when the dialog is first opened — to keep it off the critical path:

function loadPagefind() {
  if (loaded || !root) return;
  loaded = true;
  var s = document.createElement("script");
  s.src = "/_pagefind/pagefind-ui.js";
  s.onload = function () {
    if (window.PagefindUI) {
      new window.PagefindUI({ element: root, showSubResults: true, resetStyles: false });
    }
  };
  s.onerror = function () {
    root.innerHTML = '<p>Search index not available yet (first build). Try again after next deploy.</p>';
  };
  document.head.appendChild(s);
}

The s.onerror handler is the part most tutorials skip. On the first deploy of a new Cloudflare Pages site, the /_pagefind/ directory doesn’t exist yet — Pagefind only runs during the build. If a user opens search before the first full build completes, pagefind-ui.js 404s. Without the error handler, you get a silent failure. With it, you get a legible message.

The <dialog> element is the right primitive here: it handles focus trapping automatically, Escape closes it natively, and backdrop: CSS pseudo-element gives you the dimmed overlay without JavaScript. The Cmd+K keyboard shortcut is wired with document.addEventListener("keydown", ...) — no library needed.

What Pagefind doesn’t do

Two gaps I’ve hit:

No query logging. Pagefind runs entirely in the browser and doesn’t send queries anywhere. For a commercial directory, knowing what users search for is valuable — it tells you which models or games to add, and which compare pages to prioritize. With Algolia you get this for free. With Pagefind you’d need to add a thin logging layer (a fetch POST to an analytics endpoint on each query event). I haven’t built this yet.

No fuzzy matching out of the box. Pagefind does stemming and basic substring matching, but “stabilty diffusion” (typo) won’t match “stable diffusion”. Algolia’s typo-tolerance is significantly better. For an AI tools directory where model names are long and often misremembered, this matters. I’ll probably add a query-suggestion layer that does fuzzy pre-matching before handing off to Pagefind.

Quick comparison table

Pagefind Algolia DocSearch Lunr.js
Cost Free $49/mo (commercial) Free
Index location Static files Algolia cloud Shipped with page
Initial JS load ~30KB ~80KB ~10KB + index
Index size scalability Chunked, lazy Server-side Linear, upfront
Typo tolerance Basic stemming Strong Weak
Query logging No Yes No
Build-time integration Yes Crawler / push API Yes

For a static site on a tight infrastructure budget with 500-1,000 entries, Pagefind is the right default. If the site were larger or if I needed typo tolerance and query analytics without building them myself, Algolia would be worth the cost.

Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

How I built pairwise AI model compare pages with Claude Haiku and a budget cap

When I added compare pages to the Top AI Tools directory, the first question I had to answer was: how many pairs am I actually looking at? With roughly 200 models across 8 pipeline tags, the naive upper bound is 200 × 199 / 2 ≈ 19,900 pairs. Generating content for each one with Claude Haiku would cost somewhere around $20 per run — not ruinous, but not something I wanted to run daily without thinking carefully.

Here’s what I actually built, where it falls short, and what I’d do differently if starting over.

The combinatorics problem

Model compare pages exist for a specific type of query: “llama 3 vs mistral 7b”, “stable diffusion vs sdxl”, “whisper vs wav2vec2”. These are high-intent queries — the user has already narrowed down to a shortlist and wants a concrete decision nudge. The static SSG approach I’m running means I need to precompute each compare page at build time, which puts pressure on how many pages I can afford to generate.

The solution I landed on: group by pipeline_tag, pair the top-4 models by download count within each group, then cap total pairs with a COMPARE_LIMIT env var. Within a single pipeline like text-generation, the top 4 models give 6 pairs (4 choose 2). Across 8 active pipelines that’s roughly 48 pairs. The env cap of 50 means I stay within that budget while having room to grow.

const byPipe = new Map<string, typeof models>();
for (const m of models) {
  if (!m.pipeline_tag) continue;
  const arr = byPipe.get(m.pipeline_tag) ?? [];
  arr.push(m);
  byPipe.set(m.pipeline_tag, arr);
}

const pairs: Array<[Model, Model]> = [];
for (const [, list] of byPipe) {
  const sorted = [...list].sort((a, b) => b.downloads - a.downloads);
  const take = sorted.slice(0, Math.min(4, sorted.length));
  for (let i = 0; i < take.length; i++) {
    for (let j = i + 1; j < take.length; j++) {
      pairs.push([take[i]!, take[j]!]);
    }
  }
}
const chosen = pairs.slice(0, MAX);

The pairing happens entirely within pipelines right now, which means I’m covering “llama vs mistral” (both text-generation) but not “whisper vs gemini-vision” (cross-pipeline). Cross-pipeline comparisons are actually more valuable for users who don’t know the landscape yet — that’s the next iteration.

The pair_slug and idempotent inserts

The slug for each compare pair is constructed deterministically: sort the two model slugs alphabetically, join with --vs--. So whether the ETL processes (llama-3, mistral-7b) or (mistral-7b, llama-3), the slug is always llama-3--vs--mistral-7b.

const pairSlug = [a.slug, b.slug].sort().join("--vs--");

This makes the entire ETL idempotent. The script runs every night. If all pairs already exist in the DB, it exits in a couple of seconds without a single Claude call. I check before inserting rather than using INSERT OR IGNORE at the SQL level — the explicit check lets me count skipped vs generated in the same run, which I log:

[compare] done — generated: 3, skipped: 47

This matters for monitoring. A run that generates 0 and skips 50 is healthy. A run that generates 0 and skips 0 (nothing in DB, nothing processed) would indicate a bug.

Claude Haiku with system-prompt caching

I reuse the shared Haiku client I built in week one, which handles cacheSystem: true on the system prompt. Since the system prompt — the JSON schema instruction — is identical across all compare calls, the first call primes the cache and subsequent calls see near-zero token cost on that prefix.

The user prompt includes both model names, their authors, pipeline tags, and up to 400 characters of their existing summaries (which come from the earlier content generation step):

const userPrompt = `Compare these two AI models:
A: ${a.name} (author: ${a.author ?? "unknown"}, pipeline: ${a.pipeline_tag ?? "unknown"})
   Summary: ${a.summary?.slice(0, 400) ?? "(none)"}
B: ${b.name} (author: ${b.author ?? "unknown"}, pipeline: ${b.pipeline_tag ?? "unknown"})
   Summary: ${b.summary?.slice(0, 400) ?? "(none)"}

Produce the JSON comparison.`;

Truncating summaries at 400 characters keeps the user prompt lean. Compare pages are about the delta between two models, not a rehash of each model individually. I already have dedicated model pages for depth; the compare page needs to answer “which one, for what” — that takes maybe 6 sentences total.

The system prompt requests a JSON object with summary, differences (array), similarities (array), and recommendation. Keeping the output shape narrow means Haiku rarely wanders off-schema.

JSON parsing with a regex fence

Even with tight prompting, Haiku occasionally produces JSON with an explanation preamble: “Here is the comparison:” followed by the actual object. Strict JSON.parse on the raw output would throw. I extract the outermost {...} block with a regex before parsing:

function parseCompare(text: string, fb: CompareData): CompareData {
  try {
    const m = text.match(/{[sS]*}/);
    if (!m) return fb;
    const p = JSON.parse(m[0]);
    return {
      summary: typeof p.summary === "string" ? p.summary : fb.summary,
      differences: Array.isArray(p.differences)
        ? p.differences.map(String)
        : fb.differences,
      similarities: Array.isArray(p.similarities)
        ? p.similarities.map(String)
        : fb.similarities,
      recommendation:
        typeof p.recommendation === "string"
          ? p.recommendation
          : fb.recommendation,
    };
  } catch {
    return fb;
  }
}

Each field is validated individually before being accepted. If differences comes back as a string (occasional Haiku behavior when it conflates the array with a comma-separated list), the page falls back to the template for that field rather than crashing.

The fallback struct is worth writing carefully. I spent five minutes on mine and it shows:

const fb: CompareData = {
  summary: `${a.name} and ${b.name} are both ${a.pipeline_tag} models. See each entry for specifics.`,
  differences: ["See individual model pages for architecture and use cases."],
  similarities: ["Both are open-source models on HuggingFace."],
  recommendation: "Pick based on your compute budget and specific task requirements.",
};

A user landing on a fallback-generated compare page gets a technically-true page that directs them to the model pages rather than a blank or error state. The model_used column in the DB records "fallback-template" for these rows, which I use to identify candidates for regeneration.

Storage in libSQL and the static JSON dump

Compare data lives in a model_compare table in Turso libSQL, with a unique constraint on pair_slug. After the ETL loop, everything gets dumped to compare.json for the static build:

const all = await db.execute(
  `SELECT * FROM model_compare ORDER BY slug_a, slug_b`
);
const entries = all.rows.map((r) => ({
  slug_a: String(r.slug_a),
  slug_b: String(r.slug_b),
  pair_slug: String(r.pair_slug),
  summary: r.summary ? String(r.summary) : "",
  differences: r.differences ? JSON.parse(String(r.differences)) as string[] : [],
  similarities: r.similarities ? JSON.parse(String(r.similarities)) as string[] : [],
  recommendation: r.recommendation ? String(r.recommendation) : "",
}));
await writeFile("./src/data/compare.json", JSON.stringify(entries, null, 2));

The Astro build reads this JSON at build time, generating one static page per pair. No runtime DB calls, no cold starts. The tradeoff is freshness: compare content is up to 24 hours stale. For “llama 3.1 vs llama 3.2”, that’s fine — the models don’t change daily.

I validate the JSON-LD on compare pages through the post-deploy audit CI step the same way I do for individual model pages. Structured data matters more on comparison queries because those are the exact queries that AI Overviews tend to surface, so getting the schema right is worth the CI overhead.

The Astro slug generation for compare pages uses the pair_slug directly. The URL pattern is /compare/llama-3--vs--mistral-7b/, which is ugly but unambiguous — the double-dash separator makes it clear this is a two-part slug rather than a hyphen in a model name.

What I’d change starting over

Generate cross-pipeline pairs from day one. The most useful compare queries aren’t “llama 3.1 vs llama 3.2” — users who care about that distinction already know. The interesting queries are cross-category: “should I run inference on a text-generation model or use a RAG pipeline?” I skipped this to stay within the budget cap, but it means I’m missing the long-tail traffic that would actually be differentiated from generic model pages.

Drive pair selection from search query logs. Right now I pick pairs by download rank. A better signal would be which pairs users actually search for. Pagefind runs client-side and doesn’t log queries to any server, so I’d need a thin logging endpoint — something like a POST to a GitHub Actions-triggered function that appends to a JSONL file. Then the ETL reads the top-N ungenerated pairs from the log. This is a small amount of infrastructure but it would make the pair selection much more demand-driven.

Raise the budget cap. MAX=50 is conservative. At current Haiku pricing with prompt caching, 500 pairs would cost roughly $0.10 per nightly run. I was cautious when I set the default, but I’ve watched the billing closely and the actual spend is a fraction of what I modeled. I’ll bump this to 200 in the next ETL config update.

The itch.io entries pattern I added to the indie-games directory taught me to plan for the second data source earlier. Compare pages have the same shape: a join between two rows. Getting the abstraction right before you have 500+ rows in the DB is much easier than retrofitting it.

FAQ

Does the ETL run every night even when no new models are added?

Yes, but it’s nearly free when nothing is new. The check-before-insert means most nights it does 50 DB reads and exits in under 3 seconds without touching the Claude API. The console output shows generated: 0, skipped: 47 which is the signal that everything is up to date.

What happens when Claude returns malformed JSON?

parseCompare catches the error and returns the fallback struct. The row is still written to the DB with model_used = "fallback-template", which I can query to find rows worth retrying. In practice, this happens on maybe 2-3% of generations — usually when the two models have very sparse metadata and Haiku doesn’t have enough context to produce structured output.

Does the compare.json file get unwieldy as pairs accumulate?

At 50 pairs it’s roughly 25KB. At 500 pairs it’ll be around 250KB — still fine for build-time loading in Astro. If I ever hit 5,000 pairs I’d split the file by pipeline_tag and lazy-import only the relevant subset for each page. For now, one flat JSON file is simpler and fast enough.

Why not compute compare content at request time with an edge function?

Cold starts and cost. An edge function hit for each compare page view would add 200-500ms of latency (Haiku inference + DB round trip) and would cost much more per-pageview than the nightly batch approach. The content also doesn’t need to be fresher than daily — model capabilities don’t shift on an hourly basis. Static precomputation is the right tradeoff here, consistent with the broader bet on static SSG I’m running on all three sites.

How do you handle the case where a model is removed from HuggingFace?

Right now, I don’t. If model foo is deleted from HuggingFace but its compare rows are still in the DB, those compare pages will still be served at build time. They’ll have the old data until the model’s row in models.json is removed — which only happens if the model falls out of the top-500 in the nightly fetch. It’s a known gap. For now, the risk is low; popular models don’t disappear. A more robust system would cross-reference the compare table against the model table and tombstone orphaned pairs.

Related: How I built a shared Claude Haiku client with system-prompt caching | Turso libSQL vs Cloudflare D1 for an Astro monorepo

Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Anthropic’s MCP tunnels and self-hosted sandboxes: keeping agents inside your perimeter

Anthropic has quietly shipped two new infrastructure features for Claude agents: MCP tunnels and self-hosted sandboxes. Neither is about making Claude smarter. Both are about making it safe to deploy inside a real enterprise security perimeter.

They solve different problems. You might need one, the other, or both.

What actually changed

MCP tunnels let Claude connect to MCP servers running in your private network — without opening inbound firewall ports or exposing services to the public internet.

The mechanism is an outbound-only connection (via Cloudflare as the transport layer) that carries three independent layers of encryption: mutual TLS between Anthropic and the tunnel edge, inner TLS between Anthropic’s backend and your proxy, and OAuth authentication on each individual MCP server. Crucially, Cloudflare can see connection metadata — timing, byte volume, your subdomain — but cannot read MCP request or response payloads because the proxy terminates inner TLS using a certificate only you hold.

Each private MCP server you expose gets a hostname under your tunnel domain (e.g. docs.<your-tunnel-domain>). You attach those hostnames to Managed Agent sessions in the Console or pass them to the Messages API via the MCP connector.

“Traffic flows over an outbound-only connection, so you don’t need to open inbound firewall ports, expose services to the public internet, or allowlist Anthropic’s IP ranges on your origin.” — Anthropic docs

Self-hosted sandboxes solve a separate problem: where the agent’s code actually runs. By default, Claude Managed Agents execute tools inside Anthropic-managed cloud containers. Self-hosted sandboxes move tool execution into infrastructure you control — but keep the orchestration on Anthropic’s side. The agent’s code, filesystem, and network egress never leave your environment.

The architecture is a small “environment worker” process you run on your own infrastructure. It polls a work queue maintained by Anthropic, claims sessions, downloads agent skills, executes tool calls locally, and posts results back. Pre-built workers exist for Cloudflare, Daytona, Modal, and Vercel. The CLI and SDK include helpers for both always-on pollers and webhook-triggered architectures.

The design distinction worth understanding

These are independent controls:

  • MCP tunnels govern how Anthropic reaches your MCP servers — private network access without open ports
  • Self-hosted sandboxes govern where the agent’s tools execute — your compute, not Anthropic’s containers

A cloud-hosted agent session can use tunnels to reach private MCP servers. A self-hosted session can use either tunnelled or public MCP servers. Use both when you need execution and tool access to stay inside your boundary.

The target audience is obvious: regulated industries (finance, healthcare, government) where data residency, network isolation, and audit controls are non-negotiable — and where “we run your agent in our cloud” simply won’t fly with infosec.

What to do

  • Running MCP servers on private infrastructure? MCP tunnels are the clean answer. No VPN, no IP allowlisting, no exposed ports. Request Research Preview access via Anthropic’s form.
  • Building on Claude Managed Agents? If your use case has data-residency requirements or needs access to services that aren’t publicly routable, self-hosted sandboxes are now an option.
  • On the Messages API without Managed Agents? MCP tunnels still apply — you pass tunnelled server URLs via the mcp_servers array the same way as any other remote MCP server.
  • On AWS Bedrock / Claude Platform on AWS? Self-hosted sandboxes aren’t available there yet. Tunnels are.

Both features are in Research Preview (beta). Access isn’t automatic — you’ll need to request it.

Source: The New Stack | MCP tunnels docs | Self-hosted sandboxes docs

✏️ Drafted with KewBot (AI), edited and approved by Drew.

Building a Production Company Website on AWS — Project Overview

Architecture overview

What We Built

This series documents the end-to-end process of designing, building, and deploying a production company website for a software and cloud consulting business. The result is a modern, fully serverless stack with automated CI/CD deployments — built intentionally to demonstrate and practice the AWS services I use professionally.

Tech stack at a glance:

Layer Technology
Frontend React 18 + Vite + TypeScript + Tailwind CSS v4
Hosting Amazon S3 + CloudFront (CDN)
DNS & TLS Route 53 + ACM (SSL/TLS certificate)
CI/CD GitHub + AWS CodePipeline + CodeBuild
Contact API API Gateway (HTTP) + AWS Lambda (Python)
Data storage Amazon DynamoDB
Email delivery Amazon SES
IaC AWS CloudFormation + AWS SAM

Architecture Overview

Architecture overview

CI/CD Pipeline

Every push to the main branch on GitHub automatically triggers a full build and deploy:

CI/CD pipeline

Articles in This Series

# Article What it covers
1 Project Overview (this article) Full architecture, stack decisions, what we’re building
2 React + Vite + Tailwind Setup Scaffolding the SPA, routing, design system, component structure
3 Static Site Hosting on AWS S3, CloudFront with OAC, ACM, Route 53 DNS, CloudFormation
4 CI/CD with CodePipeline & CodeBuild GitHub connection, pipeline stages, buildspec.yml, IAM roles
5 Serverless Contact Form Lambda, API Gateway, DynamoDB, SES, CORS, SAM deployment

Key Design Decisions

Why React + Vite (not Next.js)?

Next.js is a great framework, but for a static marketing site, it’s overhead. Vite produces a clean static build (dist/) that S3 + CloudFront serves perfectly. The site has no server-side rendering requirements. Vite also gives faster local dev iteration.

Why S3 + CloudFront (not Amplify or Vercel)?

This project is intentionally built on “raw” AWS primitives — CodePipeline, CloudFormation, S3, CloudFront — rather than abstracted platforms. The goal is to learn and demonstrate AWS services used in real enterprise projects.

Why CloudFormation (not CDK or Terraform)?

CloudFormation is the AWS-native tool that every AWS practitioner encounters. Understanding it directly — before abstracting to CDK — builds a stronger mental model of what’s actually being deployed.

Why separate ACM cert in us-east-1?

CloudFront is a global service and requires ACM certificates to be provisioned specifically in us-east-1, regardless of where your other resources live. This is an AWS constraint, not a design choice.

Why CloudFront OAC (not OAI)?

Origin Access Control (OAC) is the modern replacement for Origin Access Identity (OAI). It supports all S3 operations, works with SSE-KMS encrypted buckets, and uses AWS SigV4 signing.

Repository Structure

bonefishsoftware.com/
├── src/                    # React source
│   ├── components/         # Navbar, Footer, SectionHeader
│   ├── pages/              # Home, Services, Technologies, Portfolio, Team, Contact
│   └── data/               # services.ts, technologies.ts, team.ts
├── public/                 # Static assets (logo, sitemap, robots.txt)
├── lambda/
│   └── contact/            # Python Lambda — contact form handler
├── infra/
│   ├── acm/                # certificate.yml (deploy to us-east-1)
│   └── stacks/
│       ├── website.yml     # S3 + CloudFront (deploy to us-west-2)
│       ├── pipeline.yml    # CodePipeline + CodeBuild (deploy to us-west-2)
│       └── contact-api.yml # SAM — API Gateway + Lambda + DynamoDB (deploy to us-west-2)
├── docs/                   # This documentation
├── buildspec.yml           # CodeBuild build spec
└── index.html              # Vite entry point with SEO meta tags

Serverless Contact Form — Lambda, API Gateway, DynamoDB, and SES

Contact form flow

Overview

The contact form on bonefishsoftware.com is fully serverless — no EC2, no always-on server. A visitor submits the form, the request hits an API Gateway HTTP API, a Python Lambda function validates and stores the submission in DynamoDB, then sends an email notification via SES. Cost at low volume: effectively zero.

Flow

Contact form flow

Infrastructure — AWS SAM

The contact API is deployed using AWS SAM (Serverless Application Model), a CloudFormation extension that simplifies Lambda + API Gateway resource definitions.

Why SAM over plain CloudFormation?

SAM’s AWS::Serverless::Function with Events automatically creates the API Gateway routes, integrations, Lambda permissions, and stage. Doing this in plain CloudFormation requires 6–8 separate resource definitions. SAM condenses it to one function resource with an Events section.

Key lesson: AWS::Serverless::HttpApi vs AWS::ApiGatewayV2::Api

The most important thing to get right: when using SAM’s HttpApi event type, the ApiId must reference an AWS::Serverless::HttpApi resource — not a native AWS::ApiGatewayV2::Api.

Using the wrong resource type results in the SAM transform silently skipping route and integration creation, leaving you with a deployed API that returns 404 on every request.

# ✅ Correct — SAM manages routes/integrations automatically
ContactApi:
  Type: AWS::Serverless::HttpApi
  Properties:
    CorsConfiguration:
      AllowOrigins: [https://bonefishsoftware.com]
      AllowMethods: [POST, OPTIONS]
      AllowHeaders: [Content-Type]

ContactFunction:
  Type: AWS::Serverless::Function
  Events:
    PostContact:
      Type: HttpApi
      Properties:
        ApiId: !Ref ContactApi   # ← references Serverless::HttpApi
        Path: /contact
        Method: POST
# ❌ Wrong — routes NOT created by SAM
ContactApi:
  Type: AWS::ApiGatewayV2::Api  # ← native resource, SAM ignores events

Lambda Function (Python)

# lambda/contact/handler.py

import json, os, uuid
from datetime import datetime, timezone
import boto3
from botocore.exceptions import ClientError

ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "https://bonefishsoftware.com")
TABLE_NAME = os.environ["TABLE_NAME"]
FROM_ADDRESS = os.environ["FROM_ADDRESS"]
TO_ADDRESS = os.environ["TO_ADDRESS"]

dynamodb = boto3.resource("dynamodb")
ses = boto3.client("ses")

def handler(event, context):
    # Handle CORS preflight
    if event.get("requestContext", {}).get("http", {}).get("method") == "OPTIONS":
        return _response(200, {})

    # Parse and validate
    try:
        body = json.loads(event.get("body") or "{}")
    except (json.JSONDecodeError, TypeError):
        return _response(400, {"error": "Invalid request body."})

    name    = (body.get("name") or "").strip()
    email   = (body.get("email") or "").strip()
    message = (body.get("message") or "").strip()
    company = (body.get("company") or "").strip()

    if not name or not email or not message:
        return _response(400, {"error": "Name, email, and message are required."})

    if "@" not in email or "." not in email.split("@")[-1]:
        return _response(400, {"error": "Please provide a valid email address."})

    submission_id = str(uuid.uuid4())
    timestamp = datetime.now(timezone.utc).isoformat()

    try:
        _save_to_dynamo(submission_id, timestamp, name, email, company, message)
        _send_email(submission_id, timestamp, name, email, company, message)
    except ClientError as e:
        print(f"AWS error: {e}")
        return _response(500, {"error": "Failed to process your message. Please try again."})

    return _response(200, {"message": "Message received! We'll be in touch soon."})

Why no external dependencies?

Python’s boto3 SDK is built into the Lambda runtime — no requirements.txt needed. The deployment package is just a single handler.py file, keeping Lambda cold-start time minimal.

Validation approach

Validation is intentionally lightweight:

  • Required field presence check
  • Naive email format check (contains @ and a . after it)

We’re not the primary spam defense here — the contact form has no public financial incentive to spam, and rate limiting can be added at the API Gateway level later if needed.

DynamoDB Table

SubmissionsTable:
  Type: AWS::DynamoDB::Table
  Properties:
    TableName: bonefish-contact-submissions
    BillingMode: PAY_PER_REQUEST
    AttributeDefinitions:
      - AttributeName: submissionId
        AttributeType: S
      - AttributeName: timestamp
        AttributeType: S
    KeySchema:
      - AttributeName: submissionId
        KeyType: HASH
      - AttributeName: timestamp
        KeyType: RANGE

PAY_PER_REQUEST billing — no provisioned capacity to manage. At the volume of a contact form (single-digit submissions per day at most), this costs essentially nothing.

Composite key (submissionId + timestamp) — the UUID ensures uniqueness; the timestamp makes it easy to sort and query submissions chronologically in future tooling.

SES Email Delivery

Domain verification (DKIM)

Emails sent from [email protected] require the domain to be verified in SES. This involves adding three DKIM CNAME records to Route 53:

aws sesv2 create-email-identity 
  --email-identity bonefishsoftware.com 
  --region us-west-2
# → returns 3 DKIM tokens

# Add CNAME records: <token>._domainkey.bonefishsoftware.com → <token>.dkim.amazonses.com

DKIM signing tells receiving mail servers that the email genuinely came from our domain, improving deliverability and preventing spoofing.

Sandbox mode

By default, SES operates in sandbox mode — you can only send to verified email addresses. This is sufficient for the contact form (we only send TO [email protected], which is verified). The submitter’s email address appears only in the Reply-To header and the email body — never as a direct recipient.

SES production access (removing sandbox restrictions) was requested via:

aws sesv2 put-account-details 
  --mail-type TRANSACTIONAL 
  --website-url https://bonefishsoftware.com 
  --use-case-description "..." 
  --production-access-enabled

Reply-To header

ses.send_email(
    Source=FROM_ADDRESS,                  # [email protected]
    Destination={"ToAddresses": [TO_ADDRESS]},  # [email protected]
    Message={...},
    ReplyToAddresses=[email],             # submitter's email
)

Setting ReplyToAddresses to the submitter’s email means hitting “Reply” in gmail automatically addresses the response to the client — no copy-pasting required.

CORS Configuration

CORS is configured at the API Gateway level (not in the Lambda response), via the AWS::Serverless::HttpApi resource:

ContactApi:
  Type: AWS::Serverless::HttpApi
  Properties:
    CorsConfiguration:
      AllowOrigins:
        - https://bonefishsoftware.com
        - http://localhost:5173    # local dev
      AllowHeaders:
        - Content-Type
      AllowMethods:
        - POST
        - OPTIONS
      MaxAge: 300

The Lambda still returns Access-Control-Allow-Origin headers in its response (for safety), but API Gateway handles the OPTIONS preflight response automatically.

Lesson learned: An early version used AWS::ApiGatewayV2::Api instead of AWS::Serverless::HttpApi. The CORS configuration was present but the routes were never created — resulting in 404 responses and CORS errors in the browser. Switching to AWS::Serverless::HttpApi resolved both issues simultaneously.

Frontend Integration

The contact form in React fetches the API URL from a Vite environment variable:

// src/pages/Contact.tsx
const apiUrl = import.meta.env.VITE_CONTACT_API_URL;

async function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  setStatus('submitting');
  try {
    const res = await fetch(apiUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    });
    if (!res.ok) throw new Error('Request failed');
    setStatus('success');
  } catch {
    setStatus('error');
  }
}

VITE_CONTACT_API_URL is injected at build time by CodeBuild as an environment variable. Vite replaces import.meta.env.VITE_* references with literal string values during the build — there’s no runtime environment lookup.

SAM Deployment

sam deploy 
  --template-file infra/stacks/contact-api.yml 
  --stack-name bonefish-contact-api 
  --region us-west-2 
  --capabilities CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND 
  --s3-bucket bonefish-pipeline-artifacts-709085484102 
  --s3-prefix sam-contact 
  --no-confirm-changeset

CAPABILITY_AUTO_EXPAND is required when the template uses Transform: AWS::Serverless-2016-10-31. This tells CloudFormation to expand SAM macros before processing the template.

SAM packages the Lambda code (zips lambda/contact/), uploads to the artifacts S3 bucket, and replaces the local CodeUri path with the S3 URL in the transformed template — all automatically.

Cost Analysis

At typical consulting site traffic:

Resource Usage Estimated cost
API Gateway HTTP API 100 requests/month $0.001
Lambda 100 invocations × 128MB × 500ms < $0.01
DynamoDB 100 writes, PAY_PER_REQUEST < $0.01
SES 100 emails $0.01
Total < $0.05/month

The entire contact form backend costs pennies per month.

CI/CD with AWS CodePipeline and CodeBuild

CI/CD pipeline

Overview

Every push to the main branch on GitHub automatically builds the React app and deploys it to S3 + CloudFront — zero manual steps. This article covers how that pipeline is wired together using AWS CodePipeline, CodeBuild, and GitHub via CodeStar Connections.

Pipeline Flow

CI/CD pipeline

CloudFormation Stack

The pipeline infrastructure is defined in infra/stacks/pipeline.yml.

CodeStar Connection (GitHub App)

GitHubConnection:
  Type: AWS::CodeStarConnections::Connection
  Properties:
    ConnectionName: bonefish-github
    ProviderType: GitHub

Manual step required: After deploying the stack, you must go to AWS Console → CodePipeline → Settings → Connections and click “Update pending connection” to authorize the GitHub App. This cannot be automated — AWS requires explicit human approval to grant access to your GitHub account.

When authorizing, install the AWS Connector for GitHub app on your GitHub account and grant it access to the specific repository. “Connect as a GitHub user” only works for CodeBuild — CodePipeline requires the GitHub App.

Artifact Bucket

Intermediate pipeline artifacts (source zip, build output) are stored in a private S3 bucket:

ArtifactBucket:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: !Sub 'bonefish-pipeline-artifacts-${AWS::AccountId}'
    VersioningConfiguration:
      Status: Enabled
    PublicAccessBlockConfiguration:
      BlockPublicAcls: true
      BlockPublicPolicy: true
      IgnorePublicAcls: true
      RestrictPublicBuckets: true

CodeBuild Project

BuildProject:
  Type: AWS::CodeBuild::Project
  Properties:
    Name: bonefish-build
    ServiceRole: !GetAtt CodeBuildRole.Arn
    Artifacts:
      Type: CODEPIPELINE
    Environment:
      Type: LINUX_CONTAINER
      ComputeType: BUILD_GENERAL1_SMALL
      Image: aws/codebuild/standard:7.0
      EnvironmentVariables:
        - Name: S3_BUCKET
          Value: !Ref S3BucketName
        - Name: DISTRIBUTION_ID
          Value: !Ref DistributionId
    Source:
      Type: CODEPIPELINE
      BuildSpec: buildspec.yml

CodePipeline

Pipeline:
  Type: AWS::CodePipeline::Pipeline
  Properties:
    Name: bonefish-website-pipeline
    RoleArn: !GetAtt PipelineRole.Arn
    PipelineType: V2
    Stages:
      - Name: Source
        Actions:
          - Name: GitHub
            ActionTypeId:
              Category: Source
              Owner: AWS
              Provider: CodeStarSourceConnection
              Version: '1'
            Configuration:
              ConnectionArn: !Ref GitHubConnection
              FullRepositoryId: !Sub '${GitHubOwner}/${GitHubRepo}'
              BranchName: !Ref GitHubBranch
              DetectChanges: true
            OutputArtifacts:
              - Name: SourceArtifact
      - Name: Build
        Actions:
          - Name: BuildAndDeploy
            ActionTypeId:
              Category: Build
              Owner: AWS
              Provider: CodeBuild
              Version: '1'
            Configuration:
              ProjectName: !Ref BuildProject
            InputArtifacts:
              - Name: SourceArtifact

DetectChanges: true means CodePipeline automatically triggers on every push to the configured branch — no webhooks to configure manually.

IAM Roles

Two IAM roles are needed: one for CodePipeline, one for CodeBuild.

CodePipeline Role

PipelineRole:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Statement:
        - Effect: Allow
          Principal:
            Service: codepipeline.amazonaws.com
          Action: sts:AssumeRole
    Policies:
      - PolicyName: PipelinePolicy
        PolicyDocument:
          Statement:
            - Sid: ArtifactBucket
              Effect: Allow
              Action: [s3:GetObject, s3:PutObject, s3:GetObjectVersion, s3:GetBucketVersioning]
              Resource: [!Sub '${ArtifactBucket.Arn}', !Sub '${ArtifactBucket.Arn}/*']
            - Sid: CodeBuild
              Effect: Allow
              Action: [codebuild:BatchGetBuilds, codebuild:StartBuild]
              Resource: !GetAtt BuildProject.Arn
            - Sid: CodeStarConnection
              Effect: Allow
              Action: [codestar-connections:UseConnection]
              Resource: !Ref GitHubConnection

CodeBuild Role

CodeBuildRole:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Statement:
        - Effect: Allow
          Principal:
            Service: codebuild.amazonaws.com
          Action: sts:AssumeRole
    Policies:
      - PolicyName: CodeBuildPolicy
        PolicyDocument:
          Statement:
            - Sid: Logs
              Effect: Allow
              Action: [logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents]
              Resource: '*'
            - Sid: ArtifactBucket
              Effect: Allow
              Action: [s3:GetObject, s3:PutObject, s3:GetObjectVersion]
              Resource: !Sub '${ArtifactBucket.Arn}/*'
            - Sid: WebsiteSync
              Effect: Allow
              Action: [s3:PutObject, s3:DeleteObject, s3:GetObject, s3:ListBucket]
              Resource:
                - !Sub 'arn:aws:s3:::${S3BucketName}'
                - !Sub 'arn:aws:s3:::${S3BucketName}/*'
            - Sid: CloudFrontInvalidation
              Effect: Allow
              Action: [cloudfront:CreateInvalidation]
              Resource: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId}'

Principle of least privilege — CodeBuild can only write to the specific S3 bucket and invalidate the specific CloudFront distribution.

buildspec.yml

The build specification lives in the repo root and tells CodeBuild exactly what to do:

version: 0.2

env:
  variables:
    VITE_CONTACT_API_URL: ""  # overridden by CodeBuild project env var

phases:
  install:
    runtime-versions:
      nodejs: 20
    commands:
      - npm ci

  build:
    commands:
      - npm run build

  post_build:
    commands:
      - aws s3 sync dist/ s3://$S3_BUCKET --delete
      - aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "/*"

Key points

npm ci not npm install

npm ci installs exactly what’s in package-lock.json and fails if there are any discrepancies. This ensures deterministic builds — the same packages every time, in every environment.

--delete flag on s3 sync

Removes files from S3 that no longer exist in the build output. Without this, deleted pages or renamed assets would stay in S3 forever and get served to users.

CloudFront invalidation

Vite includes content hashes in asset filenames (index-BX7FeaXh.js), so JS/CSS files are automatically cache-busted. However, index.html itself doesn’t have a hash — it must be explicitly invalidated so CloudFront fetches the new version immediately.

VITE_CONTACT_API_URL env var

Vite’s import.meta.env.VITE_* variables are replaced at build time (not runtime). The API Gateway URL is injected by CodeBuild as an environment variable and baked into the built JS bundle. This means the frontend always has the correct endpoint URL without any runtime configuration.

Troubleshooting the GitHub Connection

The CodeStar Connection requires careful setup. Common issues encountered:

Status: PENDING after stack deploy

Expected. You must visit the AWS Console to authorize it. Cannot be done via CLI.

“No Branch found” error

The GitHub App was authorized but the private repository wasn’t explicitly granted access. Fix: GitHub → Settings → Applications → AWS Connector for GitHub → Configure → add the repo.

“Role does not have sufficient permissions” error

After replacing a broken connection with a new one, the CodePipeline IAM role policy still referenced the old connection ARN. Fix: update the codestar-connections:UseConnection resource ARN in the IAM policy to match the new connection ARN.

Deployment Timeline

From git push to live site:

Phase Duration
CodePipeline detects change ~10 seconds
Source download from GitHub ~15 seconds
npm ci (cache warm) ~30 seconds
npm run build (tsc + vite) ~15 seconds
aws s3 sync ~10 seconds
CloudFront invalidation ~10 seconds
Total ~90 seconds

Full deployment in under two minutes on every push to main.

Static Site Hosting on AWS — S3, CloudFront, ACM, and Route 53

Static hosting architecture

Overview

This article covers deploying a static React site to AWS using S3 as the origin, CloudFront as the CDN, ACM for TLS certificates, and Route 53 for DNS. Everything is defined as CloudFormation infrastructure-as-code.

Architecture

Static hosting architecture

CloudFormation Infrastructure

The hosting infrastructure is split across three stacks deployed in sequence:

Stack File Region Purpose
bonefish-acm infra/acm/certificate.yml us-east-1 ACM TLS certificate
bonefish-website infra/stacks/website.yml us-west-2 S3 + CloudFront
bonefish-pipeline infra/stacks/pipeline.yml us-west-2 CI/CD (covered in article 4)

ACM Certificate (us-east-1)

Why us-east-1? CloudFront is a global service that only accepts ACM certificates provisioned in us-east-1. This is an AWS requirement regardless of where your other resources live.

# infra/acm/certificate.yml
Resources:
  Certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: bonefishsoftware.com
      SubjectAlternativeNames:
        - www.bonefishsoftware.com
      ValidationMethod: DNS

DNS validation: ACM generates two CNAME records that must be added to your DNS zone. Because the domain is managed by Route 53, we added these via CLI:

aws route53 change-resource-record-sets 
  --hosted-zone-id $ZONE_ID 
  --change-batch '{ "Changes": [
    { "Action": "UPSERT", "ResourceRecordSet": {
      "Name": "_bf48ff...bonefishsoftware.com.",
      "Type": "CNAME", "TTL": 300,
      "ResourceRecords": [{"Value": "_ea68....acm-validations.aws."}]
    }}
  ]}'

Once the domain’s nameservers resolve correctly, ACM validates automatically (typically 2–5 minutes).

S3 Bucket

The S3 bucket is private — no public access whatsoever. CloudFront accesses it via OAC (Origin Access Control).

WebsiteBucket:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: bonefishsoftware-com-website
    VersioningConfiguration:
      Status: Enabled
    PublicAccessBlockConfiguration:
      BlockPublicAcls: true
      BlockPublicPolicy: true
      IgnorePublicAcls: true
      RestrictPublicBuckets: true

Bucket Policy — allow CloudFront OAC only

WebsiteBucketPolicy:
  Type: AWS::S3::BucketPolicy
  Properties:
    Bucket: !Ref WebsiteBucket
    PolicyDocument:
      Statement:
        - Effect: Allow
          Principal:
            Service: cloudfront.amazonaws.com
          Action: s3:GetObject
          Resource: !Sub '${WebsiteBucket.Arn}/*'
          Condition:
            StringEquals:
              AWS:SourceArn: !Sub >-
                arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}

The AWS:SourceArn condition means only our specific CloudFront distribution can read from this bucket — not any other CloudFront distribution in any AWS account.

Origin Access Control (OAC)

OAC is the modern replacement for Origin Access Identity (OAI). Key advantages:

  • Supports all S3 API operations
  • Works with SSE-KMS encrypted buckets
  • Uses AWS SigV4 request signing (more secure)
OriginAccessControl:
  Type: AWS::CloudFront::OriginAccessControl
  Properties:
    OriginAccessControlConfig:
      Name: bonefishsoftware-com-oac
      OriginAccessControlOriginType: s3
      SigningBehavior: always
      SigningProtocol: sigv4

CloudFront Distribution

Distribution:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
      Enabled: true
      DefaultRootObject: index.html
      Aliases:
        - bonefishsoftware.com
        - www.bonefishsoftware.com
      ViewerCertificate:
        AcmCertificateArn: !Ref CertificateArn
        SslSupportMethod: sni-only
        MinimumProtocolVersion: TLSv1.2_2021
      HttpVersion: http2and3
      Origins:
        - Id: S3Origin
          DomainName: !GetAtt WebsiteBucket.RegionalDomainName
          S3OriginConfig:
            OriginAccessIdentity: ''
          OriginAccessControlId: !GetAtt OriginAccessControl.Id
      DefaultCacheBehavior:
        TargetOriginId: S3Origin
        ViewerProtocolPolicy: redirect-to-https
        CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6  # CachingOptimized
        Compress: true
      CustomErrorResponses:
        - ErrorCode: 403
          ResponseCode: 200
          ResponsePagePath: /index.html
          ErrorCachingMinTTL: 0
        - ErrorCode: 404
          ResponseCode: 200
          ResponsePagePath: /index.html
          ErrorCachingMinTTL: 0
      PriceClass: PriceClass_100

Key configuration points

RegionalDomainName not DomainName

Always use WebsiteBucket.RegionalDomainName (e.g. bucket.s3.us-west-2.amazonaws.com) when configuring an S3 origin with OAC. Using the global DomainName can cause redirect loops.

PriceClass_100

CloudFront price classes control which edge locations serve your content. PriceClass_100 covers North America and Europe — the right choice for most US-based businesses. PriceClass_All includes Asia/Pacific/South America but costs more.

http2and3

Enables HTTP/2 and HTTP/3 (QUIC) — better performance for browsers that support it.

Route 53 DNS Setup

Since the domain is registered in Route 53, nameservers were updated via CLI:

aws route53domains update-domain-nameservers 
  --domain-name bonefishsoftware.com 
  --region us-east-1 
  --nameservers 
    Name=ns-1325.awsdns-37.org 
    Name=ns-759.awsdns-30.net 
    Name=ns-1601.awsdns-08.co.uk 
    Name=ns-72.awsdns-09.com

Two Alias records point the apex domain and www to CloudFront:

aws route53 change-resource-record-sets 
  --hosted-zone-id $ZONE_ID 
  --change-batch '{
    "Changes": [{
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "bonefishsoftware.com",
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "d39qxh6q0wxdkd.cloudfront.net",
          "EvaluateTargetHealth": false
        }
      }
    }]
  }'

Z2FDTNDATAQYW2 is the fixed Hosted Zone ID for all CloudFront distributions — not specific to yours. Always use this value for CloudFront A alias records.

Deployment Order

The order matters because each stack depends on outputs from the previous:

1. Create Route 53 hosted zone
   → Get nameservers, update domain registrar

2. Deploy ACM certificate stack (us-east-1)
   → Add DNS validation CNAMEs to Route 53
   → Wait for ISSUED status (~2–5 min once DNS propagates)

3. Deploy website stack (us-west-2)
   → Pass CertificateArn as parameter
   → Outputs: BucketName, DistributionId, DistributionDomain

4. Deploy pipeline stack (us-west-2)
   → Pass BucketName + DistributionId from step 3

Gradual Cutover Strategy

Rather than blocking on the ACM cert, the website.yml template supports deploying without a cert first:

Parameters:
  CertificateArn:
    Type: String
    Default: ''   # ← Leave blank for initial deploy

Conditions:
  HasCustomDomain: !And
    - !Not [!Equals [!Ref CertificateArn, '']]
    - !Not [!Equals [!Ref DomainName, '']]

This lets you deploy the full CloudFront + S3 stack immediately, get the CloudFront URL (*.cloudfront.net), test it, and then update the stack with the cert ARN once it’s issued — no downtime, no waiting.

SES DKIM Records

For outbound email from [email protected], three DKIM CNAME records were added to Route 53:

jbly6mlsiqbi6h7rnnfthdfkiaxzmvjn._domainkey.bonefishsoftware.com
  → jbly6mlsiqbi6h7rnnfthdfkiaxzmvjn.dkim.amazonses.com

xj7cx3tujwzexkmsxmfruyannxq3sevj._domainkey.bonefishsoftware.com
  → xj7cx3tujwzexkmsxmfruyannxq3sevj.dkim.amazonses.com

fyee6qf6o4fzqiolzxl2dyd4rjboqw4l._domainkey.bonefishsoftware.com
  → fyee6qf6o4fzqiolzxl2dyd4rjboqw4l.dkim.amazonses.com

These allow SES to cryptographically sign outbound emails, which improves deliverability and prevents spoofing.

React + Vite + TypeScript + Tailwind CSS v4 — Project Setup

Overview

This article covers scaffolding a production-ready React single-page application using Vite, TypeScript, and Tailwind CSS v4. This is the frontend that gets built into static assets and served from S3 + CloudFront.

Tech Choices

Vite over Create React App

CRA is deprecated. Vite is the modern standard — it uses native ES modules for near-instant dev server startup and produces an optimized production build via Rollup.

TypeScript

Type safety catches bugs at compile time instead of runtime. The CloudFormation templates and the site itself are both infra-as-code; TypeScript gives the same discipline on the frontend.

Tailwind CSS v4

Tailwind v4 (released 2024) is a ground-up rewrite. Key changes from v3:

  • No tailwind.config.js — theme customization moves into CSS via @theme in your stylesheet
  • No PostCSS config file — use the @tailwindcss/vite Vite plugin instead
  • CSS-first configuration — design tokens are CSS custom properties

Scaffolding

# Create project with react-ts template
npm create vite@latest . -- --template react-ts

# Install routing and Tailwind
npm install react-router-dom
npm install -D tailwindcss @tailwindcss/vite

vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()],
})

The @tailwindcss/vite plugin replaces the old PostCSS setup. No postcss.config.js needed.

src/index.css

/* Google Fonts import MUST come before @import "tailwindcss" */
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";

/* Custom design tokens via @theme (Tailwind v4 CSS-first config) */
@theme {
  --color-bg: #111318;
  --color-surface: #1C2028;
  --color-accent: #00D4FF;
  --color-text: #F0F4F8;
  --color-text-muted: #8B95A3;
  --color-border: #2A3040;
  --font-sans: 'Space Grotesk', system-ui, sans-serif;
}

body {
  background-color: #111318;
  color: #F0F4F8;
  font-family: 'Space Grotesk', system-ui, sans-serif;
  margin: 0;
}

#root {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

Gotcha: In Tailwind v4, @import "tailwindcss" expands to real CSS. Any @import url(...) (like Google Fonts) must appear before it or the browser ignores the font import. This caused a CSS warning in the build until we fixed the order.

Project Structure

src/
├── components/
│   ├── Navbar.tsx        # Sticky nav, mobile hamburger, active link highlighting
│   ├── Footer.tsx        # Logo, nav links, LinkedIn + GitHub icons
│   └── SectionHeader.tsx # Reusable section heading (title + subtitle)
├── pages/
│   ├── Home.tsx          # Hero, featured services, cert strip, CTA banner
│   ├── Services.tsx      # Full 6-card service grid
│   ├── Technologies.tsx  # Grouped tech badges + certifications
│   ├── Portfolio.tsx     # Placeholder project cards
│   ├── Team.tsx          # Bio card with photo, certs, social links
│   └── Contact.tsx       # Form → API Gateway fetch
├── data/
│   ├── services.ts       # Service card data (title, description, icon)
│   ├── technologies.ts   # Tech groups + certifications
│   └── team.ts           # Team member data
├── App.tsx               # BrowserRouter + route config
├── main.tsx              # React DOM root
└── index.css             # Global styles + Tailwind v4 config

Data-driven content

Rather than hardcoding content in page components, all repeated content lives in typed data files:

// src/data/services.ts
export interface Service {
  id: string;
  title: string;
  description: string;
  icon: string;
}

export const services: Service[] = [
  {
    id: 'eda-serverless',
    title: 'Event-Driven Architecture & Serverless',
    description: 'Architect decoupled, resilient systems using SQS, SNS, EventBridge, and Lambda...',
    icon: '⚡',
  },
  // ...
];

This makes it trivial to add, update, or reorder content without touching component markup.

Routing

React Router v7 handles client-side routing:

// src/App.tsx
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';

export default function App() {
  return (
    <Router>
      <Navbar />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/services" element={<Services />} />
        <Route path="/technologies" element={<Technologies />} />
        <Route path="/portfolio" element={<Portfolio />} />
        <Route path="/team" element={<Team />} />
        <Route path="/contact" element={<Contact />} />
        <Route path="*" element={<Navigate to="/" replace />} />
      </Routes>
      <Footer />
    </Router>
  );
}

SPA Routing on CloudFront

Because React Router handles navigation client-side, all URLs (/services, /team, etc.) point to the same index.html. When a user navigates directly to https://bonefishsoftware.com/team, S3 returns a 403 (key doesn’t exist). CloudFront must be configured to map 403 and 404 errors back to index.html:

# In website.yml CloudFormation
CustomErrorResponses:
  - ErrorCode: 403
    ResponseCode: 200
    ResponsePagePath: /index.html
    ErrorCachingMinTTL: 0
  - ErrorCode: 404
    ResponseCode: 200
    ResponsePagePath: /index.html
    ErrorCachingMinTTL: 0

Without this, refreshing any non-root page returns a CloudFront error page.

Design System

Color palette (dark charcoal + electric cyan):

Token Hex Usage
bg #111318 Page background
surface #1C2028 Cards, panels
surface-2 #232936 Nested surfaces
accent #00D4FF Cyan highlights, links, active states
text #F0F4F8 Primary text
text-muted #8B95A3 Secondary text, descriptions
border #2A3040 Card borders, dividers

Font: Space Grotesk — a modern geometric sans-serif that feels technical but approachable.

Logo Integration

The company logo is an SVG with a transparent background. The white stripe visible in the logo is part of the Colorado flag design — it’s not a background fill.

Using the SVG directly in the Navbar (instead of PNG) means:

  • Zero white-box artifact on the dark background
  • Infinitely scalable — looks sharp at any size
  • Small file size
// Navbar.tsx
<NavLink to="/">
  <img src="/logo.svg" alt="Bonefish Software" className="h-10 w-auto" />
</NavLink>

The no-text variant (/logo-icon.svg) is used in the footer where horizontal space is tighter.

Production Build

npm run build
# → tsc -b && vite build
# → dist/ (index.html + hashed JS/CSS bundles)

Build output for this site:

dist/index.html                   0.64 kB │ gzip:  0.39 kB
dist/assets/index-[hash].css     22.21 kB │ gzip:  4.91 kB
dist/assets/index-[hash].js     257.69 kB │ gzip: 81.09 kB

The entire site gzips to under 86 kB — fast on any connection.