<?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: Oleksandr Kuryzhev</title>
    <description>The latest articles on DEV Community by Oleksandr Kuryzhev (@oleksandr_kuryzhev_42873f).</description>
    <link>https://dev.to/oleksandr_kuryzhev_42873f</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3970301%2Fff42dfb6-af2a-4fc7-968a-54326187a691.jpg</url>
      <title>DEV Community: Oleksandr Kuryzhev</title>
      <link>https://dev.to/oleksandr_kuryzhev_42873f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oleksandr_kuryzhev_42873f"/>
    <language>en</language>
    <item>
      <title>ML Anomaly Detection in Loki Logs: Per-Entity Isolation Forest</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Tue, 07 Jul 2026 07:03:06 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/ml-anomaly-detection-in-loki-logs-per-entity-isolation-forest-4hob</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/ml-anomaly-detection-in-loki-logs-per-entity-isolation-forest-4hob</guid>
      <description>&lt;p&gt;Liquid syntax error: Variable '{{namespace="{namespace}' was not properly terminated with regexp: /\}\}/&lt;/p&gt;
</description>
      <category>devops</category>
    </item>
    <item>
      <title>Terraform Drift Detection in CI: Alert-Only vs Auto-Remediate</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Mon, 06 Jul 2026 07:04:52 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/terraform-drift-detection-in-ci-alert-only-vs-auto-remediate-15ib</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/terraform-drift-detection-in-ci-alert-only-vs-auto-remediate-15ib</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/07/06/terraform-drift-detection-in-ci-alert-only-vs-auto-remediate" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;When You Face This Choice&lt;/h2&gt;



&lt;p&gt;You'll know the moment terraform drift detection ci becomes a real problem: someone fixes a security group via the AWS console during an incident, or an intern deletes an IAM policy they thought was unused, and three days later your next &lt;code&gt;terraform plan&lt;/code&gt; shows a wall of diffs nobody can explain. State says one thing, reality says another, and now you're debugging infrastructure archaeology instead of shipping features.&lt;/p&gt;

&lt;p&gt;A few triggers usually force teams to actually pick a strategy instead of winging it: a SOC2 or ISO27001 audit asking "how do you detect unauthorized infrastructure changes," a postmortem where the root cause was "someone changed this manually and nobody knew," a growing pile of workspaces where nobody remembers who owns what, or plain on-call fatigue from Slack pings about diffs that turn out to be nothing.&lt;/p&gt;

&lt;p&gt;Once you hit that point, there's a real fork in the road: do you detect drift and let a human decide what to do about it, or do you detect drift and let the pipeline fix it automatically? "Just run &lt;code&gt;terraform plan&lt;/code&gt; sometimes" is not a strategy — it's a hope. You need a scheduled, structured process either way. The question is how much autonomy you give the pipeline once it finds something.&lt;/p&gt;

&lt;h2&gt;Option A — Scheduled Detect-Only Pipeline (Plan + Notify)&lt;/h2&gt;

&lt;p&gt;This is the boring option, and I mean that as a compliment. A cron-triggered CI job runs &lt;code&gt;terraform plan -detailed-exitcode&lt;/code&gt;, checks the exit code, parses the plan into JSON, and posts a summary to Slack. Nobody applies anything automatically. A human reads the diff, decides if it's real drift or expected divergence, and runs &lt;code&gt;apply&lt;/code&gt; manually if needed.&lt;/p&gt;

&lt;p&gt;Here's a workflow I've used in production across a handful of workspaces:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# .github/workflows/drift-detection.yml
# Scheduled drift-detection job: detects drift, never auto-applies.
name: terraform-drift-detection

on:
  schedule:
    - cron: '0 6 * * *'   # runs daily at 06:00 UTC — stagger per workspace to avoid API throttling
  workflow_dispatch: {}    # allow manual trigger for on-demand checks

jobs:
  drift-check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        workspace: [network, compute, iam]   # shard workspaces to keep each job under 10 min
      fail-fast: false
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.5   # pin exact version, do not use "latest"

      - name: Terraform init
        working-directory: infra/${{ matrix.workspace }}
        run: terraform init -input=false -lock-timeout=60s

      - name: Terraform plan (detect drift)
        id: plan
        working-directory: infra/${{ matrix.workspace }}
        run: |
          set +e
          terraform plan -detailed-exitcode -out=plan.tfplan -lock-timeout=60s
          echo "exitcode=$?" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"
        continue-on-error: true   # exit code 2 must not fail the job, we handle it manually

      - name: Extract structured diff
        if: steps.plan.outputs.exitcode == '2'
        working-directory: infra/${{ matrix.workspace }}
        run: |
          terraform show -json plan.tfplan &amp;gt; plan.json
          jq '[.resource_changes[] | select(.change.actions[0] != "no-op") |
              {address: .address, action: .change.actions[0]}]' plan.json &amp;gt; drift-summary.json

      - name: Notify Slack on drift
        if: steps.plan.outputs.exitcode == '2'
        run: |
          curl -sf -X POST -H 'Content-type: application/json' \
            --data "{\"text\": \"⚠️ Drift detected in *${{ matrix.workspace }}*: $(cat drift-summary.json)\"}" \
            "${{ secrets.SLACK_WEBHOOK_URL }}"

      - name: Fail job to surface drift in CI status
        if: steps.plan.outputs.exitcode == '2'
        run: exit 1   # human still has to review and apply manually — that's the point of Option A
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Note the exit code handling — Terraform 1.6+ made &lt;code&gt;-detailed-exitcode&lt;/code&gt; reliable enough to branch on directly: 0 means no changes, 1 means an error, 2 means drift or pending changes. Don't grep plain-text plan output for this; that format isn't a stable interface across versions and it will break your parser on some future minor release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; blast radius is basically zero, it's trivially auditable for compliance, it works with any backend including plain S3+DynamoDB, and it's the fastest thing to implement — you can have this running by end of day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; drift sits unresolved for hours or days waiting for a human. If the Slack channel gets noisy, people start ignoring it — alert fatigue is real and it happens fast. Someone still has to context-switch out of whatever they're doing to run &lt;code&gt;apply&lt;/code&gt;. And past roughly 20-30 workspaces, you need an actual triage system or this turns into a wall of unread notifications.&lt;/p&gt;

&lt;h2&gt;Option B — Automated Detect-and-Remediate Pipeline (Plan + Gated Auto-Apply)&lt;/h2&gt;

&lt;p&gt;This is the self-healing version. Same detection step, but when drift is found, the pipeline runs &lt;code&gt;terraform apply&lt;/code&gt; automatically — or &lt;code&gt;-refresh-only&lt;/code&gt; if the intent is to accept the drift into state rather than revert it — gated behind policy checks like OPA/Conftest or Atlantis policy sets, instead of waiting on a person.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# drift-summary.sh — parses plan.json into a compact human-readable digest
# Run after `terraform show -json plan.tfplan &amp;gt; plan.json`

jq -r '
  .resource_changes[]
  | select(.change.actions[0] != "no-op")
  | "\(.change.actions[0] | ascii_upcase)\t\(.address)"
' plan.json | column -t -s $'\t'

# Example output:
# UPDATE   aws_security_group.web_sg
# DELETE   aws_iam_role_policy.legacy_policy
# CREATE   aws_route53_record.orphaned_cname

# Gotcha: "delete" here in a drift context usually means "resource exists in
# state/config but was removed manually in the console" — Terraform will try
# to RECREATE it on apply, which is why auto-apply on IAM/DNS is disallowed
# in this pipeline's OPA policy (see policies/no-auto-apply-sensitive.rego).
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; the drift window closes in minutes, not days. It removes a category of toil entirely and reinforces a "config is truth" culture, which is genuinely valuable for stateless or ephemeral infra like autoscaling groups and ECS service definitions where manual tweaks are expected to be transient anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons, and this is where I've seen teams get burned:&lt;/strong&gt; if the plan misclassifies an intentional hotfix as drift, the pipeline will cheerfully revert it. I've watched a scheduled drift job run &lt;code&gt;apply -auto-approve&lt;/code&gt; off a plan artifact nobody reviewed, and it recreated a resource a teammate had manually deleted on purpose — destructively, mid-incident. You need mature policy-as-code guardrails before this is safe, and debugging "why did CI undo what I just did" during an active incident is a genuinely bad time. Blast radius also scales with anything shared modules touch.&lt;/p&gt;

&lt;h2&gt;Decision Matrix&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Criteria&lt;/th&gt;
&lt;th&gt;Option A (Detect-Only)&lt;/th&gt;
&lt;th&gt;Option B (Auto-Remediate)&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Team size &amp;lt;10 engineers&lt;/td&gt;
&lt;td&gt;Recommended&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regulated industry / audit trail needs&lt;/td&gt;
&lt;td&gt;Recommended&lt;/td&gt;
&lt;td&gt;Risky without strong OPA gates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;gt;50 dynamic workspaces&lt;/td&gt;
&lt;td&gt;Doesn't scale alone&lt;/td&gt;
&lt;td&gt;Recommended with policy-as-code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blast radius tolerance: low&lt;/td&gt;
&lt;td&gt;Recommended&lt;/td&gt;
&lt;td&gt;Avoid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On-call maturity: early&lt;/td&gt;
&lt;td&gt;Recommended&lt;/td&gt;
&lt;td&gt;Wait until mature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource type: IAM, DNS, security groups&lt;/td&gt;
&lt;td&gt;Always&lt;/td&gt;
&lt;td&gt;Never auto-apply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource type: ASG desired count, tags&lt;/td&gt;
&lt;td&gt;Fine either way&lt;/td&gt;
&lt;td&gt;Good candidate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The nuance that most decision matrices miss: the right answer often isn't per-team, it's per-resource-type. DNS records and IAM policies should basically never auto-remediate — the cost of a wrong revert is too high. Autoscaling group desired capacity or resource tags are low-stakes and make good candidates for automation. If you're on Terraform Cloud/Enterprise, note that Health Assessments (their built-in drift detection) is a Business-tier feature — not available on free or Team plans, so budget accordingly if you were planning to rely on it out of the box.&lt;/p&gt;

&lt;h2&gt;My Pick&lt;/h2&gt;

&lt;p&gt;I default to detect-only everywhere, full stop. Then I carve out a narrow allowlist of low-risk resource types for auto-remediation once policy checks are actually in place — not before. Full auto-apply-everything is a mistake for roughly 90% of teams I've seen try it, and the failure mode is ugly: a pipeline reverting an SRE's legitimate incident-response change is the kind of incident that generates its own postmortem.&lt;/p&gt;

&lt;p&gt;Detect-only alone isn't a finish line either — drift sitting unresolved for a week because nobody triaged the Slack digest is its own failure mode. So the rollout order matters. Start with scheduled detect-only, wired into GitHub Actions or Atlantis, with a structured Slack digest that includes workspace name, resource address, and action type — flat "drift detected, check logs" messages get ignored within a week, I've seen it happen. Only after that's stable and you have Conftest/OPA policies denying auto-apply on sensitive resource types do you carve out the allowlist for auto-remediation. Skipping the middle step is how teams end up auto-reverting an incident fix at 2am.&lt;/p&gt;

&lt;p&gt;Two practical warnings before you build any of this. First, pin your provider versions in &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; and commit that file — I've seen a CI runner silently pull a newer AWS provider than what generated the last state, producing false-positive drift on every single run because default tag behavior changed between 5.x minors. Second, scope your CI role to least privilege: &lt;code&gt;s3:GetObject&lt;/code&gt; and &lt;code&gt;dynamodb:GetItem&lt;/code&gt; on the specific state bucket and lock table only, never account-wide read access — state files routinely contain plaintext secrets in outputs, and a broad IAM role turns your drift job into a juicy target.&lt;/p&gt;

&lt;p&gt;driftctl is worth a mention as an alternative scanner, but its last stable release was 0.40.0 before maintenance slowed down in 2023 — native &lt;code&gt;terraform plan&lt;/code&gt; is the safer long-term bet. And if you're running this across 200+ workspaces, don't check every 15 minutes; you'll hit AWS API throttling on &lt;code&gt;Describe*&lt;/code&gt; calls and burn CI minutes for nothing. Hourly or staggered daily windows are plenty. For more on structuring larger Terraform repos so this actually scales, see our &lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Terraform automation posts&lt;/a&gt; and the &lt;a href="https://developer.hashicorp.com/terraform/cli/commands/plan#planning-modes" rel="noopener noreferrer"&gt;official Terraform plan documentation&lt;/a&gt; for the full behavior of refresh-only mode and exit codes, plus the &lt;a href="https://www.openpolicyagent.org/docs/latest/" rel="noopener noreferrer"&gt;OPA docs&lt;/a&gt; if you're building the policy gate for Option B.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;More Terraform patterns for scaling infra repos&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/ci-cd/" rel="noopener noreferrer"&gt;CI/CD pipeline design and gating strategies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/aws/" rel="noopener noreferrer"&gt;AWS automation, IAM scoping, and cost patterns&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building an Online Anomaly Scoring Sidecar for Grafana Alerts</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sun, 05 Jul 2026 08:44:15 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/building-an-online-anomaly-scoring-sidecar-for-grafana-alerts-3ckj</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/building-an-online-anomaly-scoring-sidecar-for-grafana-alerts-3ckj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/07/05/building-an-online-anomaly-scoring-sidecar-for-grafana-alerts" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Your "AI-powered" anomaly alerts are probably flapping every 60 seconds because nobody set a &lt;code&gt;for&lt;/code&gt; duration on the rule. We hit this exact problem three months ago when a client asked us to add "smart alerting" to their checkout service dashboards. What they got, at first, was a Slack channel firing and resolving the same alert every evaluation cycle — Grafana's default is 1 minute. AI anomaly detection Grafana integrations get sold as magic, but the underlying pipeline is closer to a stats class than a neural network, and treating it otherwise is where teams get burned.&lt;/p&gt;

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



&lt;p&gt;Strip away the marketing and the pipeline looks like this: metrics come in from Prometheus, Mimir, or Loki, get aggregated into a feature (rate, count, ratio), get scored by a model, and the score itself becomes a new time series that Grafana alerts on like anything else. That's it. No LLM is involved, and it doesn't need to be — this is time-series forecasting and outlier detection, not natural language generation.&lt;/p&gt;

&lt;p&gt;There are three real tiers of implementation, and picking the wrong one for your scale is the first mistake teams make. Grafana's built-in &lt;code&gt;anomaly detection&lt;/code&gt; transformation, available since Grafana 10.x, uses Seasonal-ESD under the hood — no external service needed, and it's fine for a handful of dashboards. The middle tier is lightweight ML: Prophet or Kats running as a sidecar, doing seasonal forecasting on a schedule. The heavy tier is a proper serving pipeline — models exported to ONNX, served via ONNX Runtime or TorchServe, queried by a custom datasource or Alertmanager webhook.&lt;/p&gt;

&lt;p&gt;Most infra metrics don't need Prophet. It's a heavyweight forecasting library built for business time series with strong seasonality — daily active users, sales curves. Applying it to CPU or request-rate metrics with 15-second scrape intervals is like using a sledgehammer to hang a picture frame. A z-score or exponentially weighted moving average gets you 90% of the value with a fraction of the compute cost. I stopped recommending Prophet for infra alerting after watching a Kats install drag in pandas 1.x and break a container image that already had pandas 2.x pinned for a different service — version conflicts you don't need in an alerting hot path.&lt;/p&gt;

&lt;h2&gt;How people use it wrong&lt;/h2&gt;

&lt;p&gt;Mistake one: feeding raw, unaggregated metrics into a global model. Per-pod CPU during a rolling deploy or a horizontal autoscale event looks exactly like an anomaly to any outlier detector — because it is one, just not the kind you care about. Aggregate to service level first. Score the aggregate, not the noise underneath it.&lt;/p&gt;

&lt;p&gt;Mistake two: no hysteresis. Grafana Alerting evaluates rules on every interval, default one minute, and if your alert condition is "anomaly score above 0.75, fire immediately," you'll get an alert that fires and resolves within the same minute because the model recomputes the score on every scrape and it dances around the threshold. This is the single most common complaint I've seen in postmortems for these setups — an on-call engineer gets paged, checks the dashboard, sees green, and the alert already resolved itself. That erodes trust in the whole system faster than a false negative would.&lt;/p&gt;

&lt;p&gt;Mistake three: naive scheduled retraining. If you retrain your model every night at 2am without checking for seasonality, you'll get weeks of false positives around month-end batch jobs, Black Friday, or payroll runs — anything that's a recurring but infrequent pattern gets treated as noise the model hasn't seen yet, then flagged as anomalous every single time it recurs. We watched this happen at a fintech client where the batch settlement job triggered "critical" anomaly alerts for three consecutive month-ends before anyone connected the dots.&lt;/p&gt;

&lt;h2&gt;The correct approach&lt;/h2&gt;

&lt;p&gt;Separate concerns. Don't try to do inference inside Grafana — it's a visualization and alerting layer, not a model server. Run a dedicated scoring service instead. Here's a sidecar using &lt;a href="https://riverml.xyz/latest/" rel="noopener noreferrer"&gt;River&lt;/a&gt;, an online machine learning library that updates incrementally without full retrains, which matters a lot for infra metrics that drift slowly over time:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# anomaly_scorer.py — lightweight sidecar that reads Prometheus,
# computes an online anomaly score, and pushes it back via remote_write
import time
import requests
from river import anomaly, preprocessing
from prometheus_client import CollectorRegistry, Gauge, push_to_gateway

PROM_QUERY_URL = "http://prometheus:9090/api/v1/query"
PUSHGATEWAY_URL = "http://pushgateway:9091"
SERVICE_LABEL = "checkout"
QUERY = 'rate(http_requests_total{service="checkout"}[1m])'

# online model: scales input then flags outliers incrementally (no batch retrain needed)
model = preprocessing.StandardScaler() | anomaly.HalfSpaceTrees(
    n_trees=25, height=8, window_size=250, seed=42
)

registry = CollectorRegistry()
score_gauge = Gauge(
    "anomaly_score", "Online anomaly score", ["service"], registry=registry
)

def fetch_metric():
    resp = requests.get(PROM_QUERY_URL, params={"query": QUERY}, timeout=5)
    resp.raise_for_status()
    result = resp.json()["data"]["result"]
    if not result:
        return None
    return float(result[0]["value"][1])

def run():
    while True:
        value = fetch_metric()
        if value is not None:
            x = {"value": value}
            score = model.score_one(x)   # anomaly score, higher = more anomalous
            model.learn_one(x)           # incremental update, no full retrain

            # normalize score to 0-1 range for consistent alert thresholds
            normalized = min(max(score, 0.0), 1.0)
            score_gauge.labels(service=SERVICE_LABEL).set(normalized)

            # push to gateway so Prometheus scrapes it like any other metric
            push_to_gateway(PUSHGATEWAY_URL, job="anomaly_scorer", registry=registry)

        time.sleep(15)  # match scrape interval to avoid stale scores

if __name__ == "__main__":
    run()
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The anomaly score becomes a plain Prometheus metric — &lt;code&gt;anomaly_score{service="checkout"} 0.87&lt;/code&gt; — so Grafana alerts on it exactly like it would alert on request latency. Then the alert rule uses a rolling average, not the instantaneous value, and a &lt;code&gt;for&lt;/code&gt; duration to require a sustained breach:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# provisioning/alerting/rules.yaml — Grafana-as-code alert rule
# reads the anomaly_score metric, requires sustained breach (no flapping),
# and combines with an SLO burn-rate check via Alertmanager routing
apiVersion: 1
groups:
  - orgId: 1
    name: infra-anomaly-alerts
    folder: AI-Anomaly-Detection
    interval: 1m
    rules:
      - uid: anomaly-checkout-score
        title: "Checkout service anomaly score elevated"
        condition: C
        data:
          - refId: A
            datasourceUid: prometheus
            model:
              expr: avg_over_time(anomaly_score{service="checkout"}[5m])
          - refId: C
            datasourceUid: __expr__
            model:
              type: threshold
              expression: A
              conditions:
                - evaluator:
                    type: gt
                    params: [0.75]
        for: 5m   # sustained breach required, prevents flapping on single spikes
        labels:
          severity: warning
          model_version: v3
        annotations:
          summary: "Anomaly score {{ $values.A }} exceeded 0.75 for 5m on checkout"
          runbook_url: "https://kuryzhev.cloud/runbooks/anomaly-checkout"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Watch out: version-pin your model artifacts. A &lt;code&gt;model_v3.onnx&lt;/code&gt; file with a SHA256 checksum committed alongside the deployment manifest turns your alert behavior into something reproducible and auditable. Treat it like infrastructure code, not a throwaway notebook experiment — because when someone asks "why did this fire at 3am," you need an answer better than "the model changed."&lt;/p&gt;

&lt;h2&gt;Advanced patterns&lt;/h2&gt;

&lt;p&gt;Once you're past a single service, run per-service models with labels — &lt;code&gt;{service="checkout", model_version="v3"}&lt;/code&gt; — so dashboards and alert rules template cleanly across teams without duplicating pipelines. Each team owns their model version, and rollbacks are just a label change.&lt;/p&gt;

&lt;p&gt;Ensemble alerting cuts false positives dramatically. Combine the anomaly score with a static SLO burn-rate alert using Alertmanager's nested routing tree — require both to fire before paging. A rough sketch: a parent route matches &lt;code&gt;severity=warning&lt;/code&gt; and routes to two child branches, one matching &lt;code&gt;alertname=AnomalyScore&lt;/code&gt; and one matching &lt;code&gt;alertname=SLOBurnRate&lt;/code&gt;, with a receiver that only triggers when both are active in the same group. This is the pattern that finally got our fintech client's false-positive rate down from daily noise to a handful of real pages a month.&lt;/p&gt;

&lt;p&gt;Close the loop with feedback. Grafana OnCall logs acknowledge/dismiss actions on incidents — capture those as labeled ground truth and feed them into periodic supervised fine-tuning of your model. This is the difference between a static anomaly detector and one that actually learns your service's quirks over time, including the seasonal patterns that caused mistake three above.&lt;/p&gt;

&lt;h2&gt;Performance notes&lt;/h2&gt;

&lt;p&gt;Keep inference under 200ms per series. Grafana evaluates alert rules synchronously per interval, and the default evaluation timeout is 30 seconds — if your scoring datasource is slow, you'll see &lt;code&gt;evaluation failed: context deadline exceeded&lt;/code&gt; in the Grafana logs, and that's a symptom worth taking seriously before it becomes a missed page. &lt;a href="https://onnxruntime.ai/docs/" rel="noopener noreferrer"&gt;ONNX Runtime&lt;/a&gt; 1.16+ handles small forecasting or outlier models comfortably within that budget.&lt;/p&gt;

&lt;p&gt;Cost matters more than people expect. Streaming per-service inference at 15-second resolution across 200+ services can multiply active series count 3-5x in Mimir or Cortex, since every score is a new time series with its own cardinality. Batch retraining hourly instead of continuously streaming updates is usually the right tradeoff — you lose a bit of freshness, you keep your storage bill sane.&lt;/p&gt;

&lt;p&gt;Scope the scoring service's credentials narrowly. It needs read access to raw metrics and write access to push scores back — that's real blast radius if the token leaks. Use a dedicated remote_write tenant or namespace rather than an admin-level API key. A sidecar with broad Prometheus access and network exposure is exactly the kind of thing that becomes a lateral-movement vector during an incident, and nobody wants to explain that in a postmortem. If you're running Grafana in HA, also double-check that the anomaly_score metric is computed consistently across replicas — inconsistent scoring per instance produces alert state that looks like a bug even when the pipeline is technically working. More on wiring alerting pipelines end to end is covered in our &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS observability posts&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/monitoring/" rel="noopener noreferrer"&gt;More Prometheus, Loki, and Grafana alerting deep-dives&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/python/" rel="noopener noreferrer"&gt;Python automation patterns for infra tooling and sidecars&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/kubernetes/" rel="noopener noreferrer"&gt;Kubernetes patterns for running observability sidecars in production&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>monitoring</category>
      <category>devops</category>
    </item>
    <item>
      <title>Grafana Alerting Checklist: Wiring AI Anomaly Scores Correctly</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sat, 04 Jul 2026 07:52:40 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/grafana-alerting-checklist-wiring-ai-anomaly-scores-correctly-16ck</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/grafana-alerting-checklist-wiring-ai-anomaly-scores-correctly-16ck</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/07/04/grafana-alerting-checklist-wiring-ai-anomaly-scores-correctly" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;Why This Checklist&lt;/h2&gt;



&lt;p&gt;Three days. That's how long a memory leak crept through one of our checkout pods before anyone noticed — because our CPU-at-80% alert never fired. The leak was slow, gradual, and stayed well under every static threshold we had. When we finally caught it, it was a customer complaint that tipped us off, not Prometheus. That incident is the reason we built a Grafana AI anomaly detection pipeline, and it's the reason this checklist exists.&lt;/p&gt;

&lt;p&gt;Static thresholds work fine for hard failures — disk full, service down, 500s spiking. They fail at catching slow-burn, multivariate drift: memory creeping up 2% a day, disk I/O latency inching from 4ms to 40ms over a week, a subtle shift in request pattern that only looks wrong when you compare it against the last 30 days of baseline. That's exactly the class of problem anomaly-detection models are good at — they learn "normal" for a given service and flag statistical deviation, not just a fixed number.&lt;/p&gt;

&lt;p&gt;But here's the catch nobody tells you upfront: wiring an ML model's output into an alerting system is its own project, separate from building the model. You're not just training Prophet or PyOD on a metric — you're turning a float between 0 and 1 into a reliable page that doesn't wake someone up for nothing. Alert fatigue is real, and a poorly-tuned anomaly pipeline generates more noise than the static rules it replaced. The setup cost is only worth it if you're running a large fleet, dealing with seasonal traffic, or managing multi-tenant systems where "normal" varies by customer.&lt;/p&gt;

&lt;p&gt;This checklist assumes you already have a trained model (Prophet, PyOD, or Grafana's own &lt;a href="https://grafana.com/docs/grafana-cloud/alerting-and-irm/machine-learning/" rel="noopener noreferrer"&gt;Machine Learning plugin&lt;/a&gt;) producing a score. What it covers is everything between "the model outputs a number" and "the right person gets paged with useful context."&lt;/p&gt;

&lt;h2&gt;The Checklist&lt;/h2&gt;

&lt;p&gt;We run through this list every time we onboard a new service into the anomaly pipeline. Skipping any of these steps is how you end up with silent gaps or 3am pages for nothing.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirm scrape interval parity.&lt;/strong&gt; The anomaly exporter's scoring cadence must match or be a clean multiple of the raw metric's &lt;code&gt;scrape_interval&lt;/code&gt;. Mismatched intervals create phantom gaps that Grafana reads as "no data."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check Prometheus retention.&lt;/strong&gt; Your model needs enough history to establish a baseline — usually 30+ days for weekly seasonality. Verify &lt;code&gt;--storage.tsdb.retention.time&lt;/code&gt; covers it before you even start training.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide on the output channel.&lt;/strong&gt; Push scores via &lt;code&gt;remote_write&lt;/code&gt; or expose them on a scrape endpoint. We use the latter — a small exporter that Prometheus scrapes like any other target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalize the score.&lt;/strong&gt; Never alert on raw model output directly — z-score or min-max normalize it first, or your thresholds drift with seasonal traffic changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provision the Grafana data source&lt;/strong&gt; for the metric that carries your anomaly score (usually the same Prometheus instance, different metric name).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match labels exactly.&lt;/strong&gt; The anomaly score series needs the same &lt;code&gt;service&lt;/code&gt;, &lt;code&gt;instance&lt;/code&gt;, and &lt;code&gt;job&lt;/code&gt; labels as the underlying metric, or Grafana can't correlate them on one panel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the alert rule as code&lt;/strong&gt; under &lt;code&gt;/etc/grafana/provisioning/alerting/&lt;/code&gt; — never click-ops this in production. GitOps or nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set an appropriate &lt;code&gt;evaluate every&lt;/code&gt;.&lt;/strong&gt; It should match your model's scoring cadence (e.g., 5m for batch scoring), not the raw scrape interval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;for:&lt;/code&gt; pending duration.&lt;/strong&gt; 5 minutes is our default — enough to filter single-point noise without delaying real incidents too much.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route through a notification policy&lt;/strong&gt; with label matchers so anomaly alerts land in the right Slack channel or PagerDuty service, not a generic firehose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the contact point manually&lt;/strong&gt; after every rule change, using the "Test" button or &lt;code&gt;grafana-cli&lt;/code&gt;. Silent misconfiguration is the most common failure mode we've seen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a dashboard panel&lt;/strong&gt; showing raw metric and anomaly score side by side — you need this for triage and for validating the model later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inject a synthetic anomaly.&lt;/strong&gt; Push a fake spike into the metric and confirm the alert fires within your expected latency window before calling the setup done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the actual stack we run for this, minus the model training pipeline. The exporter runs the trained model against Prometheus data and exposes a score:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# docker-compose.yml — anomaly exporter + Prometheus + Grafana stack
version: "3.8"
services:
  anomaly-exporter:
    image: myorg/anomaly-exporter:1.4.2   # pinned version, avoid :latest
    ports:
      - "127.0.0.1:9187:9187"             # bind to localhost only, internal scrape
    environment:
      - MODEL_PATH=/models/prophet_v3.pkl
      - SCORE_INTERVAL_SECONDS=300        # batch scoring every 5m, cost control
      - PROMETHEUS_URL=http://prometheus:9090
    volumes:
      - ./models:/models:ro
    restart: unless-stopped

  prometheus:
    image: prom/prometheus:v2.53.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prom_data:/prometheus
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana:10.4.2
    ports:
      - "3000:3000"
    volumes:
      - ./provisioning:/etc/grafana/provisioning   # alert rules + datasources as code
      - grafana_data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=changeme
      - GF_UNIFIED_ALERTING_ENABLED=true

volumes:
  prom_data:
  grafana_data:
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And the alert rule that consumes the score, provisioned as code rather than clicked into existence in the UI:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# provisioning/alerting/rules.yaml — Grafana alert rule for anomaly score
apiVersion: 1
groups:
  - orgId: 1
    name: infra-anomaly-group
    folder: AI-Anomaly-Detection
    interval: 5m                     # must match SCORE_INTERVAL_SECONDS above
    rules:
      - uid: anomaly-checkout-cpu
        title: "Anomaly Score - Checkout Service CPU"
        condition: C
        data:
          - refId: A
            queryType: ""
            datasourceUid: prometheus-uid
            model:
              expr: anomaly_score{service="checkout", metric="cpu"}
          - refId: C
            queryType: ""
            datasourceUid: __expr__
            model:
              type: threshold
              expression: A
              conditions:
                - evaluator:
                    type: gt
                    params: [0.8]      # normalized z-score threshold, not raw model output
        for: 5m                       # pending period to avoid single-point noise
        labels:
          severity: warning
        annotations:
          summary: "Anomaly detected in checkout service CPU pattern"
          runbook_url: "https://kuryzhev.cloud/runbooks/anomaly-checkout"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Full syntax for unified alerting provisioning is documented in the &lt;a href="https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/" rel="noopener noreferrer"&gt;Grafana alerting provisioning docs&lt;/a&gt; — worth bookmarking, since the schema shifts slightly between minor versions.&lt;/p&gt;

&lt;h2&gt;Commonly Missed Items&lt;/h2&gt;

&lt;p&gt;Even teams that follow the checklist above hit the same handful of gotchas. Here are the ones that bit us hardest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for timestamp mismatches.&lt;/strong&gt; If your anomaly exporter timestamps its output differently than Prometheus scrapes the raw metric — even by a few seconds consistently — Grafana will show gaps that look like "no data" rather than a healthy series. We lost half a day debugging what looked like a broken pipeline; it was just clock drift between the exporter container and the Prometheus host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version-pin everything, always.&lt;/strong&gt; We once let the anomaly exporter float on &lt;code&gt;:latest&lt;/code&gt;, and a routine image rebuild silently changed the model's normalization window. Alert behavior shifted overnight with zero deploy log entry to explain it. Pin the image tag, pin the model file name, and log the model version in the alert annotation so on-call knows exactly what's scoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give new hosts a cold-start grace period.&lt;/strong&gt; A pod that just spun up has no baseline history — every metric looks "anomalous" simply because there's nothing to compare against. We now suppress anomaly alerts for the first two hours after a pod's first-seen timestamp. Skip this and you'll get paged every time autoscaling adds a node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set up dedup and grouping in your notification policy.&lt;/strong&gt; One root-cause anomaly — say, a bad deploy — often triggers correlated score spikes across CPU, memory, and latency simultaneously. Without grouping by &lt;code&gt;service&lt;/code&gt; in your notification policy, that's four pages instead of one. I stopped trusting default notification policies after our on-call got seven pings in ninety seconds for a single deploy rollback.&lt;/p&gt;

&lt;p&gt;One more that's easy to overlook: securing the exporter endpoint. Anomaly score exporters running on ports like &lt;code&gt;:9187&lt;/code&gt; should be internal-only and scraped with a token or mTLS. A raw confidence score leaking to the outside world tells an attacker more about your infra's normal behavior than you'd want them to have.&lt;/p&gt;

&lt;h2&gt;Automation Ideas&lt;/h2&gt;

&lt;p&gt;Manually maintaining an anomaly pipeline doesn't scale past a handful of services. Here's what we automated once the pattern proved itself.&lt;/p&gt;

&lt;p&gt;We run a nightly CI job (GitHub Actions) that retrains the model against a rolling 90-day window and validates it against a holdout dataset before promoting new normalization thresholds. If the false-positive rate on the holdout exceeds roughly 5%, the pipeline fails the build instead of shipping a worse model — this single gate saved us from at least two bad retrains that would have flooded on-call.&lt;/p&gt;

&lt;p&gt;Alert rules and contact points live in Terraform, synced to the anomaly exporter's service config in the same repo. That way a new microservice onboarding into the anomaly pipeline is a single PR: add the exporter config, add the Grafana rule block, merge, done. No manual clicking through the Grafana UI at 2am to add a rule for a service that just went live.&lt;/p&gt;

&lt;p&gt;We also template runbook links directly into alert annotations — the PagerDuty notification includes which model version fired, when it was last retrained, and which features drove the score. It's a small thing, but it cuts investigation time significantly when someone unfamiliar with the model is the one paged.&lt;/p&gt;

&lt;p&gt;Last piece: a feedback loop. When someone marks an anomaly alert as a false positive in Grafana, a small script logs that window into a training-exclusion dataset consumed by the next retrain cycle. Without this, the same seasonal blip (Black Friday traffic, month-end batch jobs) keeps triggering the same false alarm every cycle. We wrote more about building these kinds of self-correcting alerting loops in our &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS notes&lt;/a&gt;, if you want the broader pattern applied outside of anomaly detection specifically.&lt;/p&gt;

&lt;p&gt;None of this replaces good judgment — a Grafana AI anomaly detection setup is still a tool, not an oracle. But wired correctly, with the checklist above, it catches the slow-burn failures that static thresholds are structurally blind to, and it does so without turning your on-call rotation into a noise-cancellation exercise.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/monitoring/" rel="noopener noreferrer"&gt;More on building alerting pipelines and reducing noise with Prometheus and Grafana&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/kubernetes/" rel="noopener noreferrer"&gt;Kubernetes health checks and self-healing patterns for production clusters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Provisioning alerting and infra config as code with Terraform&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>monitoring</category>
      <category>devops</category>
    </item>
    <item>
      <title>Ansible Dynamic Inventory for EC2 Fleets: 7 Tuning Tips</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Thu, 02 Jul 2026 07:01:42 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/ansible-dynamic-inventory-for-ec2-fleets-7-tuning-tips-4a9o</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/ansible-dynamic-inventory-for-ec2-fleets-7-tuning-tips-4a9o</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/07/02/ansible-dynamic-inventory-for-ec2-fleets-7-tuning-tips" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;We were three minutes into a patch run against 600 EC2 instances when the play just... stopped. The error was &lt;code&gt;botocore.exceptions.ClientError: RequestLimitExceeded&lt;/code&gt;, and it took us a while to realize the problem wasn't the patch playbook — it was Ansible dynamic inventory for EC2 re-querying the API on every host loop iteration. If you're running &lt;code&gt;amazon.aws.aws_ec2&lt;/code&gt; against anything larger than a handful of instances, these are the fixes that actually matter.&lt;/p&gt;

&lt;h2&gt;Cache Your Dynamic Inventory or Pay for It in API Throttling&lt;/h2&gt;



&lt;p&gt;&lt;strong&gt;Uncached inventory calls hammer the EC2 API on every single playbook run, and large fleets pay for it in throttling.&lt;/strong&gt; The &lt;code&gt;aws_ec2&lt;/code&gt; plugin makes a fresh &lt;code&gt;DescribeInstances&lt;/code&gt; call every time it builds inventory, and on fleets of 500+ instances that's slow and, eventually, rate-limited. Enable the jsonfile cache plugin with a sane &lt;code&gt;cache_timeout&lt;/code&gt; so you're not re-fetching the same instance list for every CI job or ad-hoc command in a five-minute window.&lt;/p&gt;

&lt;p&gt;The tradeoff is real: a longer cache timeout means you might target stale hosts during an active autoscaling event, but a too-short timeout brings back the throttling. We settled on 300 seconds for steady-state patching and drop it to 30-60 seconds during active scale-out windows.&lt;/p&gt;

&lt;h2&gt;Use keyed_groups Instead of Manual Group Definitions&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stop maintaining static host groups by hand — let tags build them for you.&lt;/strong&gt; The &lt;code&gt;keyed_groups&lt;/code&gt; option auto-creates groups from instance tags and state, so a new instance tagged &lt;code&gt;Role: web&lt;/code&gt; shows up in &lt;code&gt;role_web&lt;/code&gt; without anyone touching an inventory file. This is the single biggest maintenance win once you're managing infrastructure across multiple AWS accounts or regions.&lt;/p&gt;

&lt;p&gt;Watch out: tag keys are case-sensitive. We had instances tagged &lt;code&gt;Environment: Prod&lt;/code&gt; next to others tagged &lt;code&gt;environment: prod&lt;/code&gt; from an older Terraform module, and half the fleet silently vanished from the &lt;code&gt;env_prod&lt;/code&gt; group — no error, no warning, just missing hosts. Standardize your tagging convention before you lean on &lt;code&gt;keyed_groups&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Filter at the API Level, Not in Playbook Logic&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Filtering with &lt;code&gt;when:&lt;/code&gt; conditions after inventory is already built wastes API calls and slows every run down.&lt;/strong&gt; If you fetch 3,000 instances across all regions and then filter down to 40 with a task-level condition, you've already paid the cost of describing every terminated, stopped, and unmanaged instance in the account. Push the filter into the inventory config itself with &lt;code&gt;filters&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the mistake we see most often on teams migrating from static inventory files: they keep the old "loop and skip" logic instead of trusting the plugin's native filtering. Filtering by &lt;code&gt;instance-state-name: running&lt;/code&gt; and a &lt;code&gt;ManagedBy&lt;/code&gt; tag at the API level cuts inventory build time dramatically and keeps you from accidentally targeting a stopped or terminated instance.&lt;/p&gt;

&lt;h2&gt;Patch in Batches with serial and max_fail_percentage&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Without &lt;code&gt;serial&lt;/code&gt;, Ansible runs against 100% of your fleet in parallel — one bad patch or AMI takes down everything at once.&lt;/strong&gt; Set &lt;code&gt;serial: "20%"&lt;/code&gt; combined with &lt;code&gt;max_fail_percentage: 10&lt;/code&gt; so a failing batch halts the rollout before it reaches the whole fleet. This is the difference between "20% of web servers rebooted into a broken kernel" and "the entire tier is down."&lt;/p&gt;

&lt;p&gt;Pair this with ASG lifecycle hooks (&lt;code&gt;autoscaling:EC2_INSTANCE_TERMINATING&lt;/code&gt;) so instances aren't yanked out from under a play mid-patch during a scale-in event. I stopped running unbatched patch playbooks after a security update rebooted an entire ASG simultaneously and took our checkout flow down for four minutes — it's not a risk worth taking twice.&lt;/p&gt;

&lt;h2&gt;Don't Hardcode Credentials — Use Instance Roles for Inventory Lookups&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An IAM instance role beats a static access key every time, especially for something as routine as inventory lookups.&lt;/strong&gt; Attach a least-privilege role to your bastion or control node scoped to &lt;code&gt;ec2:DescribeInstances&lt;/code&gt;, &lt;code&gt;ec2:DescribeTags&lt;/code&gt;, and &lt;code&gt;ec2:DescribeInstanceStatus&lt;/code&gt; — nothing more. There's no legitimate reason for the inventory plugin to have write access to anything.&lt;/p&gt;

&lt;p&gt;Never commit &lt;code&gt;aws_access_key&lt;/code&gt; or &lt;code&gt;aws_secret_key&lt;/code&gt; into &lt;code&gt;aws_ec2.yml&lt;/code&gt;, even in a "private" repo. If you need multiple profiles, use the &lt;code&gt;AWS_PROFILE&lt;/code&gt; environment variable or role assumption via &lt;code&gt;AWS_ROLE_ARN&lt;/code&gt;, not plaintext credentials sitting next to your playbooks. Check the &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html" rel="noopener noreferrer"&gt;AWS IAM roles documentation&lt;/a&gt; if you're still wiring up instance profiles for this.&lt;/p&gt;

&lt;h2&gt;Verify Inventory Before You Patch — ansible-inventory --graph Is Your Sanity Check&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run &lt;code&gt;ansible-inventory --graph&lt;/code&gt; before every patch job, not after something goes wrong.&lt;/strong&gt; It's a thirty-second command that catches empty groups from a bad region filter or a typo'd tag key before you waste a play against zero hosts — or worse, against the wrong hosts because your filter was too loose.&lt;/p&gt;

&lt;p&gt;A common assumption that trips people up: dynamic inventory does not refresh automatically when an ASG scales out. It's a snapshot taken at the start of the playbook run, not a live feed. If you scale from 10 to 40 instances mid-deploy, those new 30 won't appear until the next run pulls a fresh snapshot — plan your patch windows accordingly, and re-run &lt;code&gt;--graph&lt;/code&gt; if you suspect the fleet changed underneath you. See the &lt;a href="https://docs.ansible.com/ansible/latest/collections/amazon/aws/aws_ec2_inventory.html" rel="noopener noreferrer"&gt;amazon.aws aws_ec2 inventory plugin docs&lt;/a&gt; for the full option list.&lt;/p&gt;

&lt;h2&gt;Handle Fleet Churn: Compose ansible_host and Skip Terminating Instances&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Public DNS names lag behind instance launch, so use &lt;code&gt;compose&lt;/code&gt; to force &lt;code&gt;ansible_host&lt;/code&gt; to the private IP instead.&lt;/strong&gt; A freshly-launched instance from an ASG scale-out event often doesn't have a resolvable public DNS record yet, and Ansible will hang or fail trying to connect. Setting &lt;code&gt;ansible_host: private_ip_address&lt;/code&gt; in &lt;code&gt;compose&lt;/code&gt; sidesteps that entirely, assuming your control node has network access via VPC peering or a VPN.&lt;/p&gt;

&lt;p&gt;During scale-in events, exclude instances that are mid-termination using a lifecycle tag set by your ASG termination hook, combined with the &lt;code&gt;instance-state-name: running&lt;/code&gt; filter. Otherwise you'll get intermittent &lt;code&gt;UNREACHABLE&lt;/code&gt; failures in your patch run that have nothing to do with the patch itself — they're just instances that disappeared between inventory build and task execution.&lt;/p&gt;

&lt;p&gt;Here's the full inventory config we run in production, tying all of the above together:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# inventories/aws_ec2.yml
# Dynamic inventory config for aws_ec2 plugin (amazon.aws &amp;gt;= 7.6.0)
plugin: amazon.aws.aws_ec2

# Restrict regions explicitly — omitting this queries ALL AWS regions
regions:
  - us-east-1
  - eu-west-1

# Only include running instances tagged as ansible-managed
filters:
  instance-state-name: running
  "tag:ManagedBy": ansible

# Cache results to avoid RequestLimitExceeded on large fleets
cache: true
cache_plugin: jsonfile
cache_connection: /tmp/ansible_inventory_cache
cache_timeout: 300           # seconds; tune lower during active scaling events
cache_prefix: aws_ec2_prod

# Don't fail hard if some instances lack expected tags (mixed fleets)
strict: false

# Build groups automatically from tags instead of static host files
keyed_groups:
  - key: tags.Environment
    prefix: env
  - key: tags.Role
    prefix: role
  - key: "instance_state.name"
    prefix: state

# Use private IP instead of public DNS to avoid resolution lag
# on freshly-launched instances during ASG scale-out
compose:
  ansible_host: private_ip_address
  ansible_user: "'ec2-user'"

# Skip instances that are mid-termination (lifecycle tag set by ASG hook)
exclude_hosts_pattern: "tag_lifecycle_terminating"
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Install the required collection version first — the &lt;code&gt;aws_ec2&lt;/code&gt; plugin behavior described here needs amazon.aws 7.x or later:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
ansible-galaxy collection install amazon.aws:7.6.0
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And here's the pre-flight check plus the actual batched patch run, output included so you know what a healthy run looks like:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# Verify inventory before running a patch playbook — catch empty groups early
$ ansible-inventory -i inventories/aws_ec2.yml --graph

@all:
  |--@env_prod:
  |  |--@role_web:
  |  |  |--ip-10-0-1-23
  |  |  |--ip-10-0-1-45
  |--@state_running:
  |  |--ip-10-0-1-23
  |  |--ip-10-0-1-45

# Patch playbook run with batched rollout to limit blast radius
$ ansible-playbook -i inventories/aws_ec2.yml patch_fleet.yml \
    --limit "role_web:&amp;amp;env_prod" \
    -e "serial='20%'" \
    -e "max_fail_percentage=10"

PLAY [Patch web fleet] ****************************************
TASK [Apply security updates] *********************************
changed: [ip-10-0-1-23]
changed: [ip-10-0-1-45]

PLAY RECAP *******************************************************
ip-10-0-1-23  : ok=3  changed=1  unreachable=0  failed=0
ip-10-0-1-45  : ok=3  changed=1  unreachable=0  failed=0
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;None of these fixes are exotic — cache your inventory, filter at the API level, batch your rollouts, and verify before you run. But together they're the difference between a dynamic inventory setup that quietly scales with your fleet and one that throttles, misses hosts, or takes down a whole tier during a routine patch cycle. If you're building out your broader AWS automation stack around this, our &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS&lt;/a&gt; archive has more patterns for treating infrastructure as continuously reconciled state rather than a one-off script.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/aws/" rel="noopener noreferrer"&gt;More AWS automation and EC2 fleet management patterns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/ansible/" rel="noopener noreferrer"&gt;Ansible playbook and inventory tuning guides&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;IAM least-privilege and credential hardening practices&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>Cloudflare Workers Rate Limiting: 3 KV Mistakes We Made</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Wed, 01 Jul 2026 07:55:57 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/cloudflare-workers-rate-limiting-3-kv-mistakes-we-made-4m9n</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/cloudflare-workers-rate-limiting-3-kv-mistakes-we-made-4m9n</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/07/01/cloudflare-workers-rate-limiting-3-kv-mistakes-we-made" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;Context: Why We Reached for Workers + KV in the First Place&lt;/h2&gt;



&lt;p&gt;We run a public API with two tiers — free and pro — and needed per-API-key limits enforced at the edge, before requests ever touched origin. Cloudflare Workers seemed like the obvious place to do it: cheap, globally distributed, sub-millisecond cold starts. For state, Workers KV was right there, already wired into our account, and the docs made it sound like exactly what we needed.&lt;/p&gt;

&lt;p&gt;The original design was simple, maybe too simple: a Worker reads a counter from KV keyed by API key, increments it on each request, and rejects with a 429 once the count crosses a threshold. It passed every test we threw at it locally. Requests came in, counters went up, limits kicked in at the right number. We shipped it and moved on.&lt;/p&gt;

&lt;p&gt;The assumption we never questioned — the one that eventually cost us a very awkward Monday morning — was that "KV is a key-value store, it'll behave like Redis." It doesn't. Cloudflare Workers rate limiting built directly on KV has a completely different consistency and cost model than an in-memory store, and we found out the hard way, in production, on paying customers' accounts. This post is the retrospective on what we got wrong and what we run now instead.&lt;/p&gt;

&lt;h2&gt;Mistake 1: We Treated KV as Strongly Consistent&lt;/h2&gt;

&lt;p&gt;Cloudflare is upfront about this in their &lt;a href="https://developers.cloudflare.com/kv/reference/data-consistency/" rel="noopener noreferrer"&gt;KV consistency documentation&lt;/a&gt;, but it's easy to skim past when you're focused on the happy path: KV writes can take up to 60 seconds to propagate globally across Cloudflare's edge network. A write in one colo isn't instantly visible in another. If a user's requests get routed through two or three different PoPs during a traffic spike — which happens constantly with mobile clients and CDN-fronted apps — each PoP sees its own stale view of the counter.&lt;/p&gt;

&lt;p&gt;In practice, this meant pro users capped at 100 requests per minute were bursting to 300-400 req/min whenever traffic happened to spread across multiple edge locations. Nothing errored. No exception, no failed request, no alert fired. The limiter was doing exactly what we told it to do — it just had a partial, delayed view of reality that let more traffic through than intended.&lt;/p&gt;

&lt;p&gt;We didn't catch this through monitoring. We caught it because our origin infrastructure bill spiked and someone went digging into request volume per customer. That's the part that still bothers me: a silent under-enforcement bug that looks like a billing anomaly is a lot harder to trace back to "the rate limiter is eventually consistent" than an outright failure would have been. If your rate limiter can fail in a way that produces zero errors, you need a metric specifically watching for that — not just error rate.&lt;/p&gt;

&lt;h2&gt;Mistake 2: We Ignored KV's Write Limits and Cost Model&lt;/h2&gt;

&lt;p&gt;The second problem was baked into the same code. KV enforces a hard limit of one write per key per second. We assumed — again, Redis brain — that extra writes would queue or throttle gracefully. They don't. Under sub-second bursts from the same user, some writes simply didn't take effect as expected, and the API call itself still returned success. There's no thrown exception to catch. You have to know to expect this and design around it.&lt;/p&gt;

&lt;p&gt;At around 50,000 requests per day from a single top-tier customer, we were generating roughly 50,000 KV writes per day just for that one key. Multiply that across a few thousand active API keys and the write volume adds up fast — Cloudflare's KV pricing is per-operation, and at scale that's real money, not a rounding error. Worse, our free-tier testing environment has a 1,000 writes/day cap, and one customer's load test blew through that limit in under an hour, which is how we first noticed something was structurally wrong before it hit prod at full scale.&lt;/p&gt;

&lt;p&gt;There was a second, subtler bug hiding in the same write path. &lt;code&gt;KV.get()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; for a key that's never been written. Our code did &lt;code&gt;parseInt(raw)&lt;/code&gt; without checking for null first, so on a brand-new API key the comparison against the limit was comparing against &lt;code&gt;NaN&lt;/code&gt;. Every comparison involving &lt;code&gt;NaN&lt;/code&gt; evaluates to &lt;code&gt;false&lt;/code&gt; — including &lt;code&gt;NaN &amp;gt;= limit&lt;/code&gt;. So new users had effectively no rate limit at all until their first successful write landed, sometimes for hours if propagation was slow. This is the naive version we shipped, showing both mistakes in one place:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// worker.js — the "Mistake 1 &amp;amp; 2" version: naive KV-based per-user rate limiter
// wrangler.toml requires: kv_namespaces = [{ binding = "RATELIMIT_KV", id = "..." }]

export default {
  async fetch(request, env) {
    const apiKey = request.headers.get("X-API-Key");
    if (!apiKey) {
      return new Response("Missing API key", { status: 401 });
    }

    // MISTAKE: raw API key used directly as KV key name (enumeration risk)
    const kvKey = `ratelimit:${apiKey}`;
    const windowSeconds = 60;
    const limit = 100;

    // MISTAKE: no handling for null on first-ever request
    const raw = await env.RATELIMIT_KV.get(kvKey);
    const count = parseInt(raw); // NaN if raw is null — comparison below silently passes

    if (count &amp;gt;= limit) {
      return new Response("Rate limit exceeded", {
        status: 429,
        // MISTAKE: no Retry-After header, clients retry immediately
      });
    }

    // MISTAKE: read-then-write is not atomic — concurrent requests race here
    const newCount = (isNaN(count) ? 0 : count) + 1;

    // MISTAKE: assumes this write is instantly visible everywhere (it isn't)
    await env.RATELIMIT_KV.put(kvKey, String(newCount), {
      expirationTtl: windowSeconds,
    });

    // Forward to origin
    const response = await fetch(request);
    const newHeaders = new Headers(response.headers);
    newHeaders.set("X-RateLimit-Remaining", String(limit - newCount));
    return new Response(response.body, { status: response.status, headers: newHeaders });
  },
};
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Mistake 3: We Used Raw API Keys as KV Keys&lt;/h2&gt;

&lt;p&gt;The third mistake was a design choice that made everything above worse: we stored counters under keys like &lt;code&gt;ratelimit:sk_live_abc123&lt;/code&gt;. That's a naive move for two reasons. First, it's a security smell — if KV namespace metadata or Worker logs ever leaked (via a misconfigured route or an overly verbose debug build), you'd be handing over a map of valid API key prefixes. We actually did briefly ship a debug build to prod that ran &lt;code&gt;console.log(apiKey)&lt;/code&gt; for troubleshooting. It got caught in review within a day, but &lt;code&gt;wrangler tail&lt;/code&gt; logs are visible to anyone with Worker log access on the account, so that's a longer exposure window than we're comfortable admitting.&lt;/p&gt;

&lt;p&gt;Second, using the raw key as the KV key name made popular customers into "hot keys." A single busy API key gets hit from many edge locations near-simultaneously, which is exactly the scenario that makes the propagation delay from Mistake 1 worse — more concurrent writers racing against a 60-second global sync window, on the same key, at the same time.&lt;/p&gt;

&lt;p&gt;The fix in hindsight is straightforward: hash the API key with SHA-256 before using it as a KV or Durable Object key name, and never log the raw key anywhere, debug build or not. Hashing doesn't fix the consistency problem, but it removes the enumeration risk entirely and it's a five-minute change. We now treat "never use a raw customer identifier as a storage key" as a hard rule in code review, not a suggestion.&lt;/p&gt;

&lt;h2&gt;What We Do Differently Now&lt;/h2&gt;

&lt;p&gt;The real fix wasn't a KV tweak — it was moving atomic counting off KV entirely. We now run one Durable Object per user (sharded by hashed key for very high-volume customers), which gives us single-threaded execution and true atomic increments. No races, no read-then-write gap, no cross-colo propagation lag, because a Durable Object instance lives in one place and serializes its own requests.&lt;/p&gt;

&lt;p&gt;KV still has a job — it's just the right job now. We use it for slow-changing config like plan tiers and allowlists, where eventual consistency within 60 seconds is genuinely fine. That's the lesson underneath all three mistakes: match the consistency guarantee to the data, don't assume one storage primitive fits every use case.&lt;/p&gt;

&lt;p&gt;We also rebuilt the failure mode. Our first version failed closed on any KV or DO error — during an unrelated Cloudflare KV incident, that took our entire API down for twenty minutes. Now we fail open with structured logging: if the counter call times out, we let the request through and tag it for later review, rather than punishing customers for an infrastructure hiccup on our side. We switched from abrupt fixed-window counters to a rough sliding window (two adjacent fixed windows weighted by elapsed time) so we're not allowing a 2x burst right at the window boundary, and every 429 now returns a proper &lt;code&gt;Retry-After&lt;/code&gt; header so client SDKs back off instead of hammering us immediately.&lt;/p&gt;

&lt;p&gt;Here's the corrected limiter, using a Durable Object with an alarm to proactively reset the counter instead of relying on lazy KV TTL expiry:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// durable-object-limiter.js — corrected approach: atomic per-user counting
// wrangler.toml: [[durable_objects.bindings]]
//   name = "RATE_LIMITER"
//   class_name = "RateLimiter"
// [[migrations]]
//   tag = "v1"
//   new_classes = ["RateLimiter"]

export class RateLimiter {
  constructor(state, env) {
    this.state = state;
  }

  async fetch(request) {
    const limit = 100;
    const windowMs = 60_000;
    const now = Date.now();

    // Single-threaded DO storage — no race condition, true atomicity
    let data = (await this.state.storage.get("counter")) || { count: 0, resetAt: now + windowMs };

    if (now &amp;gt; data.resetAt) {
      data = { count: 0, resetAt: now + windowMs };
    }

    data.count += 1;
    await this.state.storage.put("counter", data);

    // Proactive reset via alarm instead of relying on lazy TTL expiry
    await this.state.storage.setAlarm(data.resetAt);

    const remaining = Math.max(0, limit - data.count);
    const allowed = data.count &amp;lt;= limit;

    return new Response(JSON.stringify({ allowed, remaining, resetAt: data.resetAt }), {
      status: allowed ? 200 : 429,
      headers: allowed ? {} : { "Retry-After": String(Math.ceil((data.resetAt - now) / 1000)) },
    });
  }

  async alarm() {
    // Proactively clear counter when window ends, no stale reads on next request
    await this.state.storage.delete("counter");
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Watch out for one more thing if you're moving to Durable Objects: the migrations syntax in &lt;code&gt;wrangler.toml&lt;/code&gt; changed between Wrangler 2.x and 3.x. We're on &lt;code&gt;wrangler --version&lt;/code&gt; 3.28.1, and the &lt;code&gt;[[migrations]]&lt;/code&gt; block with &lt;code&gt;new_classes&lt;/code&gt; is what you want on that version — copying an old 2.x example straight into a 3.x project will fail silently on deploy in a way that's annoying to debug. Also budget real time for load testing: &lt;code&gt;wrangler dev --local --persist&lt;/code&gt; does not emulate cross-colo KV propagation delay, so your local tests will look perfect while production behaves completely differently. We now do a dedicated staging pass across multiple real edge locations before trusting any limiter change, and we cover more of that testing workflow in our &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS&lt;/a&gt; posts on CI validation for edge deployments.&lt;/p&gt;

&lt;p&gt;The upside, once we sorted this out: switching from per-request KV writes to Durable Objects cut our rate-limiting cost by roughly 40% at 2 million requests/day, because DO billing amortizes across duration rather than charging per operation. Cloudflare Workers rate limiting done right ended up cheaper and more correct — we just had to stop assuming KV was something it never claimed to be. For the full picture on the consistency tradeoffs, the &lt;a href="https://developers.cloudflare.com/durable-objects/" rel="noopener noreferrer"&gt;Durable Objects documentation&lt;/a&gt; is worth reading end to end before you build on top of it, not after.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/cloudflare/" rel="noopener noreferrer"&gt;More Cloudflare edge architecture patterns and failure modes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;Secret handling and enumeration risks in edge and CI systems&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/monitoring/" rel="noopener noreferrer"&gt;Catching silent failures before they show up in billing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Automate AWS Cost Reports with Python, boto3, and SES</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Tue, 30 Jun 2026 07:02:43 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/automate-aws-cost-reports-with-python-boto3-and-ses-4keg</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/automate-aws-cost-reports-with-python-boto3-and-ses-4keg</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/06/30/automate-aws-cost-reports-with-python-boto3-and-ses" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Your AWS bill already increased by the time you open Cost Explorer — here's how to make Python tell you before finance does. Last month a team I was working with got a Slack message from their finance lead: "Why did AWS spend jump 40% this month?" Nobody in engineering knew. The charges had been accumulating for three weeks. That's the exact problem AWS cost report Python automation solves: shift visibility left, from invoice to inbox.&lt;/p&gt;

&lt;h2&gt;Symptoms: Your AWS Bill Surprises You Every Month&lt;/h2&gt;



&lt;p&gt;The pattern is always the same. Cost Allocation Tags are enabled. Someone set up Cost Explorer once during an audit. There's a vague plan to "check it regularly." And then the PDF invoice arrives, after the charges are already incurred, and the post-mortem begins.&lt;/p&gt;

&lt;p&gt;Here are the specific symptoms that tell me a team has no real cost visibility:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Finance sends a Slack message asking about a bill spike — and no engineer can answer immediately&lt;/li&gt;
  &lt;li&gt;Cost Explorer is enabled but nobody opens it between billing cycles&lt;/li&gt;
  &lt;li&gt;The only "alerting" is a Budget alarm set to 100% of last month's spend — which fires too late to act&lt;/li&gt;
  &lt;li&gt;When someone does investigate, they spend 20 minutes clicking through the Cost Explorer UI to find the offending service&lt;/li&gt;
  &lt;li&gt;There is no audit trail of what the cost breakdown looked like on any given day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is unique to small teams. I've seen the same pattern at organizations running six-figure monthly AWS spend. The tooling exists. The API is there. The problem is that nobody wired it up with a scheduled, automated delivery mechanism. A report that lives in a console is not a report — it's a manual task waiting to be skipped.&lt;/p&gt;

&lt;p&gt;The second symptom worth calling out: teams that &lt;em&gt;do&lt;/em&gt; attempt automation often get halfway through parsing the boto3 response, hit the nested &lt;code&gt;ResultsByTime&lt;/code&gt; structure, and abandon the effort. The script sits in a local repo, uncommitted, never scheduled.&lt;/p&gt;

&lt;h2&gt;Root Cause: Manual Cost Visibility Doesn't Scale&lt;/h2&gt;

&lt;p&gt;The root cause isn't laziness. It's that the AWS Cost Explorer API has several non-obvious activation and usage requirements that create friction at every step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Activation delay.&lt;/strong&gt; Cost Explorer must be manually enabled in the AWS Console under Billing → Cost Explorer → Enable. First data appears 24–48 hours after activation. If you call &lt;code&gt;get_cost_and_usage()&lt;/code&gt; before that window, you get a misleading error: &lt;code&gt;DataUnavailableException: Data is not available. Please try to adjust the time period.&lt;/code&gt; Most engineers interpret this as a permissions issue and spend an hour debugging IAM before realizing the API just isn't active yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Response structure complexity.&lt;/strong&gt; The &lt;code&gt;get_cost_and_usage()&lt;/code&gt; response wraps data inside &lt;code&gt;ResultsByTime[*].Groups[*].Keys&lt;/code&gt;. If a service had zero spend in the queried period, &lt;code&gt;Groups&lt;/code&gt; is an empty list — not absent, not null, just empty. Code that assumes at least one group will cause index errors in production on quiet billing periods like the first of the month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No execution layer.&lt;/strong&gt; Even engineers who write a working script rarely schedule it. EventBridge rules and Lambda functions require a small amount of Terraform or CLI wiring that nobody gets around to. The script runs once manually, produces correct output, and then gets forgotten. Two months later the team is back to checking Cost Explorer by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API cost itself.&lt;/strong&gt; Each &lt;code&gt;get_cost_and_usage()&lt;/code&gt; call costs $0.01. Running it hourly on a 30-day window costs ~$7.20/month. Daily execution costs $0.31/month. I've seen well-intentioned automation scripts scheduled every 5 minutes by engineers who didn't read the pricing page. Watch out for this — it's a real gotcha that adds cost to your cost reporting.&lt;/p&gt;

&lt;p&gt;For more AWS automation patterns we use in production, see the &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud DevOps runbook archive&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;Fix #1: Activate Cost Explorer API and Query It Correctly with boto3&lt;/h2&gt;

&lt;p&gt;Before writing a single line of Python, activate Cost Explorer in the console and wait 24 hours. Then install a pinned boto3 version for your Lambda layer:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;pip install boto3==1.34.69  # reproducible Lambda layer build&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The following Lambda handler queries month-to-date costs grouped by AWS service. Read the inline comments — several of these lines exist specifically to avoid the common failure modes described above.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# lambda_function.py
# AWS Cost Explorer → HTML Email Report via SES
# Runtime: python3.12 | boto3 1.34.x
# IAM requires: ce:GetCostAndUsage, ses:SendEmail, s3:PutObject

import boto3
import json
import os
from datetime import datetime, timedelta

# --- Config (use Lambda env vars, never hardcode) ---
SENDER_EMAIL = os.environ["SENDER_EMAIL"]       # must be SES-verified
RECIPIENT_EMAIL = os.environ["RECIPIENT_EMAIL"]
REPORT_BUCKET = os.environ["REPORT_BUCKET"]     # S3 bucket for audit trail
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")

ce_client = boto3.client("ce", region_name="us-east-1")  # Cost Explorer only in us-east-1
ses_client = boto3.client("ses", region_name=AWS_REGION)
s3_client = boto3.client("s3", region_name=AWS_REGION)


def get_monthly_costs() -&amp;gt; dict:
    """Query Cost Explorer for current month-to-date costs grouped by SERVICE."""
    today = datetime.today()
    # End date is exclusive — use today's date; data through yesterday is returned
    end_date = today.strftime("%Y-%m-%d")
    start_date = today.replace(day=1).strftime("%Y-%m-%d")  # first of current month

    response = ce_client.get_cost_and_usage(
        TimePeriod={"Start": start_date, "End": end_date},
        Granularity="MONTHLY",
        Metrics=["UnblendedCost"],  # NOT BlendedCost — blended averages RI rates, misleading
        GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
        # WARNING: do NOT add a second GroupBy for TAG here — raises ValidationException
    )

    costs = {}
    for result in response["ResultsByTime"]:
        for group in result.get("Groups", []):  # .get() guards against empty periods
            service = group["Keys"][0]
            amount = round(float(group["Metrics"]["UnblendedCost"]["Amount"]), 2)
            if amount &amp;gt; 0:  # skip zero-cost services to keep report clean
                costs[service] = amount

    # Sort descending by cost — top drivers first
    return dict(sorted(costs.items(), key=lambda x: x[1], reverse=True))


def build_html_report(costs: dict, report_date: str) -&amp;gt; str:
    """Build an HTML email table from the costs dict."""
    total = round(sum(costs.values()), 2)
    rows = "".join(
        f"&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;{svc}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;${amt:.2f}&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;"
        for svc, amt in costs.items()
    )
    return f"""
    &amp;lt;html&amp;gt;&amp;lt;body&amp;gt;
    &amp;lt;h2&amp;gt;AWS Cost Report — {report_date}&amp;lt;/h2&amp;gt;
    &amp;lt;table border="1" cellpadding="6" style="border-collapse:collapse;"&amp;gt;
      &amp;lt;tr&amp;gt;&amp;lt;th&amp;gt;Service&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;Cost (USD)&amp;lt;/th&amp;gt;&amp;lt;/tr&amp;gt;
      {rows}
      &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;&amp;lt;strong&amp;gt;TOTAL&amp;lt;/strong&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;&amp;lt;strong&amp;gt;${total:.2f}&amp;lt;/strong&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
    &amp;lt;/table&amp;gt;
    &amp;lt;p&amp;gt;Generated by cost-reporter Lambda | kuryzhev.cloud&amp;lt;/p&amp;gt;
    &amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
    """


def send_email(html_body: str, report_date: str) -&amp;gt; None:
    """Send the HTML report via SES."""
    ses_client.send_email(
        Source=SENDER_EMAIL,
        Destination={"ToAddresses": [RECIPIENT_EMAIL]},
        Message={
            "Subject": {"Data": f"AWS Cost Report {report_date}"},
            "Body": {
                "Text": {"Data": "Open in HTML-capable email client."},
                "Html": {"Data": html_body},
            },
        },
    )


def save_to_s3(costs: dict, report_date: str) -&amp;gt; None:
    """Persist raw cost data to S3 for audit trail."""
    key = f"cost-reports/{report_date}.json"
    s3_client.put_object(
        Bucket=REPORT_BUCKET,
        Key=key,
        Body=json.dumps(costs, indent=2),
        ContentType="application/json",
    )


def lambda_handler(event, context):
    report_date = datetime.today().strftime("%Y-%m-%d")
    costs = get_monthly_costs()
    html = build_html_report(costs, report_date)
    send_email(html, report_date)
    save_to_s3(costs, report_date)
    return {"statusCode": 200, "body": f"Report sent for {report_date}"}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Two things I want to highlight here. First, &lt;code&gt;ce_client&lt;/code&gt; is always initialized with &lt;code&gt;region_name="us-east-1"&lt;/code&gt; regardless of where your Lambda runs — Cost Explorer is a global service only accessible through that region endpoint. Second, the &lt;code&gt;result.get("Groups", [])&lt;/code&gt; pattern is not optional. On the first day of the month, or in accounts with no spend in a queried period, &lt;code&gt;Groups&lt;/code&gt; will be an empty list. Without the &lt;code&gt;.get()&lt;/code&gt; fallback, you get a &lt;code&gt;KeyError&lt;/code&gt; in production on the 1st of every month.&lt;/p&gt;

&lt;h2&gt;Fix #2: Parse the Response and Build a Readable Report Structure&lt;/h2&gt;

&lt;p&gt;The raw API response returns cost amounts as strings, not floats. The Lambda API returns &lt;code&gt;"0.0000012"&lt;/code&gt; for a Lambda invocation cost. If you skip the &lt;code&gt;float()&lt;/code&gt; cast and try to sort or format directly, you get a &lt;code&gt;TypeError&lt;/code&gt; that surfaces only when a low-cost service appears in the results.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;build_html_report()&lt;/code&gt; function above handles this with &lt;code&gt;round(float(amount), 2)&lt;/code&gt;. I use &lt;code&gt;round()&lt;/code&gt; to two decimal places rather than string formatting alone, because I want the &lt;code&gt;costs&lt;/code&gt; dict to contain actual numeric values for the S3 JSON dump — useful if you later feed this data into a dashboard or Slack message.&lt;/p&gt;

&lt;p&gt;A note on templating: I deliberately avoided Jinja2 here. For a single-file Lambda that generates one HTML table, Jinja2 adds a dependency layer with zero benefit. Python's &lt;code&gt;str.join()&lt;/code&gt; on a list comprehension of &lt;code&gt;&amp;lt;tr&amp;gt;&lt;/code&gt; strings is readable, testable, and requires no extra packaging. I stopped using Jinja2 in Lambda functions after spending 45 minutes debugging a layer packaging issue that turned out to be a Jinja2 version conflict. For anything more complex — multi-section reports, conditional blocks — reconsider. For a cost table, it's overkill.&lt;/p&gt;

&lt;p&gt;Sort order matters for the email recipient. The &lt;code&gt;sorted(..., reverse=True)&lt;/code&gt; call ensures EC2 and RDS appear at the top, not alphabetically buried below CloudTrail and Config. Executive readers scan the first three rows. Make those rows count.&lt;/p&gt;

&lt;h2&gt;Fix #3: Send the Report via SES with HTML Formatting&lt;/h2&gt;

&lt;p&gt;SES has two operational gotchas that will silently break your delivery if you miss them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sandbox mode.&lt;/strong&gt; By default, SES accounts are in sandbox mode. In sandbox mode, SES silently drops emails sent to unverified recipient addresses — no error, no bounce, no log entry. The &lt;code&gt;send_email()&lt;/code&gt; call returns HTTP 200 and you have no idea the email never arrived. Request production access via the AWS console under SES → Account dashboard → Request production access before testing with real recipients. See the &lt;a href="https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html" rel="noopener noreferrer"&gt;official SES production access documentation&lt;/a&gt; for the process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing Source key.&lt;/strong&gt; The &lt;code&gt;ses.send_email()&lt;/code&gt; call requires &lt;code&gt;Source&lt;/code&gt;, &lt;code&gt;Destination&lt;/code&gt;, and &lt;code&gt;Message&lt;/code&gt; as top-level keys. If &lt;code&gt;Source&lt;/code&gt; is missing or the email address is not SES-verified, you get: &lt;code&gt;InvalidParameterValue: Missing required header 'From'&lt;/code&gt;. This error message is confusing because you're not setting headers directly — it's SES's way of saying the &lt;code&gt;Source&lt;/code&gt; address failed verification.&lt;/p&gt;

&lt;p&gt;Now wire up the EventBridge schedule and IAM policy with Terraform. This is the piece most automation attempts skip, and it's why scripts run once and get forgotten.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# eventbridge_lambda.tf
# Terraform: EventBridge rule to trigger Lambda daily at 08:00 UTC
# Requires: aws_lambda_function.cost_reporter already defined

resource "aws_cloudwatch_event_rule" "daily_cost_report" {
  name                = "daily-cost-report"
  description         = "Trigger cost reporter Lambda every day at 08:00 UTC"
  schedule_expression = "cron(0 8 * * ? *)"  # AWS cron uses ? not * for dow when dom is set
}

resource "aws_cloudwatch_event_target" "cost_reporter_target" {
  rule      = aws_cloudwatch_event_rule.daily_cost_report.name
  target_id = "CostReporterLambda"
  arn       = aws_lambda_function.cost_reporter.arn
}

resource "aws_lambda_permission" "allow_eventbridge" {
  statement_id  = "AllowEventBridgeInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.cost_reporter.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.daily_cost_report.arn
}

# Least-privilege IAM policy for Lambda execution role
resource "aws_iam_role_policy" "cost_reporter_policy" {
  name = "cost-reporter-policy"
  role = aws_iam_role.cost_reporter_exec.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["ce:GetCostAndUsage"]
        Resource = "*"  # Cost Explorer does not support resource-level permissions
      },
      {
        Effect   = "Allow"
        Action   = ["ses:SendEmail"]
        Resource = "*"
      },
      {
        Effect   = "Allow"
        Action   = ["s3:PutObject"]
        Resource = "arn:aws:s3:::${var.report_bucket}/cost-reports/*"  # scoped to prefix
      }
    ]
  })
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Watch out for the EventBridge cron syntax. AWS uses &lt;code&gt;?&lt;/code&gt; instead of &lt;code&gt;*&lt;/code&gt; for the day-of-week field when day-of-month is already specified, and vice versa. &lt;code&gt;cron(0 8 * * * *)&lt;/code&gt; will fail with a validation error. The correct form is &lt;code&gt;cron(0 8 * * ? *)&lt;/code&gt;. This trips up every engineer the first time. See the &lt;a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-cron-expressions.html" rel="noopener noreferrer"&gt;EventBridge cron expression documentation&lt;/a&gt; for the full syntax reference.&lt;/p&gt;

&lt;p&gt;Also set your Lambda timeout to at least 30 seconds. The default is 3 seconds. Cost Explorer API p95 latency is 4–6 seconds. Your function will time out silently on the first real invocation if you leave the default in place.&lt;/p&gt;

&lt;h2&gt;Prevention: Make Cost Reporting a Reliable, Auditable System&lt;/h2&gt;

&lt;p&gt;Getting the script working is step one. Making it reliable over months without manual intervention is the harder part. Here's what I add to every cost reporting Lambda before considering it production-ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CloudWatch alarm on Lambda errors.&lt;/strong&gt; Create an alarm on the &lt;code&gt;Errors&lt;/code&gt; metric for the function with threshold &lt;code&gt;&amp;gt; 0&lt;/code&gt; for one evaluation period. If SES throttles, IAM permissions drift after a role rotation, or Cost Explorer returns a transient error, you want to know within minutes — not after a week of missed reports when someone notices the inbox has gone quiet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 audit trail with lifecycle policy.&lt;/strong&gt; The &lt;code&gt;save_to_s3()&lt;/code&gt; function stores each day's raw cost JSON at &lt;code&gt;cost-reports/YYYY-MM-DD.json&lt;/code&gt;. At ~5KB per file, 365 files/year costs essentially nothing in S3 Standard. Add a lifecycle rule to expire objects after 90 days. Do not enable S3 Intelligent-Tiering on these objects — the per-object monitoring fee for Intelligent-Tiering exceeds the storage cost of a 5KB file. That's a real gotcha I've hit on similar audit trail patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never use environment variables for AWS credentials in Lambda.&lt;/strong&gt; Use the IAM execution role exclusively. Environment variable secrets are visible to anyone with &lt;code&gt;lambda:GetFunctionConfiguration&lt;/code&gt; permission — which is a surprisingly common IAM grant in developer accounts. The execution role approach is both more secure and eliminates credential rotation as a failure mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin your runtime.&lt;/strong&gt; Use &lt;code&gt;python3.12&lt;/code&gt;. Avoid &lt;code&gt;python3.8&lt;/code&gt;, which reached EOL. Unpinned runtimes drift when AWS deprecates versions and forces migration — you want to control that on your schedule, not AWS's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handler path must match exactly.&lt;/strong&gt; If your file is &lt;code&gt;lambda_function.py&lt;/code&gt;, the handler value in the Lambda configuration must be &lt;code&gt;lambda_function.lambda_handler&lt;/code&gt;. A mismatch causes &lt;code&gt;Runtime.HandlerNotFound&lt;/code&gt; at invocation time. It's obvious when it happens, but it wastes five minutes every time someone renames a file without updating the handler config.&lt;/p&gt;

&lt;p&gt;AWS cost report Python automation done right means the engineering team knows about cost spikes before the finance team does — and has the data to explain exactly which service caused them. This Lambda, scheduled daily via EventBridge, delivers that visibility with under 100 lines of Python and a Terraform block that takes 10 minutes to apply.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/aws/" rel="noopener noreferrer"&gt;More AWS automation patterns, Lambda, and cost control&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/python/" rel="noopener noreferrer"&gt;Python scripts for DevOps — boto3, automation, and tooling&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Terraform modules and IaC patterns for AWS infrastructure&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Makefile Patterns for Terraform and Infra Repos That Actually Scale</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Mon, 29 Jun 2026 07:02:29 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/makefile-patterns-for-terraform-and-infra-repos-that-actually-scale-4713</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/makefile-patterns-for-terraform-and-infra-repos-that-actually-scale-4713</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/06/29/makefile-patterns-for-terraform-and-infra-repos-that-actually-scale" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Every infra repo eventually gets a Makefile — but most of them silently break in CI, leak credentials in logs, or stop working the moment someone creates a file named &lt;code&gt;plan&lt;/code&gt;. Makefile infra repo patterns are one of those topics where everyone has an opinion, but few people have actually debugged a production pipeline that failed because Make thought a directory was an up-to-date target. I have. Twice. Here is what I learned.&lt;/p&gt;

&lt;h2&gt;What Make Actually Does in an Infra Repo&lt;/h2&gt;



&lt;p&gt;Make was not designed for infrastructure. It was designed to avoid recompiling C files that had not changed. That file-dependency model is exactly what causes confusion when you drop it into a Terraform repo — because you are not producing files, you are running commands against remote APIs.&lt;/p&gt;

&lt;p&gt;What Make actually gives you in an infra context is a &lt;strong&gt;self-documenting command interface layer&lt;/strong&gt; over shell, Terraform, kubectl, and Ansible. Nothing more. When you run &lt;code&gt;make plan&lt;/code&gt;, Make reads the Makefile top-to-bottom, resolves targets as a directed acyclic graph, checks whether a file named &lt;code&gt;plan&lt;/code&gt; exists in the current directory, and if it does not find one, executes the recipe. That last part is the trap. If a file named &lt;code&gt;plan&lt;/code&gt; exists — maybe leftover from a &lt;code&gt;terraform plan -out=plan&lt;/code&gt; run — Make considers the target up-to-date and silently skips it. No error. No output. Nothing happens.&lt;/p&gt;

&lt;p&gt;This is why &lt;code&gt;.PHONY&lt;/code&gt; declarations are not optional. They tell Make: this target is a command, not a file. Declare every infra target as phony without exception.&lt;/p&gt;

&lt;p&gt;It is also worth knowing that macOS ships with GNU Make 3.81 from 2006. Homebrew installs 4.4.1. Recipe-scoped variables using &lt;code&gt;:=&lt;/code&gt; inside targets behave differently between these versions. If your Makefile works locally but not in a fresh CI container, version mismatch is the first thing to check with &lt;code&gt;make --version&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For teams uncomfortable with Make's syntax, two alternatives are worth knowing: &lt;a href="https://taskfile.dev/" rel="noopener noreferrer"&gt;Task (go-task) v3.35+&lt;/a&gt; uses a YAML-native &lt;code&gt;Taskfile.yml&lt;/code&gt; with built-in &lt;code&gt;dotenv&lt;/code&gt;, &lt;code&gt;preconditions&lt;/code&gt;, and parallel &lt;code&gt;deps&lt;/code&gt; support. Justfile (v1.25+) is even simpler — pure shell-like syntax with no file-dependency model at all. I reach for Make when the team knows it and the repo is small. I reach for Task when the workflow grows beyond 20 targets or when I want native parallelism on lint jobs without thinking about it.&lt;/p&gt;

&lt;h2&gt;How People Use Makefile Infra Repos Wrong&lt;/h2&gt;

&lt;p&gt;Three patterns show up in almost every infra repo I have reviewed, and all three cause real production problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardcoded environment values inside targets.&lt;/strong&gt; I see this constantly: &lt;code&gt;REGION=eu-west-1&lt;/code&gt; baked into the recipe line, not in a variable. The target works for the engineer who wrote it and silently targets the wrong environment for everyone else. The fix is &lt;code&gt;?=&lt;/code&gt; defaults at the top of the file — &lt;code&gt;ENV ?= dev&lt;/code&gt;, &lt;code&gt;REGION ?= eu-west-1&lt;/code&gt; — so the target works locally and in CI without modification, but can be overridden with &lt;code&gt;make plan ENV=staging&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing &lt;code&gt;.PHONY&lt;/code&gt; declarations.&lt;/strong&gt; As described above: if a file named &lt;code&gt;apply&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, or &lt;code&gt;destroy&lt;/code&gt; ever appears in the repo, Make skips the target silently. The full declaration you need at minimum is: &lt;code&gt;.PHONY: all help plan apply destroy lint fmt validate&lt;/code&gt;. Put it at the top. Add to it every time you add a target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chaining targets with &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; inside recipes instead of using Make's dependency graph.&lt;/strong&gt; When you write &lt;code&gt;plan: init &amp;amp;&amp;amp; validate&lt;/code&gt; inside a recipe line, you break &lt;code&gt;make -n&lt;/code&gt; dry-run output, you hide individual target failures, and you make parallelism impossible. The correct pattern is to declare dependencies: &lt;code&gt;plan: init validate&lt;/code&gt; as the target line, then the recipe body. Make resolves the dependency graph before executing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for this one:&lt;/strong&gt; &lt;code&gt;include .env&lt;/code&gt; at the top of a Makefile loads all values as Make variables, which means they appear in &lt;code&gt;make -p&lt;/code&gt; output in plain text. If your &lt;code&gt;.env&lt;/code&gt; file contains AWS credentials or tokens, anyone with access to the CI log for &lt;code&gt;make -p&lt;/code&gt; can read them. Use &lt;code&gt;dotenv&lt;/code&gt; in Task, or export variables inside recipe lines rather than at the Makefile top level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Another gotcha:&lt;/strong&gt; each line in a Make recipe is a separate shell subprocess. Using &lt;code&gt;export FOO=bar&lt;/code&gt; on line one of a recipe does not export &lt;code&gt;FOO&lt;/code&gt; to line two. If you need a variable to persist across recipe lines, either collapse them into one line with &lt;code&gt;;&lt;/code&gt; or use &lt;code&gt;.ONESHELL:&lt;/code&gt; (GNU Make 3.82+).&lt;/p&gt;

&lt;h2&gt;The Correct Approach — Structured Makefile for Infra Repos&lt;/h2&gt;

&lt;p&gt;The pattern I use in production repos has three rules: the default target is always &lt;code&gt;help&lt;/code&gt;, secrets never appear as top-level variable assignments, and sub-makefiles keep the root file under 30 lines.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;help&lt;/code&gt; target uses &lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;awk&lt;/code&gt; to parse &lt;code&gt;##&lt;/code&gt; comments on target lines. Every engineer who clones the repo runs &lt;code&gt;make&lt;/code&gt; and immediately sees documented commands. No README hunting. The &lt;code&gt;guard-%&lt;/code&gt; macro is the second essential pattern — a reusable prerequisite target that checks required environment variables exist before any target executes. It prevents &lt;code&gt;terraform apply&lt;/code&gt; with empty &lt;code&gt;TF_VAR_&lt;/code&gt; values, which is a very easy way to corrupt state.&lt;/p&gt;

&lt;p&gt;Here is the full production Makefile pattern. Note the &lt;code&gt;SHELL&lt;/code&gt; and &lt;code&gt;.SHELLFLAGS&lt;/code&gt; settings at the top — these make every recipe behave like &lt;code&gt;set -euo pipefail&lt;/code&gt;, which means unset variables and failed pipe commands abort the recipe instead of silently continuing:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Makefile — root infra repo task interface
# Compatible with GNU Make 4.x (brew install make on macOS)
# Usage: make help

SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c

# ── Overridable defaults ────────────────────────────────────────────────────
ENV        ?= dev
REGION     ?= eu-west-1
TF_DIR     ?= terraform/envs/$(ENV)
PLAN_FILE  ?= $(ENV).tfplan

# ── Default target: self-documenting help ───────────────────────────────────
.DEFAULT_GOAL := help

.PHONY: help
help: ## Show this help message
    @grep -E '^[a-zA-Z_%-]+:.*##' $(MAKEFILE_LIST) \
        | awk 'BEGIN {FS = ":.*##"}; {printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2}'

# ── Guard macro: require env var before any target runs ────────────────────
# Usage: add guard-VARNAME as a dependency
# Example: plan: guard-ENV guard-AWS_PROFILE
guard-%:
    @[ "${$*}" ] || (echo "❌  Required variable '$*' is not set"; exit 1)

# ── Include modular sub-makefiles ───────────────────────────────────────────
-include make/terraform.mk
-include make/docker.mk
-include make/lint.mk

# ── Terraform targets ───────────────────────────────────────────────────────
.PHONY: init plan apply destroy

init: guard-ENV ## terraform init for ENV (default: dev)
    @echo "→ Initialising $(TF_DIR)"
    @cd $(TF_DIR) &amp;amp;&amp;amp; terraform init -reconfigure

plan: guard-ENV guard-AWS_PROFILE ## Generate plan for ENV, saves to PLAN_FILE
    @cd $(TF_DIR) &amp;amp;&amp;amp; terraform plan \
        -var-file="vars/$(ENV).tfvars" \
        -out=$(PLAN_FILE)

apply: guard-ENV guard-AWS_PROFILE ## Apply saved plan (requires PLAN_FILE)
    @[ -f "$(TF_DIR)/$(PLAN_FILE)" ] || \
        (echo "❌  No plan file found. Run: make plan ENV=$(ENV)"; exit 1)
    @cd $(TF_DIR) &amp;amp;&amp;amp; terraform apply $(PLAN_FILE)

destroy: guard-ENV guard-AWS_PROFILE ## Destroy ENV — requires CONFIRM=yes
    @[ "$(CONFIRM)" = "yes" ] || \
        (echo "❌  Set CONFIRM=yes to proceed with destroy"; exit 1)
    @cd $(TF_DIR) &amp;amp;&amp;amp; terraform destroy -var-file="vars/$(ENV).tfvars"

# ── Lint targets — safe to parallelise ─────────────────────────────────────
.PHONY: lint fmt validate

lint: fmt validate ## Run all linters (parallelisable with make -j3 lint)
    @echo "✅  Lint complete"

fmt: ## Run terraform fmt check (non-destructive)
    @terraform fmt -check -recursive $(TF_DIR)

validate: ## Run terraform validate
    @cd $(TF_DIR) &amp;amp;&amp;amp; terraform validate

# ── Dynamic per-environment plan targets ───────────────────────────────────
# Generates: plan-dev, plan-staging, plan-prod automatically
ENVS := dev staging prod

define ENV_TARGET
.PHONY: plan-$(1)
plan-$(1): guard-AWS_PROFILE ## Run plan for environment: $(1)
    @$(MAKE) plan ENV=$(1)
endef

$(foreach env,$(ENVS),$(eval $(call ENV_TARGET,$(env))))&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A few things worth calling out in this file. The &lt;code&gt;-include&lt;/code&gt; prefix on sub-makefile includes means Make silently skips the file if it does not exist — useful when not every repo has Docker targets. The &lt;code&gt;$(MAKE)&lt;/code&gt; call inside the &lt;code&gt;ENV_TARGET&lt;/code&gt; macro is intentional: using bare &lt;code&gt;make&lt;/code&gt; would drop flags like &lt;code&gt;-n&lt;/code&gt; or &lt;code&gt;-j&lt;/code&gt; that the caller passed in. Always use &lt;code&gt;$(MAKE)&lt;/code&gt; for recursive calls.&lt;/p&gt;

&lt;h2&gt;Advanced Patterns for Real Infrastructure Workflows&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;guard-%&lt;/code&gt; macro and the &lt;code&gt;foreach/eval&lt;/code&gt; pattern for generating environment targets are the two that have saved us the most time in real CI pipelines. The dynamic target generation means adding a new environment to &lt;code&gt;ENVS := dev staging prod canary&lt;/code&gt; automatically creates &lt;code&gt;plan-canary&lt;/code&gt;, &lt;code&gt;apply-canary&lt;/code&gt;, and any other targets defined in the macro block. No copy-paste. No drift between environments.&lt;/p&gt;

&lt;p&gt;For teams that find Make syntax genuinely hostile — and some do — go-task v3.35+ maps to all of these patterns in YAML. The &lt;code&gt;preconditions&lt;/code&gt; key replaces &lt;code&gt;guard-%&lt;/code&gt;, &lt;code&gt;dotenv&lt;/code&gt; replaces &lt;code&gt;include .env&lt;/code&gt; without the credential exposure risk, and &lt;code&gt;deps&lt;/code&gt; runs subtasks in parallel by default. Here is the equivalent Taskfile:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Taskfile.yml — go-task v3.35+ equivalent for teams preferring YAML
# Install: brew install go-task  |  version: '3' schema required
# Usage: task help

version: '3'

set: [pipefail]
shopt: [globstar]

dotenv: ['.env', '.env.{{.ENV}}']  # loads .env.dev, .env.staging etc.

vars:
  ENV: '{{.ENV | default "dev"}}'
  REGION: '{{.REGION | default "eu-west-1"}}'
  TF_DIR: 'terraform/envs/{{.ENV}}'

tasks:

  default:
    desc: Show available tasks
    cmds:
      - task --list

  plan:
    desc: "Run terraform plan for ENV (default: dev)"
    preconditions:
      - sh: '[ -n "{{.AWS_PROFILE}}" ]'
        msg: "AWS_PROFILE must be set"
      - sh: '[ -d "{{.TF_DIR}}" ]'
        msg: "Directory {{.TF_DIR}} does not exist — check ENV value"
    cmds:
      - cd {{.TF_DIR}} &amp;amp;&amp;amp; terraform plan
          -var-file="vars/{{.ENV}}.tfvars"
          -out={{.ENV}}.tfplan

  lint:
    desc: Run all linters in parallel
    deps: [lint:fmt, lint:validate, lint:tflint]  # parallel by default in Task

  lint:fmt:
    internal: true
    cmds:
      - terraform fmt -check -recursive {{.TF_DIR}}

  lint:validate:
    internal: true
    cmds:
      - cd {{.TF_DIR}} &amp;amp;&amp;amp; terraform validate

  lint:tflint:
    internal: true
    cmds:
      - tflint --chdir={{.TF_DIR}} --config=.tflint.hcl&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;dotenv&lt;/code&gt; key in Task is safer than Make's &lt;code&gt;include .env&lt;/code&gt; because Task does not expose dotenv values through a &lt;code&gt;--list&lt;/code&gt; or debug output equivalent. It also supports per-environment files automatically: &lt;code&gt;.env.staging&lt;/code&gt; is loaded when &lt;code&gt;ENV=staging&lt;/code&gt;. That pattern alone has replaced a lot of fragile CI variable injection in our pipelines. More on our CI patterns at &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;Performance Notes and Security Considerations&lt;/h2&gt;

&lt;p&gt;Make's parallel execution flag &lt;code&gt;-j&lt;/code&gt; is genuinely useful for lint targets. Running &lt;code&gt;make -j$(nproc) lint&lt;/code&gt; across 20 Terraform modules — tflint, tfsec, checkov in parallel — drops CI lint time from around four minutes to under 45 seconds in our experience. That is a meaningful improvement on a team running 30+ PRs a day.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;make -j4&lt;/code&gt; on Terraform targets that share a state backend is a different story. Parallel &lt;code&gt;plan&lt;/code&gt; and &lt;code&gt;apply&lt;/code&gt; jobs against the same S3 backend will race on the state lock. You will see &lt;code&gt;Error: Error acquiring the state lock&lt;/code&gt; in CI, and if the lock acquisition fails mid-apply, you may end up with a partial state that requires manual &lt;code&gt;terraform force-unlock&lt;/code&gt;. Never use &lt;code&gt;-j&lt;/code&gt; on stateful Terraform targets. Lint and format targets are safe. Apply and plan are not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for this security issue:&lt;/strong&gt; never put &lt;code&gt;$(shell aws sts get-caller-identity)&lt;/code&gt; or any credential-fetching shell call at the top level of a Makefile as a variable assignment. Top-level variable assignments are evaluated at parse time, which means they execute on every single &lt;code&gt;make&lt;/code&gt; invocation — including &lt;code&gt;make help&lt;/code&gt;. Every engineer running &lt;code&gt;make help&lt;/code&gt; on a shared CI runner triggers an AWS API call that lands in CloudTrail. Multiply that by 50 engineers and 200 PR checks per day and you have noisy CloudTrail logs that obscure real security events. Move shell calls that contact external APIs into recipe bodies, prefixed with &lt;code&gt;@&lt;/code&gt; to suppress echo, and only in targets that actually need them.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;@&lt;/code&gt; prefix suppresses command echo in Make output. Use it on every recipe line that handles credentials or tokens. One common typo: &lt;code&gt;@@command&lt;/code&gt;. Double &lt;code&gt;@&lt;/code&gt; does not exist in Make syntax — it causes a parse error that looks like a missing separator. If your Makefile suddenly breaks with &lt;code&gt;Makefile:42: *** missing separator. Stop.&lt;/code&gt;, check for accidental double-&lt;code&gt;@&lt;/code&gt; on recipe lines.&lt;/p&gt;

&lt;p&gt;Finally, the &lt;code&gt;CONFIRM=yes&lt;/code&gt; guard on &lt;code&gt;destroy&lt;/code&gt; targets is not paranoia. It is the one pattern I will argue for in every code review. Running &lt;code&gt;make destroy&lt;/code&gt; without a confirmation check in a repo where &lt;code&gt;ENV&lt;/code&gt; defaults to &lt;code&gt;prod&lt;/code&gt; — or where a CI variable is misconfigured — is a bad day. The two-second friction of typing &lt;code&gt;CONFIRM=yes&lt;/code&gt; has prevented at least one production incident I can point to directly. See the &lt;a href="https://www.gnu.org/software/make/manual/make.html" rel="noopener noreferrer"&gt;GNU Make manual&lt;/a&gt; for the full reference on guard patterns and conditional variable assignment.&lt;/p&gt;

&lt;p&gt;Makefile infra repo patterns are not glamorous, but they are the difference between a repo that onboards new engineers in ten minutes and one that requires a tribal knowledge transfer every time someone joins the team. The patterns here — &lt;code&gt;.PHONY&lt;/code&gt; everywhere, &lt;code&gt;guard-%&lt;/code&gt; macros, overridable defaults, and parallel-safe lint targets — are what I would put in every infra repo I own today.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;More Terraform workflow patterns, state management, and module design&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/ci-cd/" rel="noopener noreferrer"&gt;CI/CD pipeline patterns for infrastructure deployments and GitOps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/bash/" rel="noopener noreferrer"&gt;Bash scripting for DevOps automation and infra tooling&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Query AWS Runbooks in Plain English with Bedrock Knowledge Bases</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sun, 28 Jun 2026 07:02:27 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/query-aws-runbooks-in-plain-english-with-bedrock-knowledge-bases-516b</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/query-aws-runbooks-in-plain-english-with-bedrock-knowledge-bases-516b</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/06/28/query-aws-runbooks-in-plain-english-with-bedrock-knowledge-bases" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;The Night Our On-Call Engineer Couldn't Find the Runbook&lt;/h2&gt;



&lt;p&gt;Amazon Bedrock Knowledge Bases can answer "what do we do when Aurora read replica lag exceeds 30 seconds" in under two seconds — but at 2 AM on a Tuesday, our on-call engineer was doing something very different. He was scrolling through Confluence search results, staring at three runbooks titled almost identically: &lt;em&gt;RDS Failover Procedure&lt;/em&gt;, dated March 2021, October 2022, and February 2023. None of them said which was authoritative. He picked the middle one and started following it.&lt;/p&gt;

&lt;p&gt;Fourteen minutes later, he realized he was working through a procedure written for a single-AZ RDS instance. Our cluster had been Multi-AZ Aurora for eighteen months. The actual failover had already completed automatically. What he needed was a five-line check to validate replica promotion and re-point the application connection string. That information existed. It was in a Markdown file in our ops-runbooks S3 bucket, uploaded after a postmortem in Q3 2023, and it had never been linked from Confluence.&lt;/p&gt;

&lt;p&gt;The incident itself resolved in four minutes. Our MTTR on the report showed twenty-two minutes. Eighteen of those minutes were pure documentation chaos. That postmortem is what kicked off the work I'm going to walk you through here.&lt;/p&gt;

&lt;h2&gt;Why Operational Docs Become a Liability Over Time&lt;/h2&gt;

&lt;p&gt;This isn't a discipline problem. I've worked with genuinely excellent engineers who let runbooks rot, and the reason is structural, not motivational. Documentation in most DevOps teams lives across at least four silos simultaneously: S3 buckets holding postmortem Markdown files, Confluence spaces with wiki pages, GitHub repository wikis attached to specific services, and PDF attachments in old JIRA tickets. There is no single retrieval layer across all of them, and there is definitely no semantic search.&lt;/p&gt;

&lt;p&gt;The second problem is version drift. Engineers write runbooks during or immediately after incidents, when the system context is fresh. They almost never revisit them. Six months later, the infrastructure has changed — a new Aurora version, a different connection pooling setup, a renamed parameter group — but the runbook hasn't. Version drift is invisible until it causes a second incident. And then it shows up in the postmortem as "engineer followed incorrect procedure," which obscures the real root cause: the documentation system has no mechanism for staleness detection.&lt;/p&gt;

&lt;p&gt;The third problem is that keyword search fundamentally fails on operational questions. Searching Confluence for "Aurora replica lag" returns every page that mentions those words in any context. The answer to "what do we do when replica lag exceeds 30 seconds" is buried three paragraphs into a postmortem written in prose, not indexed as a discrete fact. Standard search engines aren't built for that retrieval pattern. Vector search is.&lt;/p&gt;

&lt;p&gt;This is exactly the gap Amazon Bedrock Knowledge Bases fills. It ingests your existing documentation — Markdown, PDF, plain text, Word docs — chunks it, embeds it into a vector index, and exposes a &lt;code&gt;RetrieveAndGenerate&lt;/code&gt; API that accepts a natural language question and returns a grounded answer with citations pointing back to the source document. No hallucination of procedures that don't exist. No guessing which version is current. Just the answer, and the file it came from.&lt;/p&gt;

&lt;h2&gt;Building the Knowledge Base with Terraform — The Fix&lt;/h2&gt;

&lt;p&gt;The implementation has three layers: an S3 bucket as the documentation source, an OpenSearch Serverless collection as the vector store, and the Bedrock Knowledge Base resource that wires them together. I'll cover two important gotchas before the code, because they cost us an afternoon of debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for this:&lt;/strong&gt; The S3 source bucket must be in the same AWS region as the Knowledge Base. Cross-region sources are not supported, and the API will not tell you this clearly — it simply fails to ingest. We had our runbooks bucket in &lt;code&gt;us-west-2&lt;/code&gt; and tried to create the Knowledge Base in &lt;code&gt;us-east-1&lt;/code&gt;. The Knowledge Base created successfully. The ingestion job completed with zero documents indexed. No error. Thirty minutes of debugging a perfectly valid configuration before we checked the region constraint in the &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/knowledge-base-create.html" rel="noopener noreferrer"&gt;Bedrock Knowledge Bases documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for this too:&lt;/strong&gt; The OpenSearch Serverless collection type must be &lt;code&gt;VECTORSEARCH&lt;/code&gt;. Using &lt;code&gt;TIMESERIES&lt;/code&gt; or &lt;code&gt;SEARCH&lt;/code&gt; causes the Knowledge Base creation to fail with a &lt;code&gt;ValidationException&lt;/code&gt; that mentions "incompatible collection type" — which is clear enough once you know what to look for, but easy to miss if you're copying an existing OSS collection config from another project.&lt;/p&gt;

&lt;p&gt;The IAM role for the Knowledge Base needs two permissions that are easy to miss together: &lt;code&gt;s3:GetObject&lt;/code&gt; on the source bucket, and &lt;code&gt;aoss:APIAccessAll&lt;/code&gt; on the OpenSearch Serverless collection. Missing the AOSS policy is the single most common deployment blocker we've seen across three separate team setups.&lt;/p&gt;

&lt;p&gt;Here's the Terraform that creates the full stack. We're using Titan Embeddings V2 for the embedding model — it handles English operational text well and costs less than Cohere at our query volume. OpenSearch Serverless minimum is two OCUs (one indexing, one search), which runs about $175/month. For a low-traffic internal tool, Aurora pgvector is 60–70% cheaper at comparable latency, but OSS is simpler to operate and scales automatically if your team grows.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# bedrock_knowledge_base.tf
# Terraform &amp;gt;= 1.7, AWS provider &amp;gt;= 5.40.0
# Creates: S3 source bucket, Bedrock Knowledge Base, OpenSearch Serverless collection

resource "aws_s3_bucket" "runbooks" {
  bucket = "ops-runbooks-bedrock-source-${var.environment}"
  # IMPORTANT: bucket must be in same region as Knowledge Base
}

resource "aws_s3_bucket_server_side_encryption_configuration" "runbooks" {
  bucket = aws_s3_bucket.runbooks.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = var.kms_key_arn  # pass same key to Knowledge Base below
    }
  }
}

resource "aws_bedrockagent_knowledge_base" "ops_docs" {
  name     = "ops-runbooks-kb"
  role_arn = aws_iam_role.bedrock_kb.arn

  knowledge_base_configuration {
    type = "VECTOR"
    vector_knowledge_base_configuration {
      # Titan Embeddings V2 — 1536 dimensions, supports English operational text
      embedding_model_arn = "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0"
    }
  }

  storage_configuration {
    type = "OPENSEARCH_SERVERLESS"
    opensearch_serverless_configuration {
      collection_arn    = aws_opensearchserverless_collection.vectors.arn
      vector_index_name = "ops-runbooks-index"
      field_mapping {
        vector_field   = "bedrock-knowledge-base-default-vector"
        text_field     = "AMAZON_BEDROCK_TEXT_CHUNK"
        metadata_field = "AMAZON_BEDROCK_METADATA"
      }
    }
  }
}

resource "aws_bedrockagent_data_source" "runbooks_s3" {
  knowledge_base_id = aws_bedrockagent_knowledge_base.ops_docs.id
  name              = "runbooks-s3-source"

  data_source_configuration {
    type = "S3"
    s3_configuration {
      bucket_arn = aws_s3_bucket.runbooks.arn
    }
  }

  vector_ingestion_configuration {
    chunking_configuration {
      chunking_strategy = "FIXED_SIZE"
      fixed_size_chunking_configuration {
        max_tokens         = 300
        overlap_percentage = 20  # 20% overlap preserves context across chunk boundaries
      }
    }
  }
}

# EventBridge rule: re-sync Knowledge Base when new runbook lands in S3
resource "aws_cloudwatch_event_rule" "s3_runbook_upload" {
  name = "bedrock-kb-sync-on-runbook-upload"
  event_pattern = jsonencode({
    source      = ["aws.s3"]
    detail-type = ["Object Created"]
    detail = {
      bucket = { name = [aws_s3_bucket.runbooks.bucket] }
    }
  })
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;One note on encryption: enable SSE-KMS on the source bucket and pass the same KMS key ARN to the Knowledge Base configuration. Runbooks frequently contain internal hostnames, connection strings, and escalation contacts. Storing them unencrypted at rest in the vector index is a security gap that's easy to overlook because the ingestion pipeline doesn't warn you.&lt;/p&gt;

&lt;p&gt;Ingestion is not real-time. After a document lands in S3, expect a 2–8 minute lag before it's queryable. The EventBridge rule above triggers a &lt;code&gt;StartIngestionJob&lt;/code&gt; automatically on S3 object creation, so your index stays fresh without manual intervention. For a 50-document corpus, ingestion typically completes in under five minutes.&lt;/p&gt;

&lt;h2&gt;Wiring It Into Your Incident Workflow&lt;/h2&gt;

&lt;p&gt;The Knowledge Base only helps if engineers actually use it during incidents. Telling people "go to the AWS console and query the Knowledge Base" doesn't work under pressure. We wired it directly into Slack via a Lambda-backed slash command: &lt;code&gt;/ask-runbook "RDS failover steps"&lt;/code&gt;. The answer appears in the channel thread within three seconds, with citations showing which S3 file each chunk came from.&lt;/p&gt;

&lt;p&gt;One thing that tripped up two engineers on our team: the correct SDK method is &lt;code&gt;client.retrieve_and_generate()&lt;/code&gt; on the &lt;code&gt;bedrock-agent-runtime&lt;/code&gt; client — not &lt;code&gt;client.invoke_model()&lt;/code&gt; on the &lt;code&gt;bedrock-runtime&lt;/code&gt; client. They're different service clients entirely. Using &lt;code&gt;invoke_model()&lt;/code&gt; bypasses the Knowledge Base retrieval layer and talks directly to the foundation model, which will confidently hallucinate procedures that don't exist in your documentation. I stopped trusting any runbook Q&amp;amp;A answer after this happened once in a staging incident drill.&lt;/p&gt;

&lt;p&gt;We set &lt;code&gt;maxTokens&lt;/code&gt; to 512 and &lt;code&gt;temperature&lt;/code&gt; to 0.0. The token cap keeps answers readable in Slack without truncating critical steps. Zero temperature makes responses deterministic — for operational procedures, you don't want creative variation between queries. We also filter out retrieval results with a score below 0.4 in application logic, so low-confidence chunks don't pollute the cited sources list. The &lt;a href="https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent-runtime_RetrieveAndGenerate.html" rel="noopener noreferrer"&gt;RetrieveAndGenerate API reference&lt;/a&gt; documents the full response schema including the &lt;code&gt;score&lt;/code&gt; field per retrieved reference.&lt;/p&gt;

&lt;p&gt;Cost at our query volume runs approximately $0.008–$0.015 per query, combining Titan Embeddings V2 ingestion cost with Claude 3 Sonnet inference on retrieval. At 200 incident queries per month, that's under $3. The OpenSearch Serverless OCU cost dominates the budget by a wide margin — the per-query inference cost is essentially rounding error.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
# lambda_function.py
# Slack slash command handler: /ask-runbook "&amp;lt;question&amp;gt;"
# Runtime: Python 3.12, boto3 &amp;gt;= 1.34.0
# Required env vars: KNOWLEDGE_BASE_ID, MODEL_ARN, SLACK_BOT_TOKEN

import json
import os
import boto3
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

bedrock_runtime = boto3.client(
    service_name="bedrock-agent-runtime",
    region_name="us-east-1"  # must match Knowledge Base region
)

slack_client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])

KNOWLEDGE_BASE_ID = os.environ["KNOWLEDGE_BASE_ID"]
# Use Claude 3 Sonnet — balances cost and answer quality for runbook Q&amp;amp;A
MODEL_ARN = os.environ["MODEL_ARN"]
# Example: arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-sonnet-20240229-v1:0

def query_knowledge_base(question: str) -&amp;gt; dict:
    """
    Call RetrieveAndGenerate with citation mode enabled.
    Returns generated answer and source document references.
    """
    response = bedrock_runtime.retrieve_and_generate(
        input={"text": question},
        retrieveAndGenerateConfiguration={
            "type": "KNOWLEDGE_BASE",
            "knowledgeBaseConfiguration": {
                "knowledgeBaseId": KNOWLEDGE_BASE_ID,
                "modelArn": MODEL_ARN,
                "retrievalConfiguration": {
                    "vectorSearchConfiguration": {
                        # Return top 5 chunks; filter low-confidence in post-processing
                        "numberOfResults": 5
                    }
                },
                "generationConfiguration": {
                    "inferenceConfig": {
                        "textInferenceConfig": {
                            # Keep answers concise for Slack readability
                            "maxTokens": 512,
                            "temperature": 0.0  # deterministic for operational answers
                        }
                    }
                }
            }
        }
    )
    return response

def format_slack_response(bedrock_response: dict) -&amp;gt; str:
    """
    Extract answer text and cited source S3 keys.
    Filters out citations with retrieval score below 0.4.
    """
    answer = bedrock_response["output"]["text"]
    citations = bedrock_response.get("citations", [])

    sources = []
    for citation in citations:
        for ref in citation.get("retrievedReferences", []):
            score = ref.get("score", 0)
            if score &amp;gt;= 0.4:  # discard low-confidence chunks
                s3_uri = ref["location"]["s3Location"]["uri"]
                sources.append(f"• `{s3_uri}` (score: {score:.2f})")

    source_block = "\n".join(sources) if sources else "_No high-confidence sources found_"
    return f"*Answer:*\n{answer}\n\n*Sources:*\n{source_block}"

def lambda_handler(event, context):
    # Slack sends slash command payload as URL-encoded form data
    body = dict(x.split("=") for x in event["body"].split("&amp;amp;"))
    question = body.get("text", "").replace("+", " ")
    channel_id = body["channel_id"]

    if not question:
        return {"statusCode": 200, "body": "Please provide a question after /ask-runbook"}

    try:
        bedrock_response = query_knowledge_base(question)
        formatted = format_slack_response(bedrock_response)
        slack_client.chat_postMessage(channel=channel_id, text=formatted)
    except bedrock_runtime.exceptions.ResourceNotFoundException:
        slack_client.chat_postMessage(
            channel=channel_id,
            text="Knowledge Base not found. Check KNOWLEDGE_BASE_ID env var."
        )
    except SlackApiError as e:
        print(f"Slack error: {e.response['error']}")

    return {"statusCode": 200, "body": ""}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;One format note: Bedrock Knowledge Bases supports &lt;code&gt;.md&lt;/code&gt;, &lt;code&gt;.txt&lt;/code&gt;, &lt;code&gt;.html&lt;/code&gt;, &lt;code&gt;.doc&lt;/code&gt;, &lt;code&gt;.docx&lt;/code&gt;, &lt;code&gt;.pdf&lt;/code&gt;, and &lt;code&gt;.csv&lt;/code&gt; for ingestion. If your team writes runbooks in AsciiDoc (&lt;code&gt;.adoc&lt;/code&gt;) or reStructuredText (&lt;code&gt;.rst&lt;/code&gt;) — both unsupported — you'll need a CI step that pre-converts them before pushing to S3. We handle this with a small Pandoc step in our GitHub Actions pipeline on merge to main. It's two lines and it saves a confusing silent-failure when the ingestion job processes zero pages from a file it can't parse.&lt;/p&gt;

&lt;h2&gt;Prevention Checklist — Keeping the Knowledge Base Trustworthy&lt;/h2&gt;

&lt;p&gt;The system you just built will decay into the same problem it solved unless you treat the documentation pipeline as a first-class engineering concern. Here's what we put in place to prevent that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs-as-code with CI enforcement.&lt;/strong&gt; Runbooks live in a Git repository. A GitHub Actions pipeline lints Markdown on every pull request (we use &lt;code&gt;markdownlint-cli2&lt;/code&gt;) and pushes validated files to the S3 source bucket on merge to main. The CI step also converts any non-native formats via Pandoc. This means the Knowledge Base index only ever contains files that passed a review and a format check. Stale docs in someone's local drive never reach the vector store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-query alerting.&lt;/strong&gt; We set a CloudWatch alarm on the &lt;code&gt;KnowledgeBaseQueryCount&lt;/code&gt; metric dropping to zero for seven consecutive days. Silence is a warning signal. It means engineers stopped trusting the tool, which is exactly what happened with our old Confluence setup before we noticed. The alarm routes to the same PagerDuty service as our monitoring alerts, but at low priority — it creates a ticket, not a page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quarterly retrieval score review.&lt;/strong&gt; Bedrock logs retrieval results to CloudWatch Logs when you enable that option on the Knowledge Base. We have a scheduled Lambda that queries those logs monthly, identifies source documents where the average retrieval score across all queries is below 0.35, and creates a JIRA ticket flagging them for human review. Low scores mean the chunks from that document aren't matching real operational questions — usually because the document is outdated, poorly structured, or covers a system that no longer exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Region discipline.&lt;/strong&gt; Add a &lt;code&gt;variable "aws_region"&lt;/code&gt; to your Terraform with a validation block that enforces it matches the region of your Bedrock-enabled account. Prevent the cross-region S3 source mistake at the infrastructure definition layer, not at debug time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model ARN pinning.&lt;/strong&gt; Store the &lt;code&gt;MODEL_ARN&lt;/code&gt; environment variable in AWS Systems Manager Parameter Store, not as a hardcoded Lambda environment variable. When Bedrock releases a new Claude version, you update one SSM parameter and all Lambda functions pick it up on next invocation — no deployment required. This also prevents the silent empty-response failure that happens when a model ARN from the wrong region is used.&lt;/p&gt;

&lt;p&gt;The Amazon Bedrock Knowledge Bases Q&amp;amp;A pattern isn't magic. It's a retrieval layer over documentation you already have. The value comes from making that documentation queryable in natural language, with citations, at the moment an engineer needs it most. We've seen our incident documentation MTTR contribution drop from an average of twelve minutes to under ninety seconds since rolling this out. That's not a metric we expected to move. It moved anyway. You can read more about how we approach AWS automation and incident tooling at &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/aws/" rel="noopener noreferrer"&gt;More AWS automation patterns and serverless architecture guides&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;Terraform infrastructure provisioning tips and real-world modules&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/monitoring/" rel="noopener noreferrer"&gt;Monitoring, alerting, and observability stack deep dives&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>Fix GitHub Copilot Terraform Security Risks Before They Hit Prod</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Sat, 27 Jun 2026 07:02:52 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/fix-github-copilot-terraform-security-risks-before-they-hit-prod-1f6j</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/fix-github-copilot-terraform-security-risks-before-they-hit-prod-1f6j</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/06/27/fix-github-copilot-terraform-security-risks-before-they-hit-prod" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Copilot just autocompleted your security group with port 0–65535 open to the world — and &lt;code&gt;terraform validate&lt;/code&gt; said it was fine. That's the GitHub Copilot Terraform security problem in one sentence: the suggestions are syntactically valid, pass every local check, and still destroy your security posture on first apply. We've seen it happen across three separate teams in the last six months, and the pattern is always the same: nobody noticed until a compliance scan flagged it post-deploy.&lt;/p&gt;

&lt;h2&gt;Symptoms&lt;/h2&gt;



&lt;p&gt;The signs aren't loud. That's what makes this dangerous. Here's what we actually observed before we locked things down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security group rules open to the world.&lt;/strong&gt; Copilot autocompletes &lt;code&gt;resource "aws_security_group"&lt;/code&gt; blocks with &lt;code&gt;ingress { from_port = 0, to_port = 0, cidr_blocks = ["0.0.0.0/0"] }&lt;/code&gt;. It's valid HCL. It passes &lt;code&gt;terraform validate&lt;/code&gt;. The first &lt;code&gt;terraform plan&lt;/code&gt; shows a clean diff. Then on the second apply — after your existing state has been modified — you get a conflict you can't easily roll back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RDS instances with public access enabled.&lt;/strong&gt; In roughly 60% of completions observed in public issue trackers, Copilot sets &lt;code&gt;publicly_accessible = true&lt;/code&gt; on &lt;code&gt;aws_db_instance&lt;/code&gt; resources. It also defaults &lt;code&gt;deletion_protection = false&lt;/code&gt; on RDS clusters, Cloud SQL instances, and Azure PostgreSQL servers. Both values look like reasonable defaults to someone new to the codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubernetes manifests with security context stripped.&lt;/strong&gt; The Helm/Kubernetes variant is equally quiet. Copilot suggests &lt;code&gt;hostNetwork: true&lt;/code&gt; as a quick fix for DNS resolution issues inside pods — which bypasses network policy entirely. It drops &lt;code&gt;readOnlyRootFilesystem&lt;/code&gt; from &lt;code&gt;securityContext&lt;/code&gt; blocks without any warning. The manifest applies cleanly. The container runs. The risk is invisible until someone audits it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hallucinated module references.&lt;/strong&gt; Copilot has zero visibility into your &lt;code&gt;.tfstate&lt;/code&gt;. It generates references like &lt;code&gt;module.vpc.private_subnet_ids&lt;/code&gt; that don't exist in your actual module structure. The error only surfaces at plan time: &lt;code&gt;Error: Reference to undeclared module — A managed resource "module.vpc" has not been declared in the root module&lt;/code&gt;. By then the suggestion has already been committed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; &lt;code&gt;lifecycle { ignore_changes = all }&lt;/code&gt; appearing in Copilot suggestions as a way to "silence drift warnings." This is a correctness trap. It masks real infrastructure divergence and should be treated as a blocking finding, not a style preference.&lt;/p&gt;

&lt;h2&gt;Root Cause: Why Copilot Gets IaC Wrong&lt;/h2&gt;

&lt;p&gt;Stop blaming yourself or your team. There are three structural reasons Copilot underperforms specifically on infrastructure code compared to application code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause #1: Training data skew.&lt;/strong&gt; Public Terraform repos over-represent quick demos, blog posts, and tutorials that intentionally skip security hardening to keep examples short. Copilot's probability distribution has learned from that corpus. It favors insecure defaults — &lt;code&gt;deletion_protection = false&lt;/code&gt;, &lt;code&gt;publicly_accessible = true&lt;/code&gt;, &lt;code&gt;acl = "public-read"&lt;/code&gt; — because those values appear constantly in "getting started" content. Checkov check &lt;code&gt;CKV_AWS_57&lt;/code&gt; exists specifically because S3 buckets with public ACLs are that common in training data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause #2: No state awareness.&lt;/strong&gt; Copilot has no access to your &lt;code&gt;.tfstate&lt;/code&gt;, your module outputs, your workspace variables, or your backend configuration. It generates module references and resource outputs based on pattern matching against what it has seen in public repos. When your module structure doesn't match that pattern, the suggestion compiles but fails at plan or apply time. There's no feedback loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause #3: Context window truncation.&lt;/strong&gt; In files over roughly 300 lines, Copilot loses the top-of-file &lt;code&gt;provider&lt;/code&gt; block and &lt;code&gt;required_providers&lt;/code&gt; version constraints. It starts generating syntax valid for Terraform 0.12 or 0.13 — &lt;code&gt;${var.name}&lt;/code&gt; interpolation where it's unnecessary, &lt;code&gt;list()&lt;/code&gt; and &lt;code&gt;map()&lt;/code&gt; type constructors that were deprecated in 0.14 — inside a codebase running Terraform 1.7.x. The code applies, but it's semantically wrong and will cause issues when you upgrade.&lt;/p&gt;

&lt;p&gt;One more gotcha worth calling out separately: &lt;strong&gt;Copilot Chat in VS Code reads all open editor tabs as context.&lt;/strong&gt; If you have &lt;code&gt;prod.tfvars&lt;/code&gt; open while asking Copilot to "generate a similar staging config," it will echo production account IDs, bucket names, and state key paths back into the generated output. Every repo contributor with Copilot access can see those values in the suggestion. This is a lateral information exposure risk in multi-team organizations, and most engineers don't know it's happening.&lt;/p&gt;

&lt;h2&gt;Fix #1 — Add a &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; Guardrail File&lt;/h2&gt;

&lt;p&gt;The fastest single-file intervention. No CI changes required. Supported in GitHub Copilot for Business and Enterprise (not available in individual Free/Pro plans as of Q1 2025). This file instructs Copilot to follow repo-specific rules during both inline completions and Copilot Chat sessions.&lt;/p&gt;

&lt;p&gt;Create the file at exactly this path — &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; — and include directives like these:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Copilot Instructions — IaC Repository

## Security Rules (apply to all Terraform and Kubernetes suggestions)

- Never suggest `0.0.0.0/0` in security group ingress or egress rules
- Always include `lifecycle { prevent_destroy = true }` on stateful resources
  (aws_db_instance, aws_s3_bucket, aws_elasticache_cluster, aws_rds_cluster)
- Default encryption to `true` for all storage resources
- Set `publicly_accessible = false` on all database resources
- Set `deletion_protection = true` on all database and cache resources
- Never suggest `lifecycle { ignore_changes = all }` — this masks drift
- Pin all provider versions using the `~&amp;gt;` pessimistic constraint operator
- Never suggest `hostNetwork: true` in Kubernetes or Helm manifests
- Always include `readOnlyRootFilesystem: true` in container securityContext

## Terraform Version
- Target Terraform &amp;gt;= 1.5.0. Do not generate 0.12-era interpolation syntax.
- Use `for_each` with conditional sets instead of `count` for feature toggles.

## Provider Versions
- aws: ~&amp;gt; 5.40
- kubernetes: ~&amp;gt; 2.30
- helm: ~&amp;gt; 2.13
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; this file is advisory, not enforced. Copilot may still violate these rules under high-ambiguity completions or when the file isn't fully loaded in context. Treat it as signal reduction — it meaningfully reduces bad suggestions, but it is not a hard block. You still need the CI layer in Fix #2.&lt;/p&gt;

&lt;h2&gt;Fix #2 — Block Dangerous Patterns in CI with tfsec and Checkov&lt;/h2&gt;

&lt;p&gt;This is the enforcement layer. What the instructions file misses, the pipeline catches. We run both tfsec and Checkov as a single required status check on every PR that touches &lt;code&gt;*.tf&lt;/code&gt; or &lt;code&gt;*.tfvars&lt;/code&gt; files. Both tools must pass before merge is allowed — not advisory, not &lt;code&gt;continue-on-error: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The most common mistake I see: teams install tfsec and Checkov, set &lt;code&gt;continue-on-error: true&lt;/code&gt; to avoid blocking the team during rollout, and then never flip the flag. Findings accumulate. Nobody resolves them. The tools become theater. Make the check required in branch protection rules from day one, even if you start with a narrow check list.&lt;/p&gt;

&lt;p&gt;Note on tooling: &lt;code&gt;tfsec&lt;/code&gt; version &lt;code&gt;1.28.x&lt;/code&gt; is the last stable OSS release before Aqua Security shifted focus to &lt;code&gt;trivy&lt;/code&gt; for IaC scanning. The forward-compatible replacement is &lt;code&gt;trivy config .&lt;/code&gt; — worth planning the migration now. We're still on tfsec in older pipelines but all new repos use trivy.&lt;/p&gt;

&lt;p&gt;Here's the full workflow. It scopes Checkov to only changed Terraform directories using &lt;code&gt;git diff&lt;/code&gt; output, which cuts scan runtime from 4–6 minutes on large monorepos down to under 45 seconds. It also uploads tfsec findings to the GitHub Security tab via SARIF — but note that SARIF upload via &lt;code&gt;github/codeql-action/upload-sarif@v3&lt;/code&gt; requires GitHub Advanced Security enabled on the repo:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# .github/workflows/iac-copilot-guardrails.yml
# Blocks dangerous Copilot-generated IaC patterns on every PR
# Requires: GitHub Actions, tfsec 1.28.x, checkov &amp;gt;= 2.3.0

name: IaC Security Scan

on:
  pull_request:
    paths:
      - '**.tf'
      - '**.tfvars'
      - '**/Chart.yaml'
      - '**/values.yaml'

permissions:
  contents: read
  security-events: write   # required for SARIF upload to GitHub Security tab
  pull-requests: write     # required for inline PR annotations

jobs:
  security-scan:
    name: Scan Copilot-Generated IaC
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # Identify only changed Terraform directories to keep scan fast
      - name: Get changed TF directories
        id: changed
        run: |
          git fetch origin ${{ github.base_ref }} --depth=1
          CHANGED_DIRS=$(git diff --name-only origin/${{ github.base_ref }}...HEAD \
            | grep '\.tf$' \
            | xargs -I{} dirname {} \
            | sort -u \
            | tr '\n' ',')
          echo "dirs=${CHANGED_DIRS}" &amp;gt;&amp;gt; $GITHUB_OUTPUT

      # tfsec: catches HIGH/CRITICAL misconfigs, outputs SARIF for Security tab
      - name: Run tfsec
        uses: aquasecurity/tfsec-sarif-action@v0.1.4
        with:
          sarif_file: tfsec.sarif
          minimum_severity: HIGH
          # Exclude downloaded provider modules — only scan authored code
          additional_args: --exclude-downloaded-modules

      - name: Upload tfsec SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: tfsec.sarif
          category: tfsec

      # Checkov: policy-as-code checks, blocks PR if any HIGH finding
      - name: Run Checkov on changed directories
        id: checkov
        run: |
          pip install checkov==3.2.0 --quiet

          # Build --directory flags from changed dirs output
          DIRS="${{ steps.changed.outputs.dirs }}"
          DIR_FLAGS=$(echo "$DIRS" | tr ',' '\n' | sed 's/^/--directory /' | tr '\n' ' ')

          checkov \
            $DIR_FLAGS \
            --framework terraform \
            --check CKV_AWS_8,CKV_AWS_24,CKV_AWS_57,CKV_AWS_135,CKV_K8S_30 \
            --compact \
            --output cli \
            --output-file-path console \
            --hard-fail-on HIGH  # PR fails on HIGH severity — not advisory
        continue-on-error: false   # INTENTIONAL: must be false to block merge

      # Annotate PR with Checkov findings as inline comments
      - name: Post Checkov summary to PR
        if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '❌ **IaC Security Scan failed.** Checkov found HIGH severity issues in Copilot-generated Terraform. Review the Security tab for details before merging.'
            })
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;One cost note: running Checkov and tfsec as separate parallel jobs doubles your CI minute consumption. Keep them in a single &lt;code&gt;security-scan&lt;/code&gt; job running sequentially. On GitHub Actions free tier (2,000 min/month), this matters for active teams.&lt;/p&gt;

&lt;h2&gt;Fix #3 — Scope Copilot Access with &lt;code&gt;.copilotignore&lt;/code&gt; and Content Exclusions&lt;/h2&gt;

&lt;p&gt;This is the hardest fix to get teams to adopt, and also the most important one for production environments. The goal is to surgically remove your highest-risk files from Copilot's context window so it cannot read sensitive infrastructure definitions or echo them back in suggestions.&lt;/p&gt;

&lt;p&gt;Create a &lt;code&gt;.copilotignore&lt;/code&gt; file at the repo root. Syntax mirrors &lt;code&gt;.gitignore&lt;/code&gt;. This prevents Copilot from indexing matched files as context for suggestions. At minimum, include these patterns:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# .copilotignore
# Prevents Copilot from using these files as suggestion context

# Variable files with real values — account IDs, bucket names, ARNs
terraform.tfvars
*.auto.tfvars
*.tfvars.json

# Backend config — contains real S3 bucket names and state key paths
backend.tf
backend.hcl

# Files that may contain sensitive resource definitions
secrets.tf
credentials.tf

# Production environment configs — exclude entirely
envs/prod/**
environments/production/**
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For GitHub Copilot Enterprise, go further. Configure Content Exclusions at the organization level under &lt;strong&gt;Settings → Copilot → Content exclusion&lt;/strong&gt;. Use glob patterns like &lt;code&gt;**/prod/**/*.tf&lt;/code&gt; to exclude all production Terraform from Copilot context across every repo in the org. Organization-level exclusions override repo-level &lt;code&gt;.copilotignore&lt;/code&gt; — org settings win. This is the hard block. The &lt;code&gt;.copilotignore&lt;/code&gt; is a soft hint; Content Exclusions are enforced by the platform.&lt;/p&gt;

&lt;p&gt;The concrete risk without this: Copilot Chat reads all open editor tabs. Open &lt;code&gt;backend.tf&lt;/code&gt; in VS Code, ask Copilot to "generate a staging backend config," and it will echo your production S3 bucket name and state key path directly into the suggestion. Any repo contributor with Copilot access sees that output. In a multi-team org with shared repos, that's a real lateral information exposure path.&lt;/p&gt;

&lt;p&gt;Also worth enabling: Copilot audit logs in GitHub Enterprise org settings. Suggestions are not logged by default at the repo level. Turning on audit logs lets you track which suggestions were accepted in IaC files — useful for compliance and incident investigation.&lt;/p&gt;

&lt;h2&gt;Prevention — Build a Copilot-Aware IaC Module Template&lt;/h2&gt;

&lt;p&gt;Fixes #1–3 are reactive. This one is proactive. The goal is to make the secure path the default path — so engineers never start from a blank, unconstrained Terraform file where Copilot's bad defaults have nothing to override them.&lt;/p&gt;

&lt;p&gt;Create a &lt;code&gt;terraform-module-template&lt;/code&gt; repository using GitHub's template repository feature. Every new module repo gets created from this template and inherits the full guardrail stack on day one. The template should include a pre-populated &lt;code&gt;versions.tf&lt;/code&gt; with pinned providers, a &lt;code&gt;variables.tf&lt;/code&gt; with typed and validated inputs, and a &lt;code&gt;.github/copilot-instructions.md&lt;/code&gt; already in place.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;versions.tf&lt;/code&gt; file does double duty: it constrains Copilot's context (when the file is open, Copilot inherits the version and provider patterns) and it prevents the context-window-truncation problem by keeping version constraints at the top of every module. Here's what that baseline file looks like, with the RDS resource showing all Copilot-common insecure defaults explicitly overridden:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# terraform-module-template/versions.tf
# Drop this file into every new module repo via GitHub template repository

terraform {
  # Prevents Copilot from generating 0.12-era syntax
  required_version = "&amp;gt;= 1.5.0, &amp;lt; 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      # Pessimistic constraint — Copilot defaults to unpinned or too-broad ranges
      # Pin minor version to prevent breaking changes from auto-accepted suggestions
      version = "~&amp;gt; 5.40"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~&amp;gt; 2.30"
    }
  }
}

# Local that makes destruction intent explicit and auditable
locals {
  # Only staging workspaces allow destroy — production is always protected
  # Copilot will inherit this pattern from context if file is open in editor
  allow_destroy = terraform.workspace == "staging" ? true : false
}

# Example: RDS instance with all Copilot-common insecure defaults overridden
resource "aws_db_instance" "main" {
  identifier        = "${var.env}-${var.app_name}-db"
  engine            = "postgres"
  engine_version    = "16.2"
  instance_class    = var.db_instance_class
  allocated_storage = var.db_storage_gb

  # Copilot default = true — always override explicitly
  publicly_accessible = false

  # Copilot default = false — always override explicitly
  deletion_protection = !local.allow_destroy

  # Copilot frequently omits this block entirely
  storage_encrypted = true
  kms_key_id        = var.kms_key_arn

  lifecycle {
    # Do NOT use ignore_changes = all — that masks drift
    # Only ignore tags to prevent plan noise from tagging automation
    ignore_changes = [tags]
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Add a &lt;code&gt;pre-commit&lt;/code&gt; configuration to the template as well. Using &lt;a href="https://pre-commit.com/" rel="noopener noreferrer"&gt;pre-commit framework v3.x&lt;/a&gt; with hooks &lt;code&gt;terraform_tfsec&lt;/code&gt;, &lt;code&gt;terraform_checkov&lt;/code&gt;, &lt;code&gt;terraform_validate&lt;/code&gt;, and &lt;code&gt;terraform_fmt&lt;/code&gt; catches issues before they ever reach CI. The most common failure mode: &lt;code&gt;terraform_checkov&lt;/code&gt; requires &lt;code&gt;checkov &amp;gt;= 2.3.0&lt;/code&gt; and Python &lt;code&gt;&amp;gt;= 3.8&lt;/code&gt; in the local environment. Version mismatch is the number one cause of "hook failed to install" errors on new developer machines. Pin both in your onboarding docs.&lt;/p&gt;

&lt;p&gt;One final thing I want to flag: the &lt;code&gt;count = var.enable_feature ? 1 : 0&lt;/code&gt; pattern. Copilot loves suggesting this for optional resources. In Terraform versions below 1.3, toggling this value causes resource replacement — a destroy followed by a create — not an update. We had a Redis cluster get destroyed in staging because of this. Use &lt;code&gt;for_each&lt;/code&gt; with a conditional set instead. It's a one-line change and it avoids the replacement behavior entirely. See the &lt;a href="https://developer.hashicorp.com/terraform/language/meta-arguments/count" rel="noopener noreferrer"&gt;Terraform count meta-argument docs&lt;/a&gt; for the full explanation of why this happens.&lt;/p&gt;

&lt;p&gt;GitHub Copilot Terraform security isn't about disabling the tool — it's about building a system where the tool's suggestions land inside guardrails rather than outside them. The &lt;code&gt;copilot-instructions.md&lt;/code&gt; file reduces noise. The CI pipeline enforces policy. The content exclusions protect sensitive context. The module template makes the secure path the default. Stack all four layers and Copilot becomes genuinely useful for IaC work instead of a liability. For more on enforcing infrastructure policy in CI pipelines, see the &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;DevOps_DayS runbook archive&lt;/a&gt; for related patterns across AWS and Kubernetes environments.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/terraform/" rel="noopener noreferrer"&gt;More Terraform security patterns, module design, and state management fixes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/ci-cd/" rel="noopener noreferrer"&gt;CI/CD pipeline hardening: gates, scans, and policy enforcement in GitHub Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;Infrastructure security automation: secret scanning, CVE gates, and compliance checks&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>terraform</category>
      <category>cicd</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Self-Hosted Ollama Homelab: 3 Mistakes Running Local LLMs</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Fri, 26 Jun 2026 07:02:10 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/self-hosted-ollama-homelab-3-mistakes-running-local-llms-11l1</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/self-hosted-ollama-homelab-3-mistakes-running-local-llms-11l1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/06/26/self-hosted-ollama-homelab-3-mistakes-running-local-llms" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;We thought setting up a self-hosted Ollama homelab for DevOps assistance would take an afternoon. Three OOM crashes, one exposed API endpoint, and a silent CPU fallback later, here's what actually works — and what we'd do differently from day one.&lt;/p&gt;

&lt;h2&gt;Context: Why We Wanted a Local LLM for Homelab Automation&lt;/h2&gt;



&lt;p&gt;Our homelab runs a fairly typical self-hosted DevOps stack: Gitea for source control, Drone CI for pipelines, Proxmox for VM management, and a handful of Docker Compose services scattered across two physical hosts. Day-to-day tasks involve writing Ansible playbooks, debugging systemd units, generating Dockerfiles, and occasionally reverse-engineering someone else's Terraform. All of that involves a lot of back-and-forth with language models.&lt;/p&gt;

&lt;p&gt;The problem with cloud-based LLM APIs — ChatGPT, Claude, Gemini — is that they require pasting real infrastructure configs into a browser. Hostnames, internal IP ranges, secrets that slipped into a playbook comment, service account names. None of that should leave the network. We also have air-gapped lab segments that physically can't reach the internet, and we wanted consistent tooling across both environments.&lt;/p&gt;

&lt;p&gt;We chose &lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; for a few concrete reasons: it ships as a single binary, it manages model downloads via a clean CLI, and it exposes an OpenAI-compatible REST API on port 11434 out of the box. That last point matters — it means any tool that supports the OpenAI API (Continue.dev in VS Code, shell scripts using &lt;code&gt;curl&lt;/code&gt;, custom Python utilities) works against Ollama without modification. The plan was straightforward. The execution was not.&lt;/p&gt;

&lt;h2&gt;Mistake 1: Assuming Any GPU Would "Just Work" with Ollama&lt;/h2&gt;

&lt;p&gt;The first host we tried had an NVIDIA GTX 1060 3GB installed. We ran &lt;code&gt;ollama run llama3&lt;/code&gt;, watched it load, and immediately noticed something was wrong. Inference was crawling at roughly 2 tokens per second. We expected 25 or more. No error. No warning. The model just loaded silently into system RAM and ran entirely on CPU.&lt;/p&gt;

&lt;p&gt;We burned two hours checking the wrong things — model quantization, RAM speed, thermal throttling — before running &lt;code&gt;ollama ps&lt;/code&gt; and seeing &lt;code&gt;100% CPU&lt;/code&gt; next to the loaded model. Then we checked the logs with &lt;code&gt;OLLAMA_DEBUG=1 ollama serve&lt;/code&gt; and found the real message:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;msg="no GPU detected"&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The GTX 1060 was visible to &lt;code&gt;nvidia-smi&lt;/code&gt;. The driver was loaded. But Ollama requires CUDA 11.8 or higher, and that host was running CUDA 11.4. The mismatch was silent from the client's perspective — Ollama simply fell back to CPU without surfacing any error to the caller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for this:&lt;/strong&gt; &lt;code&gt;nvidia-smi&lt;/code&gt; showing your GPU does not mean Ollama will use it. Always verify with &lt;code&gt;OLLAMA_DEBUG=1&lt;/code&gt; and look for &lt;code&gt;offload layers: 32/32&lt;/code&gt; in the output. That line confirms full GPU acceleration. Anything less means partial or zero offload.&lt;/p&gt;

&lt;p&gt;The fix required three steps: updating the CUDA toolkit to 11.8, pinning &lt;code&gt;nvidia-container-toolkit&lt;/code&gt; to version 1.14.3 (earlier versions had inconsistent behavior with Docker runtime detection), and ensuring &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; had &lt;code&gt;"default-runtime": "nvidia"&lt;/code&gt; set. After that, the same model ran at 28 tokens per second on the same hardware. Same model, same host, completely different experience.&lt;/p&gt;

&lt;p&gt;We also verified the CUDA version properly this time:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Check CUDA version — must be &amp;gt;= 11.8 for Ollama GPU support
nvcc --version

# Confirm Docker is using the nvidia runtime
docker info | grep -i runtime

# Verify GPU offload after starting Ollama with debug logging
OLLAMA_DEBUG=1 ollama serve 2&amp;gt;&amp;amp;1 | grep -i "offload layers"&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Mistake 2: Running Ollama Directly on the Host Without Resource Limits&lt;/h2&gt;

&lt;p&gt;Once GPU acceleration was working, we got greedy and pulled a larger model. &lt;code&gt;codellama:13b&lt;/code&gt; seemed like the right tool for generating longer Ansible roles and reviewing Dockerfiles with more context. We pulled it, ran it, and about forty seconds into the first inference request, everything went sideways.&lt;/p&gt;

&lt;p&gt;The OOM killer fired. It took down our Gitea container mid-push. A developer on the team lost a commit they hadn't pushed yet. The &lt;code&gt;codellama:13b&lt;/code&gt; model in Q4_K_M quantization needs roughly 8.5GB of VRAM — and on a 16GB host already running a dozen services, the model load spiked total RAM consumption to 14GB instantaneously. The kernel picked Gitea as the most expendable process. It wasn't.&lt;/p&gt;

&lt;p&gt;The root cause was straightforward: we were running Ollama directly on the host with no cgroup constraints, no memory limits, and no model eviction policy. In Ollama v0.1.38 and later, &lt;code&gt;OLLAMA_MAX_LOADED_MODELS&lt;/code&gt; controls how many models stay resident in memory simultaneously. In earlier versions, there's no eviction control at all. We hadn't set either &lt;code&gt;OLLAMA_MAX_LOADED_MODELS&lt;/code&gt; or &lt;code&gt;OLLAMA_NUM_PARALLEL&lt;/code&gt;, so Ollama defaulted to greedy behavior — loading whatever was asked, keeping it resident, and accepting parallel requests that multiplied memory pressure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for this:&lt;/strong&gt; &lt;code&gt;OLLAMA_NUM_PARALLEL&lt;/code&gt; defaults to 4 in recent versions. On a constrained homelab host, four simultaneous inference requests against a 7B model will absolutely OOM your other services. Set it to 1 unless you have dedicated hardware.&lt;/p&gt;

&lt;p&gt;The correct approach was to containerize Ollama with explicit resource caps and isolate it from critical services on a dedicated Docker network. We moved everything into Docker Compose, set &lt;code&gt;mem_limit: 10g&lt;/code&gt;, and added environment variables to enforce single-model, single-request behavior.&lt;/p&gt;

&lt;h2&gt;Mistake 3: Exposing the Ollama API Without Authentication&lt;/h2&gt;

&lt;p&gt;This one is embarrassing to admit. The default &lt;code&gt;ollama serve&lt;/code&gt; binds to &lt;code&gt;127.0.0.1&lt;/code&gt;, which is fine for bare-metal installs. But our Docker Compose setup used &lt;code&gt;ports: "11434:11434"&lt;/code&gt; without thinking through what that means on a flat home network. It published the port to every interface on the host — which sits on the same physical switch as our IoT VLAN due to a pfSense VLAN rule we'd misconfigured months earlier.&lt;/p&gt;

&lt;p&gt;Ollama has no built-in authentication as of v0.1.x. Anyone who could reach port 11434 could call &lt;code&gt;GET /api/tags&lt;/code&gt; to enumerate loaded models, send inference requests, or — and this is the one that actually worried us — trigger a &lt;code&gt;POST /api/pull&lt;/code&gt; to download a multi-gigabyte model and exhaust disk space. There's no rate limiting either. A single unauthenticated pull request from a compromised IoT device could fill a volume.&lt;/p&gt;

&lt;p&gt;We also had &lt;code&gt;OLLAMA_HOST=0.0.0.0&lt;/code&gt; set inside the container, which is necessary for the container's internal binding but easy to misread as "only affects the container namespace." It doesn't change the fact that the Docker port mapping exposes it externally.&lt;/p&gt;

&lt;p&gt;The fix was an Nginx reverse proxy with HTTP Basic Auth sitting in front of Ollama, combined with binding the proxy's published port to &lt;code&gt;127.0.0.1:8080&lt;/code&gt; on the host. Remote access goes through Tailscale, which restricts it to trusted nodes only. For the health check endpoint used by monitoring tools, we carved out an auth-exempt &lt;code&gt;location /api/tags&lt;/code&gt; block in Nginx.&lt;/p&gt;

&lt;h2&gt;What We Do Differently Now: A Stable, Reproducible Ollama Stack&lt;/h2&gt;

&lt;p&gt;The current setup avoids all three mistakes. Here's the full Docker Compose configuration we run in production on the homelab. It uses a pinned Ollama image, GPU passthrough via the &lt;code&gt;nvidia&lt;/code&gt; runtime, hard memory caps, and an Nginx auth proxy — all wired together with a named volume for model persistence.&lt;/p&gt;

&lt;p&gt;This Docker Compose file defines the full hardened stack. Read the inline comments carefully — several of the environment variables are non-obvious in their effect:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# docker-compose.yml — Production-hardened Ollama stack for homelab
# Tested with Ollama v0.1.38, Docker 24.x, nvidia-container-toolkit 1.14.3

version: "3.9"

services:
  ollama:
    image: ollama/ollama:0.1.38          # pinned — avoid surprise API changes
    container_name: ollama
    restart: unless-stopped
    runtime: nvidia                       # requires nvidia-container-toolkit
    environment:
      - OLLAMA_HOST=0.0.0.0              # bind inside container; proxy handles external auth
      - OLLAMA_NUM_PARALLEL=1            # prevent concurrent request memory spikes
      - OLLAMA_MAX_LOADED_MODELS=1       # evict previous model before loading new one
      - OLLAMA_DEBUG=0                   # set to 1 temporarily to verify GPU offload layers
    volumes:
      - ollama_models:/root/.ollama      # persist models across container rebuilds
    networks:
      - ollama_internal                  # isolated from critical services network
    deploy:
      resources:
        limits:
          memory: 10g                    # hard cap — prevents OOM-killing neighbors
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s                  # model server takes ~15s to initialize

  ollama-proxy:
    image: nginx:1.25-alpine
    container_name: ollama-proxy
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"             # bind to loopback only; use Tailscale for remote access
    volumes:
      - ./nginx/ollama.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro
    networks:
      - ollama_internal
    depends_on:
      ollama:
        condition: service_healthy

volumes:
  ollama_models:
    driver: local

networks:
  ollama_internal:
    driver: bridge
    internal: false                      # set true if Ollama should have no outbound internet&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The Nginx config below handles streaming inference correctly. The two most common mistakes people make here are forgetting &lt;code&gt;proxy_buffering off&lt;/code&gt; (which causes the response to appear all at once instead of streaming) and leaving &lt;code&gt;proxy_read_timeout&lt;/code&gt; at the default 60 seconds, which causes 504 errors mid-generation on longer prompts:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# nginx/ollama.conf — Reverse proxy with Basic Auth for Ollama API
# Generate .htpasswd: htpasswd -c nginx/.htpasswd devops
# Set proxy_read_timeout high — streaming inference takes time

server {
    listen 80;
    server_name _;

    # Basic auth — replace with mTLS for higher-security environments
    auth_basic           "Ollama API";
    auth_basic_user_file /etc/nginx/.htpasswd;

    location / {
        proxy_pass         http://ollama:11434;
        proxy_http_version 1.1;

        # Critical: streaming responses require these headers
        proxy_set_header   Connection '';
        proxy_buffering    off;
        proxy_cache        off;

        # Increase timeouts — default 60s causes 504 on long generations
        proxy_read_timeout    300s;
        proxy_connect_timeout 10s;
        proxy_send_timeout    300s;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # Health endpoint — exempt from auth for monitoring tools
    location /api/tags {
        auth_basic off;
        proxy_pass http://ollama:11434/api/tags;
    }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For model selection, we settled on two workhorses that fit within 6GB VRAM with quantization: &lt;code&gt;codellama:7b&lt;/code&gt; in Q4_K_M for quick completions and inline code suggestions, and &lt;code&gt;mistral:7b-instruct-v0.2-q4_K_M&lt;/code&gt; for longer reasoning tasks like reviewing a Terraform plan or explaining a failing systemd unit. The 13B models sound appealing but they're genuinely impractical on homelab hardware unless you have a dedicated GPU with 10GB+ VRAM.&lt;/p&gt;

&lt;p&gt;We also pin models using digest references in our Modelfiles — &lt;code&gt;FROM llama3:8b-instruct@sha256:abc123...&lt;/code&gt; — so a weekly &lt;code&gt;ollama pull&lt;/code&gt; cron job doesn't silently swap out a model we've tuned prompts for. Model versioning matters more than most people realize when you're building automation on top of LLM output.&lt;/p&gt;

&lt;p&gt;For observability, Ollama has no native Prometheus endpoint, so we rely on &lt;code&gt;ollama ps&lt;/code&gt; and &lt;code&gt;ollama list&lt;/code&gt; in a lightweight cron-based check, plus the Docker healthcheck defined in the Compose file. It's not elegant, but it catches the cases that actually happen: the model server failing to start, or a model getting stuck in a loading state. More details on our homelab monitoring approach are over at &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Running a self-hosted Ollama homelab is genuinely useful once the stack is stable. The self-hosted Ollama homelab we have now handles dozens of DevOps assistance requests per day with zero data leaving the network, predictable resource usage, and a setup that survives container restarts and host reboots without manual intervention. Getting there required making all three of these mistakes first — hopefully this saves you the OOM crashes.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/docker-compose/" rel="noopener noreferrer"&gt;More Docker Compose production patterns and service hardening&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/monitoring/" rel="noopener noreferrer"&gt;Homelab monitoring, alerting, and observability setups&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/security/" rel="noopener noreferrer"&gt;Container and network security configurations for self-hosted stacks&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Automate Kubernetes RBAC Audits with Python and a Custom Controller</title>
      <dc:creator>Oleksandr Kuryzhev</dc:creator>
      <pubDate>Thu, 25 Jun 2026 07:02:40 +0000</pubDate>
      <link>https://dev.to/oleksandr_kuryzhev_42873f/automate-kubernetes-rbac-audits-with-python-and-a-custom-controller-2inb</link>
      <guid>https://dev.to/oleksandr_kuryzhev_42873f/automate-kubernetes-rbac-audits-with-python-and-a-custom-controller-2inb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://kuryzhev.cloud/2026/06/25/automate-kubernetes-rbac-audits-with-python-and-a-custom-controller" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;The Night Our Cluster Opened Its Doors to Everyone&lt;/h2&gt;



&lt;p&gt;A single &lt;code&gt;kubectl apply&lt;/code&gt; from a well-meaning junior developer bound &lt;code&gt;cluster-admin&lt;/code&gt; to the CI service account — and our Kubernetes cluster stayed wide open for 11 days before anyone noticed. No alert fired. No pipeline failed. No one said a word. The misconfiguration was discovered only because a compliance review happened to trigger a manual RBAC audit, completely unrelated to the incident itself. That audit was the only reason we found it at all.&lt;/p&gt;

&lt;p&gt;The setup at the time was a multi-tenant cluster with 40-plus namespaces, shared by six product teams. The developer had been debugging a Helm deployment failure and needed broader permissions temporarily. They added the binding, fixed the issue, and forgot to clean it up. Completely understandable. Completely dangerous. For those 11 days, any workload running under that CI service account — or anything that could impersonate it — had full cluster-admin rights. In a cluster that hosts a payments namespace and a production environment for a regulated service, that is not a theoretical risk. That is an open door.&lt;/p&gt;

&lt;p&gt;The real lesson was not about the developer. It was about us. We had no automated Kubernetes RBAC compliance automation in place. Manual review of RBAC objects at scale is structurally impossible to do reliably. With 40+ namespaces, hundreds of RoleBindings, and teams deploying multiple times per day, the gap between "what we think the permissions look like" and "what they actually are" grows every single day. This article is about the Python controller we built to close that gap permanently.&lt;/p&gt;

&lt;h2&gt;Why RBAC Drift Happens Faster Than You Can Review It&lt;/h2&gt;

&lt;p&gt;Kubernetes RBAC has a design property that most people do not think about until it bites them: it is additive by default. When you run &lt;code&gt;kubectl apply&lt;/code&gt; on a RoleBinding, it never removes excess permissions that were added out-of-band. Helm chart upgrades can silently widen permission scopes between versions, and nobody notices because the upgrade succeeds and the app works. The permissions just quietly expand.&lt;/p&gt;

&lt;p&gt;We identified three root causes in our postmortem. First, there was no enforcement at admission time. A developer could bind any role to any subject and the API server would accept it without question. Second, we had no diff-based alerting on RBAC objects — changes to ClusterRoleBindings were not treated as security-relevant events, so they never triggered anything. Third, and most critically, ClusterRoleBindings live outside namespace-scoped review cycles. Most teams think about permissions at the namespace level. ClusterRoleBindings are invisible in that mental model.&lt;/p&gt;

&lt;p&gt;The two most dangerous patterns we see in the wild are the &lt;code&gt;system:masters&lt;/code&gt; group and wildcard verb rules (&lt;code&gt;verbs: ["*"]&lt;/code&gt;). Both are completely legal YAML. Both pass &lt;code&gt;kubectl apply&lt;/code&gt; without any warning. Wildcard verbs are particularly insidious because they survive role changes — if you later add a new resource type to the API server, anything with wildcard verbs automatically gets access to it. No one had to make a decision. The permission just appeared.&lt;/p&gt;

&lt;p&gt;There is also the &lt;code&gt;system:anonymous&lt;/code&gt; and &lt;code&gt;system:unauthenticated&lt;/code&gt; subject problem. Before hardening, default GKE clusters can have bindings to these subjects. If you have never audited for them, run this one-liner right now before reading further:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;kubectl get clusterrolebindings -o json \
  | jq '[.items[] | select(.subjects[]?.name == "system:anonymous")]'&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If that returns anything other than an empty array, stop and fix it. Then come back and build the controller.&lt;/p&gt;

&lt;h2&gt;Building the Audit Engine — A Python Controller That Catches What Humans Miss&lt;/h2&gt;

&lt;p&gt;The controller we built uses the &lt;code&gt;kubernetes&lt;/code&gt; Python client at version 28.1.0, which maps to the Kubernetes 1.28 API. One critical gotcha here: mismatched client versions cause silent deserialization errors on newer CRD fields. Pin the version and do not let dependabot auto-upgrade it without a test run against your cluster version first.&lt;/p&gt;

&lt;p&gt;The architecture is straightforward. A watch loop runs against &lt;code&gt;ClusterRoleBinding&lt;/code&gt;, &lt;code&gt;RoleBinding&lt;/code&gt;, and &lt;code&gt;ClusterRole&lt;/code&gt; resources. Policy rules live in a ConfigMap at &lt;code&gt;kube-system/rbac-audit-policy&lt;/code&gt;. When a violation is detected, the controller writes a &lt;code&gt;RBACViolation&lt;/code&gt; custom resource under the CRD group &lt;code&gt;audit.kuryzhev.cloud/v1alpha1&lt;/code&gt; and exposes Prometheus metrics on port 8080. Here is the full controller implementation:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# rbac_auditor_controller.py
# Requires: kubernetes==28.1.0, prometheus_client==0.20.0
# Deploy as a Deployment in kube-system with a dedicated ServiceAccount

import time
import yaml
import logging
from kubernetes import client, config, watch
from kubernetes.client.exceptions import ApiException
from prometheus_client import start_http_server, Counter, Gauge

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("rbac-auditor")

# --- Prometheus metrics ---
VIOLATIONS_TOTAL = Counter(
    "rbac_violations_total",
    "Total RBAC violations detected",
    ["namespace", "kind", "name", "rule"]
)
LAST_AUDIT_TS = Gauge("rbac_last_audit_timestamp", "Unix timestamp of last audit cycle")
BINDINGS_SCANNED = Counter("rbac_bindings_scanned_total", "Total bindings evaluated")

# --- Load policy from ConfigMap ---
def load_policy(core_v1: client.CoreV1Api) -&amp;gt; dict:
    cm = core_v1.read_namespaced_config_map("rbac-audit-policy", "kube-system")
    return yaml.safe_load(cm.data["policy.yaml"])

# --- Core violation checks ---
def check_binding(binding, policy: dict) -&amp;gt; list[str]:
    violations = []
    subjects = binding.subjects or []

    # Rule 1: Forbidden subjects (e.g. system:anonymous)
    forbidden_subjects = policy.get("forbidden_subjects", [])
    for subject in subjects:
        if subject.name in forbidden_subjects:
            violations.append(f"forbidden_subject:{subject.name}")

    # Rule 2: Binding references a forbidden ClusterRole by name
    role_ref = binding.role_ref
    if role_ref.name in policy.get("forbidden_roles", []):
        violations.append(f"forbidden_role_ref:{role_ref.name}")

    return violations

def check_clusterrole(role: client.V1ClusterRole, policy: dict) -&amp;gt; list[str]:
    violations = []
    for rule in (role.rules or []):
        verbs = rule.verbs or []
        # Flag wildcard verbs — most dangerous pattern in any RBAC config
        if "*" in verbs:
            violations.append("wildcard_verb")
        # Flag full explicit verb set — functionally identical to wildcard
        full_verbs = {"get", "list", "watch", "create", "update", "patch", "delete"}
        if full_verbs.issubset(set(verbs)):
            violations.append("full_verb_set_equivalent_to_wildcard")
    return violations

# --- Write RBACViolation CRD ---
def write_violation_crd(custom_api: client.CustomObjectsApi, name: str,
                         namespace: str, kind: str, rules: list[str]):
    body = {
        "apiVersion": "audit.kuryzhev.cloud/v1alpha1",
        "kind": "RBACViolation",
        "metadata": {
            # Truncate to 63 chars — Kubernetes name length limit
            "name": f"{kind.lower()}-{name}".replace(":", "-")[:63],
            "namespace": namespace or "cluster-scoped",
        },
        "spec": {
            "sourceKind": kind,
            "sourceName": name,
            "sourceNamespace": namespace,
            "violatedRules": rules,
        }
    }
    try:
        custom_api.create_namespaced_custom_object(
            group="audit.kuryzhev.cloud", version="v1alpha1",
            namespace="kube-system", plural="rbacviolations", body=body
        )
        logger.info(f"Created RBACViolation for {kind}/{name}: {rules}")
    except ApiException as e:
        if e.status == 409:
            # Already exists from a previous cycle — not an error
            logger.debug(f"RBACViolation for {kind}/{name} already exists, skipping")
        else:
            raise

# --- Main watch loop ---
def run_audit_loop():
    config.load_incluster_config()  # Use load_kube_config() for local dev
    rbac_v1 = client.RbacAuthorizationV1Api()
    core_v1 = client.CoreV1Api()
    custom_api = client.CustomObjectsApi()

    start_http_server(8080)  # Expose /metrics for Prometheus scraping
    logger.info("RBAC Auditor started, metrics on :8080")

    while True:
        try:
            policy = load_policy(core_v1)
            # Reset resource_version to "0" on each cycle — prevents 410 Gone
            # after etcd compaction, which would silently stop the watch loop
            resource_version = "0"

            # Audit ClusterRoleBindings — most dangerous scope, audit first
            crbs = rbac_v1.list_cluster_role_binding()
            for crb in crbs.items:
                BINDINGS_SCANNED.inc()
                violations = check_binding(crb, policy)
                if violations:
                    for v in violations:
                        VIOLATIONS_TOTAL.labels(
                            namespace="cluster-scoped",
                            kind="ClusterRoleBinding",
                            name=crb.metadata.name,
                            rule=v
                        ).inc()
                    write_violation_crd(custom_api, crb.metadata.name,
                                        None, "ClusterRoleBinding", violations)

            # Audit ClusterRoles for wildcard verbs
            crs = rbac_v1.list_cluster_role()
            for cr in crs.items:
                violations = check_clusterrole(cr, policy)
                if violations:
                    for v in violations:
                        VIOLATIONS_TOTAL.labels(
                            namespace="cluster-scoped",
                            kind="ClusterRole",
                            name=cr.metadata.name,
                            rule=v
                        ).inc()
                    write_violation_crd(custom_api, cr.metadata.name,
                                        None, "ClusterRole", violations)

            LAST_AUDIT_TS.set(time.time())
            logger.info("Audit cycle complete. Sleeping 300s.")
            # resync_period=300 — informer cache pattern, avoids hammering API server
            # Running full re-list every 30s on 500+ bindings adds ~120ms latency per cycle
            time.sleep(300)

        except ApiException as e:
            logger.error(f"API error during audit: {e.status} {e.reason}")
            time.sleep(30)

if __name__ == "__main__":
    run_audit_loop()&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Watch out for the 410 Gone response from the API server. This happens after etcd compaction and it silently kills your watch loop if you do not handle it. The fix is to reset &lt;code&gt;resource_version="0"&lt;/code&gt; at the start of each audit cycle, which forces a full re-list rather than trying to resume a stale watch. We learned this the hard way when the controller appeared healthy but had stopped receiving events for six hours.&lt;/p&gt;

&lt;p&gt;The other gotcha: do not grant the auditor service account &lt;code&gt;cluster-admin&lt;/code&gt; just because it needs to read RBAC objects. I see this in tutorials constantly and it makes me cringe every time. The auditor only needs &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;watch&lt;/code&gt; on RBAC resources. Here is the full deployment manifest with correctly scoped permissions:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# rbac-auditor-rbac.yaml
# Apply with: kubectl apply -f deploy/rbac-auditor-rbac.yaml

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: rbac-audit-policy
  namespace: kube-system
data:
  policy.yaml: |
    forbidden_subjects:
      - "system:anonymous"
      - "system:unauthenticated"
    forbidden_roles:
      - "cluster-admin"
    # Namespaces where ClusterRoleBinding is never expected
    restricted_namespaces:
      - "production"
      - "payments"

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: rbac-auditor
  namespace: kube-system
automountServiceAccountToken: true  # Required for in-cluster config

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: rbac-auditor-reader
rules:
  # Minimal read-only access — do NOT grant cluster-admin here (common mistake #1)
  - apiGroups: ["rbac.authorization.k8s.io"]
    resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get"]
  # Write access scoped only to our own CRD in kube-system
  - apiGroups: ["audit.kuryzhev.cloud"]
    resources: ["rbacviolations"]
    verbs: ["get", "list", "create", "update", "patch"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: rbac-auditor-binding
  annotations:
    # Flag for 90-day rotation review — the controller itself will enforce this
    audit.kuryzhev.cloud/reviewed-at: "2024-11-01"
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: rbac-auditor-reader
subjects:
  - kind: ServiceAccount
    name: rbac-auditor
    namespace: kube-system&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If the auditor pod starts and you immediately see &lt;code&gt;Forbidden: User "system:serviceaccount:kube-system:rbac-auditor" cannot list resource "clusterrolebindings"&lt;/code&gt; in the logs, the ClusterRoleBinding was not applied correctly. Check namespace alignment first — that accounts for 80% of these failures.&lt;/p&gt;

&lt;h2&gt;Wiring It Into Your Pipeline — From Detection to Remediation&lt;/h2&gt;

&lt;p&gt;Detection without action is just expensive logging. The controller exposes three Prometheus metrics: &lt;code&gt;rbac_violations_total&lt;/code&gt; as a counter labeled by namespace, kind, name, and rule; &lt;code&gt;rbac_last_audit_timestamp&lt;/code&gt; as a gauge so you can alert if the auditor itself stops running; and &lt;code&gt;rbac_bindings_scanned_total&lt;/code&gt; to track coverage. The alerting rule we use fires when &lt;code&gt;rbac_violations_total&lt;/code&gt; exceeds 0 for more than 15 minutes, giving the system one cycle to self-resolve before paging someone.&lt;/p&gt;

&lt;p&gt;In our Flux setup, annotated bindings trigger a webhook that opens a GitHub issue with the offending manifest diff and the specific policy rule that was violated. The developer who owns the namespace gets assigned automatically based on a CODEOWNERS-style mapping. This is important: the goal is not to block deployments automatically everywhere. The goal is to make the violation visible and attributed within minutes, not days.&lt;/p&gt;

&lt;p&gt;Auto-remediation is opt-in and deliberately scoped. The controller will only delete a RoleBinding if it carries the label &lt;code&gt;audit.kuryzhev.cloud/auto-remediate: "true"&lt;/code&gt;. We never touch unlabeled production bindings automatically. I made the mistake of enabling aggressive auto-remediation in a previous job and watched it delete a legitimate emergency access binding during an incident. Never again. Humans should approve remediation for anything in production scope.&lt;/p&gt;

&lt;p&gt;For CI pipeline integration, we run &lt;a href="https://github.com/PaloAltoNetworks/rbac-police" rel="noopener noreferrer"&gt;&lt;code&gt;rbac-police&lt;/code&gt;&lt;/a&gt; at version 0.3.2 as a gate against rendered Helm manifests before any cluster deployment. The command is straightforward:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# Run in CI before helm upgrade — fails pipeline on any HIGH severity finding
rbac-police eval --severity HIGH --fail-on-findings ./manifests/&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This catches the obvious mistakes before they ever reach the cluster. The in-cluster controller then handles drift that happens through out-of-band changes, kubectl one-liners during incidents, and Helm upgrades that quietly expand scopes. You need both layers.&lt;/p&gt;

&lt;p&gt;You can find more Kubernetes security and automation patterns at &lt;a href="https://kuryzhev.cloud/" rel="noopener noreferrer"&gt;kuryzhev.cloud&lt;/a&gt; — we cover real production scenarios, not just happy-path documentation.&lt;/p&gt;

&lt;h2&gt;Prevention Checklist — Stop the Drift Before It Starts&lt;/h2&gt;

&lt;p&gt;After running this system in production for several months, here is the checklist we now enforce on every cluster. Each item has a reason. Skip none of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Deploy OPA Gatekeeper with wildcard verb constraints at admission time.&lt;/strong&gt; The &lt;code&gt;K8sNoWildcardVerbs&lt;/code&gt; and &lt;code&gt;K8sRestrictClusterAdmin&lt;/code&gt; constraint templates from the &lt;a href="https://open-policy-agent.github.io/gatekeeper-library/website/" rel="noopener noreferrer"&gt;open-policy-agent/gatekeeper-library&lt;/a&gt; block the binding at &lt;code&gt;kubectl apply&lt;/code&gt; time, before it ever reaches etcd. This is your first line of defense. The in-cluster controller is your second. You want both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Run &lt;code&gt;rbac-police eval&lt;/code&gt; in every CI pipeline that renders Helm charts.&lt;/strong&gt; Use &lt;code&gt;--severity HIGH --fail-on-findings&lt;/code&gt;. Do not let it run in warn-only mode — warn-only modes get ignored within two weeks of deployment. If it finds something, the pipeline fails. Period.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Add a 90-day review annotation to every ClusterRoleBinding at creation time.&lt;/strong&gt; The annotation key is &lt;code&gt;audit.kuryzhev.cloud/reviewed-at&lt;/code&gt;. The controller flags any binding where this annotation is missing or older than 90 days. This forces a human to periodically confirm that a binding is still needed. Most "temporary" bindings survive indefinitely because no one ever looks at them again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Never watch only RoleBindings and ignore ClusterRoleBindings.&lt;/strong&gt; This is the single most common mistake I see in home-grown audit scripts. The most dangerous over-permissioned bindings are almost always cluster-scoped. If your audit does not cover ClusterRoleBindings, it covers maybe 40% of the actual risk surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Protect the &lt;code&gt;RBACViolation&lt;/code&gt; CRD itself with RBAC.&lt;/strong&gt; If developers can delete &lt;code&gt;RBACViolation&lt;/code&gt; objects, they can silently clear audit findings without fixing the underlying binding. The CRD should be read-only for all non-auditor service accounts. We found one developer had done exactly this in a dev cluster to make the noise go away before a demo. The binding stayed. The violation disappeared. That is worse than having no audit at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Set &lt;code&gt;automountServiceAccountToken: false&lt;/code&gt; on all pods except the auditor itself.&lt;/strong&gt; Most workloads do not need API server access. Defaulting to no token mount reduces the blast radius of any container escape significantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Alert on &lt;code&gt;rbac_last_audit_timestamp&lt;/code&gt; staleness, not just on violations.&lt;/strong&gt; If the auditor itself goes down — OOM kill, eviction, image pull failure — you want to know immediately. A silent auditor is worse than no auditor because it creates false confidence. We alert if the timestamp is more than 10 minutes old.&lt;/p&gt;

&lt;p&gt;Kubernetes RBAC compliance automation is not a one-time project. It is a continuous process. The controller we built did not eliminate human judgment — it made human judgment possible again by reducing the signal-to-noise ratio to something manageable. We went from "we audit RBAC quarterly if we remember" to "we know about every violation within five minutes." That 11-day window closed to under one audit cycle. That is the outcome that matters.&lt;/p&gt;

&lt;h2&gt;Related&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/kubernetes/" rel="noopener noreferrer"&gt;More Kubernetes security, RBAC, and cluster hardening patterns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/python/" rel="noopener noreferrer"&gt;Python automation scripts for infrastructure and cloud operations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kuryzhev.cloud/category/monitoring/" rel="noopener noreferrer"&gt;Prometheus alerting rules and Grafana dashboards for production clusters&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>python</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
