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.

My Cloud-Native Journey: Docker, Kubernetes, Security, and Open Source

Cloud Native Freiburg and Docker Black Forest

TL;DR: Over the past year, I published Docker and Kubernetes Security, evolved the Docker Black Forest meetup into a CNCF chapter, delivered 15 global engagements across 6 countries, and joined LFX and GSoC as mentor. This post reflects on my journey from Docker Captain to CNCF Ambassador applicant, focused on education through storytelling and building secure supply chains.

In early 2024, I wrote an article titled How to Become a Docker Captain, chronicling my journey from a casual Docker user in 2015 to an official community leader. Today, as a Senior Backend Engineer at JobRad GmbH, a Docker Captain, and a Snyk Ambassador, my perspective on infrastructure has grown. Containers don’t live in a vacuum—they are part of a massive, interconnected cloud-native ecosystem.

As I submit my application for the CNCF Ambassador program, I wanted to take a moment for a mid-year check-in to reflect on the community work, authorship, and open-source contributions I’ve been focused on between May 2025 and May 2026.

Here is what the journey has looked like over the past year.

1. Build a Cloud-Native Community

Back in 2022, I founded the Docker Black Forest meetup because I was looking for a local community to join. Over time, as our discussions expanded beyond containerization into Kubernetes and secure supply chains, I joined forces with the DevOps Meetup Freiburg to evolve into a broader Cloud Native Freiburg chapter. This was a natural progression, as the lines between DevOps, containers, and cloud-native technologies are increasingly blurred.

Cloud Native Freiburg and Docker Black Forest

  • DockBurg.com is the community hub, that brings Cloud Native Freiburg and Docker Black Forest together under one roof.
  • Cloud Native Freiburg is the CNCF Chapter. Since its founding in April 2025, we have had 10 in-person events, with an average attendance of 20 people.
  • Docker Freiburg and Black Forest with about 400 members, 19 events, and a rating of 4.7/5 across ~50 reviews on Meetup.com.
  • DevOps Meetup Freiburg with about 600 members, 25 events, and a rating of 4.7/5 across ~40 reviews on Meetup.com.

And we had some high-profile speakers, including:

  • Lize Raes, Docker Captain, Java Champion, and Developer Advocate at Oracle
  • Timo Stark, Docker Captain and Head of IT
  • Jonas Scholz, Docker Captain and Co-founder of Sliplane


Docker and Kubernetes Security: Implementing Supply Chain Security and Runtime Security for Containers from Development to Production – Docker and Kubernetes Security – Docker and Kubernetes Security

Learn to secure containers and clusters—from supply chain to runtime. This comprehensive guide covers Docker fundamentals, Kubernetes security, CI/CD integration, and future trends.

favicon
dockersecurity.io

2. Write the Book (and Tell a Story)

In October 2025, after nearly two years of writing and 170 git commits, I published my book, Docker and Kubernetes Security. It was an absolute honor to see it nominated as a finalist for the Best DevOps Book of the Year at the DevOps Dozen 2025 awards. To support the book’s educational mission, I also launched DockerSecurity.io as an accompanying platform, where I publish regular updates and made the first two chapters available for free to the community.

Docker and Kubernetes Security book at Docker Captains Summit 2025

But let’s be honest: Security policies can be dry and cause cognitive fatigue. To fix this, I experimented with narrative-driven technical fiction:


Black Forest Shadow — A Dark Fantasy Guide to Docker and Kubernetes Security – Docker and Kubernetes Security – Docker and Kubernetes Security

A dark fantasy novel set in the Black Forest of 1865 that teaches Docker and Kubernetes security through narrative — covering CVE hunting, SBOM generation, runtime hardening, and container security.

favicon
dockersecurity.io

  • In December 2025, I launched the Black Forest Shadows: Container Security Advent Series, publishing 24 sequential parts across DEV.to and Medium.
  • This series utilized an 1865 folklore setting where CVEs were literal monsters, helping junior engineers grasp complex DevSecOps principles.
  • This highly successful experiment is evolving into a full publication titled Black Forest Shadow: A Dark Fantasy Guide to Docker and Kubernetes Security, which was published on Friday the 13th of March 2026.

I was also involved in reviewing Operational AI with Docker by Ajeet Singh Raina and Harsh Manvar, which was published in May 2026. I had the honor of being a technical reviewer for the book, and I shared some exclusive behind-the-scenes insights about it in my book review.

3. Black Forest Commandos: Talks and Workshops

In June 2025, I did a short talk at PlatformCon 2025 about “10 Docker commands you didn’t know about”. It was a mix of AI and security.


Bake a Docker Cake — Talk by Mohammad-Ali A’râbi – Docker and Kubernetes Security – Docker and Kubernetes Security

A PlatformCon talk on 10 lesser-known Docker commands for improving development workflows, vulnerability scanning, supply chain security, and local AI workflows.

favicon
dockersecurity.io

There I talked about Docker Scout, Trivy, Cosign, SBOM attestations, and Docker Bake (hence the name of the talk: Bake a Docker Cake).

In October 2025, I did a workshop version of the same talk at the WeAreDevelopers World Congress in Berlin, which was a huge success. The workshop was attended by 40 people, while 100 more were waiting behind the doors to be let in.


Docker Deep Dive with a Docker Captain — Docker Commandos Workshop – Docker and Kubernetes Security – Docker and Kubernetes Security

The workshop that started it all. Over 100 people queued for 40 seats at WeAreDevelopers World Congress 2025. Covered Docker Init, Docker Bake, SBOMs, attestations, and Docker Scout.

favicon
dockersecurity.io

I did the same internally at JobRad, and called it 10 Docker Commandos, which is a play on “10 Docker commands” (as in German, the word for command is Kommando).

In early 2026, I took the main stage at Jfokus 2026 in Stockholm to teach “Dockerize Securely”.


Dockerize Securely — Talk by Mohammad-Ali A’râbi – Docker and Kubernetes Security – Docker and Kubernetes Security

A Jfokus talk on building secure container images using SBOMs, OCI 1.1 attestations, and Docker Bake, told through the narrative of the Docker Commandos in Asgard.

favicon
dockersecurity.io

I was telling a story about the Black Forest Commandos defending the realm of Asgard against CVE monsters. It was Asgard, because we were in Sweden and the conference was Viking-themed.

On the same week, I turned the talk into a blog post for JAVAPRO, and created 10 original Black Forest Commandos personas, each representing a different Docker command or security tool.

The blog post is published on JAVAPRO’s website:

Later, Rabobank and Docker, Inc. jointly invited me to do a workshop on behalf of Docker at their internal conference in Utrecht, Netherlands. The workshop was attended by 50 people, and the Commandos traveled to Utrecht.


Docker Commandos v1.5 — Docker Commandos Workshop – Docker and Kubernetes Security – Docker and Kubernetes Security

Docker Commandos v1.5 at Rabobank, part of their Docker Champions program. Full supply-chain security pipeline from Docker Init to cryptographic signing and zero-day runtime defense.

favicon
dockersecurity.io

The 10 Commandos are (from left to right):

  1. Gord – Docker Init
  2. Rothütle – Docker SBOM
  3. Jack – Docker Scout
  4. The Valkyrie – SBOM Attestations
  5. Artemisia – Docker Hardened Images
  6. Mina – VEX Exemptions
  7. RuinTan – VEX Attestations
  8. Captain Ahab – Docker Bake
  9. Evie – Cosign
  10. Agent Null – Zero-Day Defense

10 Docker Commandos

The Black Forest Commandos then went to Cologne for JCON Europe 2026, where I used Docker Labspaces to launch the Docker Commandos workshop.


Java Supply Chain Security with Docker — Docker Commandos Workshop – Docker and Kubernetes Security – Docker and Kubernetes Security

Docker Commandos adapted for a Java audience at JCON Europe 2026. Supply chain security, SBOMs, and attestations — using Docker tooling with a Java project as the target.

favicon
dockersecurity.io

docker compose -f oci://docker.io/aerabi/docker-commandos-labspace up -d

The team is called Black Forest Commandos because they are a continuation of the Black Forest Shadows story, but the workshop series are called Docker Commandos being the Dutch and German name meaning “Docker Commands”.

On the stage of DevOpsDays Zurich

The Commandos went on the stage of DevOpsDays Zurich in May 2026 and will be at EnterJS in Mannheim in June 2026, where I will talk about “Defense Against the Dark Arts: NPM Attack”.

Black Forest Commandos are in:


Black Forest Commandos — Narrative-Driven Container Security Workshop – Docker and Kubernetes Security – Docker and Kubernetes Security

A hands-on container security workshop told through the story of 10 commandos fighting CVE monsters in Asgard. Covering SBOMs, attestations, hardened images, VEX, Docker Bake, Cosign, and zero-day defense. (Previously known as Docker Commandos).

favicon
dockersecurity.io

I started doing the comic book as I was asked a few times.

4. Mentor the Next Generation

I started writing in 2021 since I was mentoring a few junior engineers at work, and I realized those answers could be useful for a wider audience. I also wanted to give back to the community that had given me so much. So, mentoring others has always been more educational for me than for the mentees.

Because I joined a team of senior engineers at JobRad, I suddenly found myself without junior colleagues to mentor. To fill that gap, I turned to the open-source world:

  • In 2025, I joined the Linux Foundation Mentorship (LFX) Program. I had the immense privilege of managing a cohort of 24 active mentees, successfully guiding three of them to full graduation.
  • This term, I am acting as a primary mentor for The Linux Foundation during Google Summer of Code (GSoC) 2026.
  • Alongside my co-mentors, I am guiding our mentee through a highly critical engineering project: “CISA 2025 SBOM Conformance and SPDX 3 Support“.

5. Contribute to the Core

I still remember going to my favorite café every Sunday to write. That habit hasn’t died.

  • I am a frequent contributor to DEV.to (76 total articles, 37 in 2025) and Medium (93 total, 31 in 2025), alongside my dedicated posts on DockerSecurity.io/blog.
  • I continue to author the Git Weekly newsletter, which has 500 subscribers and 29 issues published so far.
  • In 2026, I started the Docker Security Dispatch to keep the DevSecOps community updated on container security postures and CVE alerts. With only 2 issues published so far, it has already attracted 400 subscribers.

I’m honored to be on the Docker’s official docs workgroup, where I contribute to the guides and reference documentation. I co-authored the C++ guide and have added the security sections to it.

Impact at a Glance

Since May 2025, I’ve had the privilege of sharing my journey and knowledge across various stages and formats. Here is a summary of my impact:

Speaking & Training Engagements

Between May 2025 and July 2026, I will have delivered a total of 15 engagements across 6 countries and online:

  • 8 Talks 🎤
  • 4 Workshops 🛠️
  • 3 Interviews 🎙️

Locations included: Germany 🇩🇪 (9), Sweden 🇸🇪 (1), Switzerland 🇨🇭 (1), Netherlands 🇳🇱 (1), and Global/Online 🌐 (3).

Legend: 🎤 Talk | 🛠️ Workshop | 🎙️ Interview

Date Title Event / Venue Location
27.06.2025 🎤 Bake a Docker Cake PlatformCon 2025 Online 🌐
08.07.2025 🛠️ Docker Deep Dive with a Docker Captain WeAreDevelopers World Congress Berlin 🇩🇪
25.07.2025 🎙️ Docker Captain, DevSecOps, and Developer Advocacy TACOS Podcast Online 🌐
11.09.2025 🛠️ 10 Docker Commandos JobRad GmbH Freiburg 🇩🇪
01.10.2025 🎤 5 Docker Commandos #cTENcf Birthday Bash Freiburg Freiburg 🇩🇪
06.11.2025 🎤 Node.js Supply Chain Security + dhi Node.js Meetup #46 Berlin 🇩🇪
03.02.2026 🎤 Dockerize Securely: SBOMs + Attestations + Bake Jfokus 2026 Stockholm 🇸🇪
27.03.2026 🛠️ Docker Commandos v1.5 Rabobank Utrecht Utrecht 🇳🇱
20.04.2026 🛠️ Java Supply Chain Security with Docker JCON Europe 2026 Cologne 🇩🇪
20.04.2026 🎙️ Interview with Baruch Sadogursky at JCON Europe JAVAPRO / Tessl Cologne 🇩🇪
24.04.2026 🎙️ Writing a Tech Book: Docker and Kubernetes Security JobRad Podcast: Increase Cycle Time Freiburg 🇩🇪
06.05.2026 🎤 Beyond SBOMs: The Future of Container Supply Chain Security DevOpsDays Zurich 2026 Zurich 🇨🇭
16.06.2026 🎤 Defense Against the Dark Arts: NPM Attack EnterJS 2026 Mannheim 🇩🇪
09.07.2026 🎤 Dockerize Java Securely: SBOMs + Attestations + Bake WeAreDevelopers World Congress Berlin 🇩🇪
10.07.2026 🎤 Beyond SBOMs: The Future of Container Supply Chain Security WeAreDevelopers World Congress Berlin 🇩🇪

Pillars of Impact

Pillar of Impact Key Highlights
Community Leadership Evolving local meetups into the CNCF Cloud Native Freiburg chapter with ~1,000 combined members.
Content & Authorship Publishing Docker and Kubernetes Security, launching DockerSecurity.io, and maintaining multiple blogs and newsletters.
Speaking & Training Delivering the Docker Commandos workshops globally, from Jfokus in Sweden to JCON in Cologne.
Open Source Mentorship Managing 24 LFX mentees and serving as a primary mentor for GSoC 2026.

Conclusion: The Path Ahead

The theme of the year has been Education by Storytelling. It started with the Black Forest Shadows series, evolved into the Black Forest Commandos, and now I’m working on a comic book about the Commandos’ mission to Asgard. There also have been some spin-offs, like the Swiss Jass: Commandos Edition card game or my other article on JAVAPRO:

I’m excited to see what the next year will bring. Perhaps I’ll see you in Black Forest, in Asgard, or maybe at the next KubeCon!

Gord and Rothütle in the Black Forest