<?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: u11d</title>
    <description>The latest articles on DEV Community by u11d (u11d).</description>
    <link>https://dev.to/u11d</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%2Forganization%2Fprofile_image%2F11016%2F3aaba768-bbba-47eb-999a-9c314cf428f1.png</url>
      <title>DEV Community: u11d</title>
      <link>https://dev.to/u11d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/u11d"/>
    <language>en</language>
    <item>
      <title>Stop One EKS Node Group From Blocking Scale-Down in Another</title>
      <dc:creator>Daniel Kraszewski</dc:creator>
      <pubDate>Wed, 19 Aug 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/stop-one-eks-node-group-from-blocking-scale-down-in-another-2hb</link>
      <guid>https://dev.to/u11d/stop-one-eks-node-group-from-blocking-scale-down-in-another-2hb</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx11rf2lbou73d6tkwm27.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx11rf2lbou73d6tkwm27.png" alt="Stop One EKS Node Group From Blocking Scale-Down in Another" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If idle EKS nodes keep running while other node groups scale up around them, Cluster Autoscaler's global scale-down cooldown is the likely cause, and&amp;nbsp;&lt;code&gt;--scale-down-delay-type-local=true&lt;/code&gt;&amp;nbsp;is the fix. The flag makes Cluster Autoscaler track post-scale-up cooldowns per node group instead of cluster-wide, so a GPU pool adding capacity no longer resets the clock on an idle node in an unrelated memory pool. The default is&amp;nbsp;&lt;code&gt;false&lt;/code&gt;, which means every cluster running multiple ASGs has this behavior until someone turns it off.&lt;/p&gt;

&lt;p&gt;We hit this with Dagster. Jobs used separate pools for general work, large CPU and memory tasks, and GPU tasks, each with&amp;nbsp;&lt;code&gt;min_size = 0&lt;/code&gt;, so we expected an unused pool to disappear on its own schedule. It didn't. A job starting in one pool delayed scale-down in every other pool, and with jobs starting and ending throughout the day, nodes that were no longer needed stayed alive far longer than they should have.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why idle nodes stay running&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Take two node groups, one for memory-heavy batch jobs and one for GPU jobs, both with&amp;nbsp;&lt;code&gt;min_size = 0&lt;/code&gt;. At noon the last memory-heavy job completes, its node goes empty, and Cluster Autoscaler starts the normal waiting period before deletion. Ten minutes later that node has waited long enough - and at almost the same moment a GPU job appears, its pod can't schedule, and Cluster Autoscaler grows the GPU Auto Scaling Group. You'd expect the memory node to disappear now: it belongs to another group, has no useful work, and has already passed its own waiting period. It stays.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Memory group&lt;/th&gt;
&lt;th&gt;GPU group&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Last job ends&lt;/td&gt;
&lt;td&gt;Idle&lt;/td&gt;
&lt;td&gt;Memory node becomes unneeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12:10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Node has waited 10 minutes&lt;/td&gt;
&lt;td&gt;New job triggers scale-up&lt;/td&gt;
&lt;td&gt;Global cooldown starts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12:11&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Node is eligible for deletion&lt;/td&gt;
&lt;td&gt;New node is starting&lt;/td&gt;
&lt;td&gt;Memory node cannot scale down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12:20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Node has been idle 20 minutes&lt;/td&gt;
&lt;td&gt;Cooldown expires&lt;/td&gt;
&lt;td&gt;Deletion can proceed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One extra ten-minute wait doesn't look serious. Bursty workloads turn it into a loop: a CPU pool grows, then a GPU pool grows, then a general pool grows, and each event postpones unrelated scale-down work. The problem is easy to miss because every node group looks correctly configured - minimum size is zero, jobs finish, pods disappear, and the autoscaler still reports nodes as unneeded. Yet the EC2 instances stay active, and nothing in your Terraform or your Helm values looks wrong. We didn't identify the shared cooldown from configuration alone; it took finding upstream issue&amp;nbsp;#4872, which describes the same cross-group delay.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What Cluster Autoscaler actually tracks&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Cluster Autoscaler treats an AWS Auto Scaling Group as a Kubernetes node group and decides both when to grow that group and when nodes can be safely removed from it. Scale-down has two separate waiting mechanisms that are easy to confuse: one governs each node's eligibility, the other protects the cluster after recent scaling actions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-unneeded-time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long a node must remain unneeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-delay-after-add&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long scale-down waits after scale-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-delay-after-delete&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long scale-down waits after deletion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-delay-after-failure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long scale-down waits after failed scale-down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-delay-type-local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether post-action delays are per node group&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;scale-down-unneeded-time&lt;/code&gt;&amp;nbsp;answers "has this node been unnecessary for long enough?" - Cluster Autoscaler checks resource requests, movable pods, scheduling rules, PodDisruptionBudgets, and other safety conditions to decide. The&amp;nbsp;&lt;code&gt;scale-down-delay-after-*&lt;/code&gt;&amp;nbsp;settings answer a completely different question: "is scale-down allowed at all right now, given recent autoscaler activity?" A node can pass its unneeded timer while a cooldown still blocks deletion, and that's exactly what happens here.&amp;nbsp;&lt;strong&gt;Another node group scaling up does not erase the idle node's unneeded history - it adds a separate gate on top of it.&lt;/strong&gt;&amp;nbsp;Before 2024 that gate was always global, and with the default&amp;nbsp;&lt;code&gt;false&lt;/code&gt;&amp;nbsp;it still is. Support for per-group cooldowns arrived through&amp;nbsp;kubernetes/autoscaler#5729, which lets one process remember scaling activity per node group instead of treating every action as cluster-wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Enable per-node-group cooldowns&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Keep one Deployment managing all node groups and set the flag. If you install Cluster Autoscaler with Helm, the change belongs in your values file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;cloudProvider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws&lt;/span&gt;

&lt;span class="na"&gt;autoDiscovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clusterName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production-eks&lt;/span&gt;

&lt;span class="na"&gt;awsRegion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;

&lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scale-down-delay-type-local&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
  &lt;span class="na"&gt;scale-down-delay-after-add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
  &lt;span class="na"&gt;scale-down-unneeded-time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A GPU group scale-up now starts a cooldown for the GPU group only, and an eligible node in the memory group can be removed while it runs. Keep the delay values at their defaults unless you have measurements that justify changing them - new nodes need time to register, start DaemonSets, advertise GPUs, and receive pending pods, and local scope fixes the isolation problem without removing that protection.&amp;nbsp;&lt;strong&gt;Setting&amp;nbsp;&lt;code&gt;scale-down-delay-after-add: 0&lt;/code&gt;&amp;nbsp;is not an equivalent shortcut.&lt;/strong&gt;&amp;nbsp;It does avoid cross-group delay, but it does so by removing the cooldown everywhere, including from the group that just added nodes, which is the one group that actually needs it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Check your binary supports the flag&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The feature merged into the main branch in January 2024 and was backported to the 1.29 release branch that March through&amp;nbsp;#6484.&amp;nbsp;&lt;strong&gt;The original&amp;nbsp;&lt;code&gt;1.29.0&lt;/code&gt;&amp;nbsp;release does not include it, so do not assume every binary labeled 1.29 supports the flag.&lt;/strong&gt;&amp;nbsp;Check the actual image and the rendered command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get deployment cluster-autoscaler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.template.spec.containers[0].image}{"\n"}'&lt;/span&gt;

kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get deployment cluster-autoscaler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .spec.template.spec.containers[0].command[*]}{.}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the logs contain&amp;nbsp;&lt;code&gt;unknown flag: --scale-down-delay-type-local&lt;/code&gt;, the image lacks the feature - upgrade the binary rather than quietly dropping the setting. That upgrade has a constraint of its own: Cluster Autoscaler simulates Kubernetes scheduling, so&amp;nbsp;&lt;strong&gt;its minor version must match your cluster's Kubernetes minor version&lt;/strong&gt;, and cross-version combinations are not supported. AWS repeats this in its&amp;nbsp;EKS guidance. One more trap sits between you and the right binary: Helm chart version and Cluster Autoscaler application version are different numbers. Inspect the chart's&amp;nbsp;&lt;code&gt;appVersion&lt;/code&gt;&amp;nbsp;or pin&amp;nbsp;&lt;code&gt;image.tag&lt;/code&gt;&amp;nbsp;explicitly, so a chart that looks current doesn't deploy a binary that isn't.&lt;/p&gt;

&lt;p&gt;Local cooldowns only isolate delays within a single Cluster Autoscaler process, which makes process ownership the next thing to confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Make sure one autoscaler owns the ASGs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The guarantee is worthless if a second, unmanaged autoscaler also watches some of your ASGs, or if one process never discovers all of them. On AWS, auto-discovery selects ASGs by tags, and the standard Helm values look for two keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;autoDiscovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clusterName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production-eks&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;k8s.io/cluster-autoscaler/enabled&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;k8s.io/cluster-autoscaler/{{ .Values.autoDiscovery.clusterName }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every ASG the autoscaler should own needs both keys attached before discovery picks it up. Tag values are ignored unless you include them in the discovery expression, but values like&amp;nbsp;&lt;code&gt;true&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;owned&lt;/code&gt;&amp;nbsp;make ownership readable and give you something to write IAM conditions against.&lt;/p&gt;

&lt;p&gt;Use separate node groups when capacity or scheduling actually differs, and give each one real labels, real taints, and scale limits that allow it to reach zero. With&amp;nbsp;&lt;code&gt;terraform-aws-modules/eks/aws&lt;/code&gt;&amp;nbsp;(&lt;code&gt;~&amp;gt; 21.0&lt;/code&gt;), the node group definition is the part that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;eks_managed_node_groups&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;instance_types&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"m7i.2xlarge"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="nx"&gt;min_size&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;   &lt;span class="c1"&gt;# allows the group to empty completely&lt;/span&gt;
    &lt;span class="nx"&gt;max_size&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
    &lt;span class="nx"&gt;desired_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s2"&gt;"node-role"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"batch"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;taints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;dedicated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dedicated"&lt;/span&gt;
        &lt;span class="nx"&gt;value&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"batch"&lt;/span&gt;
        &lt;span class="nx"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NO_SCHEDULE"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ASG this creates is the resource your discovery tags must reach, and managed node groups generate ASG names you don't control. Module inputs and outputs also shift between versions, so verify the tags actually landed.&amp;nbsp;&lt;strong&gt;Tags on launch templates, security groups, or EC2 instances do not count&lt;/strong&gt;&amp;nbsp;- auto-discovery reads ASG tags and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws autoscaling describe-auto-scaling-groups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'AutoScalingGroups[].{Name:AutoScalingGroupName,Tags:Tags[?starts_with(Key, `k8s.io/cluster-autoscaler/`)].{Key:Key,Value:Value}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your module doesn't attach the discovery tags, read the generated ASG name and attach them yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;batch_asg_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks_managed_node_groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"batch"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;node_group_resources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;autoscaling_groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;autoscaler_tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"k8s.io/cluster-autoscaler/enabled"&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt;
    &lt;span class="s2"&gt;"k8s.io/cluster-autoscaler/production-eks"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"owned"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_autoscaling_group_tag"&lt;/span&gt; &lt;span class="s2"&gt;"autoscaler"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;autoscaler_tags&lt;/span&gt;

  &lt;span class="nx"&gt;autoscaling_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch_asg_name&lt;/span&gt;

  &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
    &lt;span class="nx"&gt;propagate_at_launch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# this tag is for the ASG itself, not the instances it launches&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Do not combine static&amp;nbsp;&lt;code&gt;--nodes&lt;/code&gt;&amp;nbsp;arguments with auto-discovery.&lt;/strong&gt;&amp;nbsp;Mixing the two makes ownership hard to reason about and will eventually have you debugging the state of a node group the process was never managing. The&amp;nbsp;AWS provider documentation&amp;nbsp;recommends auto-discovery and warns against the combination. Discovery controls what the process can see; IAM controls what it can change. Use IRSA or EKS Pod Identity and constrain&amp;nbsp;&lt;code&gt;SetDesiredCapacity&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;TerminateInstanceInAutoScalingGroup&lt;/code&gt;&amp;nbsp;to ASGs carrying the cluster tags - AWS publishes a&amp;nbsp;tag-conditioned IAM example.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Scale node groups back up from zero&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Scale-to-zero introduces a different failure mode with the same symptom: a pool that sits at the wrong size and won't move. When a node group is empty, Kubernetes has no live Node object advertising that group's labels, taints, or custom resources, and Cluster Autoscaler still has to decide whether a pending pod would fit there. This bites hardest on pools selected by labels, protected by taints, or backed by GPUs.&lt;/p&gt;

&lt;p&gt;Cluster Autoscaler infers CPU, memory, and GPU capacity from AWS configuration, and for EKS managed node groups it can also read labels and taints through&amp;nbsp;&lt;code&gt;eks:DescribeNodegroup&lt;/code&gt;, including at zero nodes. If that covers everything your pod's scheduling rules reference, you may not need explicit node-template tags at all. When it doesn't, put the metadata on the ASG, where it describes the simulated node used for scale-from-zero decisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_autoscaling_group_tag"&lt;/span&gt; &lt;span class="s2"&gt;"batch_label"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;autoscaling_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch_asg_name&lt;/span&gt;

  &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"k8s.io/cluster-autoscaler/node-template/label/node-role"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"batch"&lt;/span&gt;
    &lt;span class="nx"&gt;propagate_at_launch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_autoscaling_group_tag"&lt;/span&gt; &lt;span class="s2"&gt;"batch_taint"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;autoscaling_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch_asg_name&lt;/span&gt;

  &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"k8s.io/cluster-autoscaler/node-template/taint/dedicated"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"batch:NoSchedule"&lt;/span&gt;  &lt;span class="c1"&gt;# value and effect packed into one string&lt;/span&gt;
    &lt;span class="nx"&gt;propagate_at_launch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Node-template tags do not configure real nodes.&lt;/strong&gt;&amp;nbsp;They describe a hypothetical node for the simulator, so the same labels and taints must also exist on the managed node group itself - the&amp;nbsp;&lt;code&gt;labels&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;taints&lt;/code&gt;&amp;nbsp;blocks shown earlier - and the pending pod must request that same contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch&lt;/span&gt;
  &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dedicated&lt;/span&gt;
      &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equal&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch&lt;/span&gt;
      &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example.com/data-job:2026-07-28&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Break that three-way match and you get one of two outcomes, both of which look like a stuck queue. Either the pool never scales up, because Cluster Autoscaler can't tell the pending pod would fit, or it scales up a node that still can't schedule the pod, because the real labels don't match what the pod asked for. Whichever source the metadata comes from, simulated and real must agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Diagnose and verify&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Start by confirming the nodes are genuinely idle from Cluster Autoscaler's perspective. Low observed CPU is not enough - Cluster Autoscaler reasons about pod resource requests and whether pods can move elsewhere, not utilization. Run it at verbosity&amp;nbsp;&lt;code&gt;4&lt;/code&gt;&amp;nbsp;while investigating and follow the Deployment logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system logs deployment/cluster-autoscaler &lt;span class="nt"&gt;--follow&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'unneeded|scale down|scaled up recently|removing'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're looking for evidence that Cluster Autoscaler already considers the node removable, followed by a cooldown message instead of a deletion. Exact wording varies by version, but the pattern holds: one line marks a node unneeded or eligible, a later line skips scale-down because something scaled up recently. Compare those timestamps against ASG scaling activity in the&amp;nbsp;&lt;em&gt;other&lt;/em&gt;&amp;nbsp;group - a scale-up in group B shortly before an eligible node in group A fails to disappear is the signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws autoscaling describe-scaling-activities &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--auto-scaling-group-name&lt;/span&gt; &amp;lt;group-b-asg-name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-items&lt;/span&gt; 20

aws autoscaling describe-auto-scaling-groups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'AutoScalingGroups[].{Name:AutoScalingGroupName,Desired:DesiredCapacity,Min:MinSize,Max:MaxSize}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule out the ordinary blockers before blaming the cooldown. A node legitimately stays when it hosts local storage, a restrictive PodDisruptionBudget, an unreplicated pod, or&amp;nbsp;&lt;code&gt;safe-to-evict: "false"&lt;/code&gt;. Scheduling constraints do the same: if the pods on that node can't fit anywhere else because of selectors, affinity, host ports, or resource requests, Cluster Autoscaler is keeping the node for good reason.&lt;/p&gt;

&lt;p&gt;Once the flag is deployed, confirm that exactly one Deployment manages the intended ASGs and that the rendered command carries the setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get deployments | &lt;span class="nb"&gt;grep &lt;/span&gt;cluster-autoscaler

kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get deployment cluster-autoscaler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .spec.template.spec.containers[0].command[*]}{.}{"\n"}{end}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep &lt;/span&gt;scale-down-delay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run a controlled two-group test. Let a node in group A become unneeded, wait for the logs to show it as a candidate, and create a pod only group B can run - here a batch pool labeled&amp;nbsp;&lt;code&gt;node-role=batch&lt;/code&gt;&amp;nbsp;and tainted&amp;nbsp;&lt;code&gt;dedicated=batch:NoSchedule&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;force-batch-scale-up&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
  &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch&lt;/span&gt;
  &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dedicated&lt;/span&gt;
      &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equal&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch&lt;/span&gt;
      &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pause&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public.ecr.aws/eks-distro/kubernetes/pause:3.10&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Group B's scale-up should no longer postpone the eligible node in group A. Deletion still depends on scan interval, drain time, AWS API latency, and the node staying unneeded throughout, so give it a couple of scan cycles before declaring the test failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Legacy: one autoscaler per ASG&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before per-node-group cooldowns existed, sharding was the practical workaround: run one Cluster Autoscaler per ASG, each process watching a disjoint discovery tag. Each shard then kept its own cooldown state, so a GPU scale-up couldn't delay the process responsible for memory nodes.&lt;/p&gt;

&lt;p&gt;It's heavier than it looks. Every shard needs its own ASG ownership boundary, leader election, service account identity, and IAM scope, and the operational surface grows linearly with node group count. Worse, shards watch the same unschedulable pods without coordinating, so two autoscalers can each decide their ASG can help and add duplicate capacity for a single pending pod. AWS&amp;nbsp;recommends sharding only as a last resort, and the only case that still qualifies is a cluster where the binary matching your Kubernetes minor version predates the feature and the upgrade is blocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What's next&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Run one Cluster Autoscaler with&amp;nbsp;&lt;code&gt;--scale-down-delay-type-local&lt;/code&gt;&amp;nbsp;enabled, keep the default safety delays, and let them apply to the group that earned them. Verify three things afterward: one process owns every ASG you care about, the deployed binary actually accepts the flag, and any group that reaches zero has matching metadata across ASG node-template tags, real node configuration, and pod scheduling rules.&lt;/p&gt;

&lt;p&gt;Cluster Autoscaler's behavior shifts between releases, and flags have been added, renamed, and defaulted differently over time. Check the&amp;nbsp;AWS provider documentation&amp;nbsp;against the source tag matching your image before you ship, then test it with real workloads rather than a single pause pod.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>ai</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Postgres LISTEN/NOTIFY in Production: Safe Reconnects &amp; SSE</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:24:09 +0000</pubDate>
      <link>https://dev.to/u11d/postgres-listennotify-in-production-safe-reconnects-sse-29hd</link>
      <guid>https://dev.to/u11d/postgres-listennotify-in-production-safe-reconnects-sse-29hd</guid>
      <description>&lt;p&gt;If your app needs to push live updates to connected clients — a job progress bar, a balance that changes when a background worker finishes, a "this record was deleted elsewhere" toast — the reflex is to reach for Redis Pub/Sub or a message broker. But if you already run PostgreSQL, it ships with a pub/sub primitive built in: &lt;code&gt;LISTEN&lt;/code&gt; / &lt;code&gt;NOTIFY&lt;/code&gt;. No extra infrastructure, no extra failure domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is Postgres LISTEN/NOTIFY?&lt;/strong&gt; It's a built-in pub/sub mechanism: one session runs &lt;code&gt;pg_notify(channel, payload)&lt;/code&gt; inside a transaction, and any other session that has issued &lt;code&gt;LISTEN &amp;lt;channel&amp;gt;&lt;/code&gt; on a persistent connection receives the payload once that transaction commits. There's no persistence and no delivery guarantee — it's a live signal, not a queue — so it fits best as a "something changed, go re-check" nudge rather than a system of record.&lt;/p&gt;

&lt;p&gt;This post walks through the implementation we actually run in production: a single shared LISTEN connection multiplexing many logical channels, wired into per-client Server-Sent Events (SSE) streams. Along the way: the reconnect logic and the race conditions that come with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Postgres LISTEN/NOTIFY works
&lt;/h2&gt;

&lt;p&gt;Postgres gives you two SQL-level building blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'job_status_123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"status":"done"}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and, on a &lt;strong&gt;persistent&lt;/strong&gt; connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;LISTEN&lt;/span&gt; &lt;span class="n"&gt;job_status_123&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any session that has issued &lt;code&gt;LISTEN&lt;/code&gt; on a channel receives the payload once the transaction that ran &lt;code&gt;pg_notify&lt;/code&gt; commits, not the instant the call executes. Multiple identical notifications on the same channel/payload fired within one transaction get collapsed into one delivery. Several constraints shape everything that follows (see the Postgres NOTIFY reference for the authoritative spec):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payload is capped at 8000 bytes. Fine for status pings, not for shipping large blobs.&lt;/li&gt;
&lt;li&gt;Channel names are Postgres identifiers, truncated at 63 characters. Patterns like &lt;code&gt;job_status_&amp;lt;uuid&amp;gt;&lt;/code&gt; need to fit under that limit or get silently truncated and collide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No persistence, no delivery guarantee.&lt;/strong&gt; If nobody is listening when &lt;code&gt;pg_notify&lt;/code&gt; fires, the message is gone. This is a live signal, not a queue: any client relying on it needs a way to independently reconcile state, e.g. re-fetch from the DB.&lt;/li&gt;
&lt;li&gt;The notification queue itself is bounded (8GB by default, per the Postgres async messaging docs). If a listening session sits idle inside an open transaction, it stops draining the queue for everyone, and &lt;code&gt;pg_notify&lt;/code&gt; starts erroring once the queue fills. Keep LISTEN sessions out of long-running transactions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because &lt;code&gt;LISTEN&lt;/code&gt; requires a session-scoped, long-lived connection, and most apps talk to Postgres through a connection pool, you can't just &lt;code&gt;LISTEN&lt;/code&gt; from your normal query pool. Pooled connections get recycled and your subscription vanishes with them. You need one connection dedicated to listening, held open for the life of the process.&lt;/p&gt;

&lt;p&gt;This also rules out transaction-mode poolers like pgbouncer or RDS Proxy for the LISTEN connection specifically. They can hand your session's underlying socket to a different client between transactions, which silently kills the subscription. The dedicated LISTEN connection needs to bypass the pooler and go straight to Postgres. If you run multiple app instances, each one holds its own LISTEN connection and each receives every NOTIFY independently, which is usually what you want: every instance can push updates to its own locally-connected clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  One connection, many channels
&lt;/h2&gt;

&lt;p&gt;Rather than open a Postgres connection per subscriber, a single physical &lt;code&gt;LISTEN&lt;/code&gt; connection can multiplex an arbitrary number of logical channels. In-process, that's just a map from channel name to a set of callbacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationHub&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;postgres&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;listeners&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MessageCallback&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;unlistenFns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection itself is configured to never idle out and to identify itself for observability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;getAppConnectionConfig&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;idle_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-listen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onclose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* ... */&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pool is deliberately capped at one connection: it exists purely to host a dedicated LISTEN session, not to serve queries. Mixing it into a general-purpose pool would mean losing subscriptions every time the pool recycles a connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling reconnects: capped exponential backoff
&lt;/h2&gt;

&lt;p&gt;A long-lived TCP connection to a database will eventually drop: network blips, database failover, load balancer idle timeouts. The interesting design question isn't "will it disconnect" but "what happens to every registered channel when it does."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;onclose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dbLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LISTEN connection closed unexpectedly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reconnect uses a capped exponential backoff so a flapping database doesn't get hammered with reconnect attempts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_DELAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delayIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectAttempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_DELAYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_DELAYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;delayIndex&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectAttempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reconnectAttempt&lt;/code&gt; resets to 0 on a successful &lt;code&gt;connect()&lt;/code&gt;, so backoff only escalates across consecutive failures. A single flaky reconnect doesn't leave future reconnects artificially delayed.&lt;/p&gt;

&lt;p&gt;On reconnect, every channel that had at least one active subscriber gets re-&lt;code&gt;LISTEN&lt;/code&gt;ed from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failedChannels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listenToChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;failedChannels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failedChannels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the all-or-nothing treatment: if even one channel fails to re-&lt;code&gt;LISTEN&lt;/code&gt;, the whole reconnect is considered failed and retried. A hub that's "half connected," silently missing notifications on some channels, is worse than one that's visibly disconnected and retrying. At least then the failure isn't invisible to callers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unsubscribe race, solved by ordering, not locking
&lt;/h2&gt;

&lt;p&gt;Multiple parts of an app can subscribe to the same channel concurrently (two browser tabs watching the same job, for instance). The tricky moment is when the &lt;em&gt;last&lt;/em&gt; subscriber for a channel unsubscribes at the same time a &lt;em&gt;new&lt;/em&gt; subscriber for that same channel is arriving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unlisten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unlistenFns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// Delete before awaiting so a concurrent subscribe() for this&lt;/span&gt;
      &lt;span class="c1"&gt;// channel calls listenToChannel() again. PostgreSQL silently&lt;/span&gt;
      &lt;span class="c1"&gt;// ignores a duplicate LISTEN on the same connection, so the race&lt;/span&gt;
      &lt;span class="c1"&gt;// is harmless — the hub will have an active LISTEN either way.&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unlistenFns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unlisten&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeoutUnlisten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unlisten&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* timed out or connection gone — safe to ignore */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naive version would &lt;code&gt;await UNLISTEN&lt;/code&gt; first, then clean up bookkeeping. But between "decide to unlisten" and "UNLISTEN finishes," a new subscriber could arrive, see the channel already has a bookkeeping entry, and skip re-&lt;code&gt;LISTEN&lt;/code&gt;ing. That leaves the hub with no active &lt;code&gt;LISTEN&lt;/code&gt; for a channel it thinks it's subscribed to.&lt;/p&gt;

&lt;p&gt;By deleting the map entry &lt;em&gt;before&lt;/em&gt; awaiting &lt;code&gt;UNLISTEN&lt;/code&gt;, a concurrent &lt;code&gt;subscribe()&lt;/code&gt; call always sees "no entry" and issues its own &lt;code&gt;LISTEN&lt;/code&gt; unconditionally. Postgres treats a duplicate &lt;code&gt;LISTEN&lt;/code&gt; on the same session as a no-op, so the worst case is two harmless &lt;code&gt;LISTEN&lt;/code&gt; calls in flight instead of a silently dropped subscription. When you can't easily lock across an async boundary, look for an operation idempotent enough to make the race safe by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callers should never worry about connection state
&lt;/h2&gt;

&lt;p&gt;The public &lt;code&gt;subscribe()&lt;/code&gt; API registers callbacks unconditionally, regardless of whether the underlying Postgres connection is currently up. It only rejects if an opportunistic &lt;code&gt;LISTEN&lt;/code&gt; call fails outright while attempting an already-connected session, and that case is recovered by the reconnect logic on its next attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MessageCallback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Register callbacks regardless of connection state&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listenToChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no-op if already LISTENing&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connecting&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Callbacks are registered in memory first; the actual &lt;code&gt;LISTEN&lt;/code&gt; happens opportunistically if connected, or gets picked up automatically on the next successful reconnect. Every consumer of the hub stays decoupled from the connection's health. They just subscribe, and messages show up when the pipe is open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into Server-Sent Events
&lt;/h2&gt;

&lt;p&gt;The hub by itself is transport-agnostic; a thin SSE layer turns it into per-client streams with keep-alives and cleanup on disconnect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Subscription&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;keepAliveInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`: ping\n\n`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;keepAliveMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscribe&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;isClosed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="nx"&gt;unsubscribeState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onHubStateChange&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;liveUpdates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isClosed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onAfterSubscribe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onAfterSubscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cleanupFn&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth calling out.&lt;/p&gt;

&lt;p&gt;The hub's connection state is surfaced to the client. When &lt;code&gt;onHubStateChange&lt;/code&gt; reports &lt;code&gt;"disconnected"&lt;/code&gt;, an &lt;code&gt;{"type":"info","liveUpdates":false}&lt;/code&gt; frame goes out over SSE so the UI can show "reconnecting..." instead of silently going stale. Since NOTIFY gives no delivery guarantee, telling the client when it might be missing updates is almost as important as delivering the updates themselves.&lt;/p&gt;

&lt;p&gt;There's also a read-then-subscribe race window. &lt;code&gt;onStart&lt;/code&gt; typically does an initial DB read to send current state, and only afterwards does the code call &lt;code&gt;subscribe()&lt;/code&gt; to start receiving live updates. If a &lt;code&gt;NOTIFY&lt;/code&gt; fires in the gap between that read and the &lt;code&gt;LISTEN&lt;/code&gt; actually being established, it's lost for good: the classic "check-then-watch" race in any pub/sub system. The &lt;code&gt;onAfterSubscribe&lt;/code&gt; hook closes that window. After the subscription is confirmed active, it re-checks state one more time, so any update that landed in the gap gets picked up on the second read instead of being silently missed. The client-side handler needs to tolerate receiving the same update twice (once from the catch-up read, once from a live NOTIFY that arrives moments later). Treat updates as idempotent, not as a strictly-once stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this pattern fits
&lt;/h2&gt;

&lt;p&gt;Postgres LISTEN/NOTIFY works well when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Update volume is modest (dashboards, job status, per-user notifications), not a high-throughput event bus.&lt;/li&gt;
&lt;li&gt;Clients treat NOTIFY as a nudge to re-sync, not as the sole source of truth. Always have a path to reconstruct state via a normal query if a notification is missed.&lt;/li&gt;
&lt;li&gt;You want one fewer piece of infrastructure to run and monitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the wrong tool when you need guaranteed delivery, durable queues, replay, or very high fan-out. That's what a real message broker is for. But for "tell connected clients something changed," Postgres has had a message bus built in the whole time.&lt;/p&gt;

</description>
      <category>postgres</category>
    </item>
    <item>
      <title>Keycloak at 100% CPU After Deploy: Root Cause and Fix</title>
      <dc:creator>Bartek Gałęzowski</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:08:30 +0000</pubDate>
      <link>https://dev.to/u11d/keycloak-at-100-cpu-after-deploy-root-cause-and-fix-52dn</link>
      <guid>https://dev.to/u11d/keycloak-at-100-cpu-after-deploy-root-cause-and-fix-52dn</guid>
      <description>&lt;h2&gt;
  
  
  The Symptom: Recognizing This CPU Spike
&lt;/h2&gt;

&lt;p&gt;You deploy a containerized Keycloak instance — even the official &lt;code&gt;quay.io/keycloak/keycloak&lt;/code&gt; image, unmodified — to a managed container platform, and within seconds CPU utilization climbs to 100% and stays there — regardless of traffic. No requests are being served, no realm imports are running, and nothing in the application logs looks wrong. Health checks pass. HTTP endpoints respond normally.&lt;/p&gt;

&lt;p&gt;This pattern shows up most often on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DigitalOcean App Platform&lt;/li&gt;
&lt;li&gt;Google Cloud Run&lt;/li&gt;
&lt;li&gt;AWS ECS Fargate&lt;/li&gt;
&lt;li&gt;Heroku&lt;/li&gt;
&lt;li&gt;Self-managed or managed Kubernetes clusters without a multicast-aware CNI&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Root Cause: Infinispan, JGroups, and the Multicast Problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Default: Distributed Caching via Infinispan
&lt;/h3&gt;

&lt;p&gt;By default, Keycloak ships with a distributed cache mode built on Infinispan, an embedded data grid used to replicate session state, authentication sessions, and other transient data across cluster nodes. This is the &lt;code&gt;ispn&lt;/code&gt; cache provider, and it's the default unless explicitly overridden — either at build time (&lt;code&gt;kc.sh build --cache=&amp;lt;mode&amp;gt;&lt;/code&gt;) or at runtime (&lt;code&gt;KC_CACHE&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Why JGroups Can't Discover Peers
&lt;/h3&gt;

&lt;p&gt;Infinispan's clustering layer relies on JGroups, a toolkit for reliable group communication. JGroups needs a discovery protocol to find other cluster members, and out of the box, Keycloak's default JGroups stack uses UDP-based discovery (&lt;code&gt;PING&lt;/code&gt;/&lt;code&gt;MPING&lt;/code&gt;), which depends on IP multicast to broadcast "who else is out there?" messages across the network.&lt;/p&gt;

&lt;p&gt;Managed container platforms don't expose multicast-capable networking. Their networking layers are software-defined, ephemeral, and built around dynamic scheduling of containers across shared infrastructure — a model that's fundamentally incompatible with IP multicast, which assumes a flat, broadcast-capable network segment. This holds almost universally: PaaS offerings, serverless container runtimes, and even many managed Kubernetes configurations without a multicast-aware CNI all impose the same restriction.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Result: A Silent, Load-Independent CPU Burn
&lt;/h3&gt;

&lt;p&gt;JGroups doesn't fail gracefully when discovery is unreachable. Instead, it enters an aggressive retry loop — broadcasting discovery packets, waiting for a timeout, retrying, re-forming view negotiations, and repeating indefinitely. Each cycle consumes CPU on packet construction, timeout scheduling, and thread contention, multiplied across the JGroups protocol stack (transport, discovery, failure detection, and flow control protocols all layered on top of one another).&lt;/p&gt;

&lt;p&gt;None of this is visible from an application standpoint. A background thread pool is perpetually spinning, trying to solve a problem — finding cluster peers via multicast — that's structurally impossible in this environment. The result is sustained, load-independent CPU burn with no connection to request volume.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;KC_CACHE=local&lt;/code&gt; eliminates the problem — not by fixing discovery, but by disabling clustering altogether. In local mode, Keycloak uses in-memory, non-replicated caches with no Infinispan clustering, no JGroups stack, and therefore no discovery loop to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Happens After Redeploys, Not on First Boot
&lt;/h2&gt;

&lt;h3&gt;
  
  
  First Boot: A Clean, Uncontested Singleton
&lt;/h3&gt;

&lt;p&gt;On the very first deploy, exactly one container exists. When JGroups' discovery protocol fires, it queries whatever peer list mechanism is configured — raw UDP multicast, or more commonly on managed platforms, a DNS-based or static-host discovery protocol that resolves the platform's internal service name to the currently running container IPs. Since this is the first and only container, that resolution returns exactly one address: itself. There's nothing to negotiate and no peer to merge with. JGroups times out once on the discovery request, finds no one else, and installs itself as a singleton cluster view — a cheap, one-shot operation that doesn't manifest as sustained CPU load.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redeploy or Restart: A Two-Node Race That Never Resolves
&lt;/h3&gt;

&lt;p&gt;A redeploy or restart on most managed platforms is a rolling operation, not an instant swap. The platform starts the new container before fully tearing down the old one, to avoid downtime. For a short window, both the outgoing and incoming containers are alive simultaneously, and depending on how peer discovery is configured, both may be resolvable under the same internal service identity.&lt;/p&gt;

&lt;p&gt;This is where things diverge from the first-boot case: JGroups on the new node now believes there is another cluster member to reach. It attempts to establish a channel, negotiate a merged view, and start heartbeat-based failure detection with that peer — but the platform's networking still doesn't permit the actual cache-cluster transport traffic between containers (multicast is blocked, and often the cache port isn't even routed between sibling containers in the first place). The handshake can never complete: the peer is &lt;em&gt;addressable&lt;/em&gt; enough to be discovered, but not &lt;em&gt;reachable&lt;/em&gt; enough to actually communicate.&lt;/p&gt;

&lt;p&gt;The result is a protocol deadlock rather than a clean failure. JGroups cycles through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;periodic heartbeat attempts to the discovered peer&lt;/li&gt;
&lt;li&gt;failure-suspicion timers when heartbeats go unanswered&lt;/li&gt;
&lt;li&gt;merge-coordinator election attempts to decide which node's view should win&lt;/li&gt;
&lt;li&gt;state-transfer request/response handshakes that time out without completing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each sub-protocol retries independently and indefinitely, because there's no terminal "peer confirmed dead, drop it forever" signal — the old container is still technically registered as existing from the discovery layer's point of view, even though it's unreachable at the transport layer. This composite retry storm is considerably more CPU-intensive than the one-shot singleton bootstrap on first boot, which is why the spike reliably appears after a redeploy or restart, but not on the initial one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Fix It: Step-by-Step
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirm you're hitting this issue.&lt;/strong&gt; Check CPU metrics with no active traffic — a sustained 100% with zero requests is the signature. Confirm &lt;code&gt;KC_CACHE&lt;/code&gt; is unset or set to &lt;code&gt;ispn&lt;/code&gt; (the default).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide whether you actually need clustering.&lt;/strong&gt; If you're running a single Keycloak instance, which covers most small and mid-sized deployments, you don't need Infinispan's distributed cache at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single instance → set &lt;code&gt;KC_CACHE=local&lt;/code&gt;.&lt;/strong&gt; Add it as a runtime environment variable, or bake it in at build time with &lt;code&gt;-cache=local&lt;/code&gt;. Redeploy and confirm CPU returns to baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple instances / HA → switch to &lt;code&gt;JDBC_PING&lt;/code&gt;.&lt;/strong&gt; Build with &lt;code&gt;-cache-stack=jdbc-ping&lt;/code&gt; (or supply a custom &lt;code&gt;cache-ispn.xml&lt;/code&gt;), pointing at your existing database. This keeps session replication working without depending on multicast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify after a redeploy, not just first boot.&lt;/strong&gt; Because the issue specifically reproduces on rolling restarts, don't declare victory after the first boot alone — trigger a redeploy and confirm CPU stays flat afterward too.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Trade-Offs of KC_CACHE=local
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;KC_CACHE=local&lt;/code&gt; is a valid and common fix, but it comes with real trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No safe horizontal scaling.&lt;/strong&gt; Run more than one replica behind a load balancer, and users can be routed to a different instance mid-session — silently logged out, losing SSO state, or hitting inconsistent authorization decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restarts are lossy.&lt;/strong&gt; A container restart — which managed platforms perform routinely during deploys, scaling events, or health-check failures — wipes all in-memory session state for that instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a single-instance deployment, none of this is a compromise — there's no cluster to form in the first place, so disabling clustering is simply correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Need High Availability? Use JDBC_PING Instead
&lt;/h2&gt;

&lt;p&gt;If horizontal scaling or high availability is a requirement, the architecturally correct fix isn't to disable clustering — it's to replace the discovery mechanism with one that doesn't depend on multicast.&lt;/p&gt;

&lt;p&gt;Keycloak, via Infinispan/JGroups, supports a &lt;code&gt;JDBC_PING&lt;/code&gt; discovery protocol that uses your existing relational database as a shared coordination point: each node writes its cluster address to a database table, and peers discover each other by querying that table instead of broadcasting UDP packets.&lt;/p&gt;

&lt;p&gt;This is configured via a custom JGroups stack — &lt;code&gt;--cache-stack=jdbc-ping&lt;/code&gt; at build time, or a custom &lt;code&gt;cache-ispn.xml&lt;/code&gt; descriptor — and it works reliably on any managed platform, regardless of vendor, because it never depends on network-layer broadcast at all. The cost is a small, constant amount of additional load on your database (heartbeat writes/reads at a configurable interval) in exchange for genuine session replication across nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  KC_CACHE Settings: Quick Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Recommended Setting&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single Keycloak instance, no plans to scale horizontally&lt;/td&gt;
&lt;td&gt;&lt;code&gt;KC_CACHE=local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No cluster to form — disables Infinispan/JGroups entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple instances, need session replication / HA&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KC_CACHE=ispn&lt;/code&gt; with &lt;code&gt;--cache-stack=jdbc-ping&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Keeps clustering, swaps multicast discovery for database-backed discovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple instances, occasional failover session loss acceptable (rare)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KC_CACHE=ispn&lt;/code&gt; with default stack&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not recommended&lt;/strong&gt; on managed platforms — reproduces the CPU issue&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>keycloak</category>
      <category>infinispan</category>
      <category>jgroups</category>
    </item>
    <item>
      <title>Faster Geospatial Animations with PMTiles: Multi-Layer MVT Time Series in OpenLayers</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Wed, 08 Jul 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/faster-geospatial-animations-with-pmtiles-multi-layer-mvt-time-series-in-openlayers-30d6</link>
      <guid>https://dev.to/u11d/faster-geospatial-animations-with-pmtiles-multi-layer-mvt-time-series-in-openlayers-30d6</guid>
      <description>&lt;p&gt;Most people try to animate time series by stacking separate tile sources or flipping CSS styles mid-frame. That causes flicker and a surprising amount of wasted CPU/GPU work. The core takeaway is simple: bake your timesteps into a single vector-tile archive (PMTiles) with per-timestep named layers, load it once, and drive animation by re-styling already-decoded tiles.&lt;/p&gt;

&lt;p&gt;I tested this with OpenLayers and noticed the difference right away: playback became smoother and far less CPU-bound once tiles were pre-baked and cached. The hard part is server-side — how you generate, package, and serve tiles matters more than which client library you pick.&lt;/p&gt;

&lt;p&gt;When you animate a map by switching between separate tile sources, each frame causes one of two expensive operations: a network fetch of new tiles, or client-side re-decoding and re-styling. Baking timesteps into one archive avoids that by turning each frame into a cheap style re-evaluation on already-decoded features.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture: single archive, dynamic layers
&lt;/h2&gt;

&lt;p&gt;MVT (Mapbox Vector Tile) is a compact protobuf-based vector tile format that encodes geometry and attributes per z/x/y tile. PMTiles packages MVT tiles into a single file for efficient distribution and partial HTTP range retrieval.&lt;/p&gt;

&lt;p&gt;The key workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Server side&lt;/strong&gt; — each timestep is a separate GeoJSON file. &lt;code&gt;tippecanoe&lt;/code&gt; joins them into one PMTiles archive, giving each timestep its own named MVT layer (&lt;code&gt;timestep_00&lt;/code&gt;, &lt;code&gt;timestep_01&lt;/code&gt;, …).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client side&lt;/strong&gt; — one &lt;code&gt;VectorTileSource&lt;/code&gt; loads the archive once. One &lt;code&gt;VectorTileLayer&lt;/code&gt; uses a style function that reads a mutable &lt;code&gt;currentLayerName&lt;/code&gt; variable. Advancing a frame means: update the variable, call &lt;code&gt;layer.changed()&lt;/code&gt;. OpenLayers re-runs the style function on the already-decoded tile features and repaints — no network request, no re-decode.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Complete working example
&lt;/h2&gt;

&lt;p&gt;!animation.gif&lt;/p&gt;

&lt;p&gt;This section contains everything needed to reproduce the demo from scratch: 11 per-timestep GeoJSON files, a tile generation script, and the client HTML.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Per-timestep GeoJSON files
&lt;/h3&gt;

&lt;p&gt;Create 11 files, one per timestep. Each contains a single large polygon with a &lt;code&gt;value&lt;/code&gt; property. The temporal identity comes from the file name (and the MVT layer name tippecanoe will assign), not from a property on the feature.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;0 10&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%02d"&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;i &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"square_t&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.geojson"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
{
  "type": "FeatureCollection",
  "features": [{
    "type": "Feature",
    "geometry": {
      "type": "Polygon",
      "coordinates": [[[-60,-50],[60,-50],[60,50],[-60,50],[-60,-50]]]
    },
    "properties": { "value": &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt; }
  }]
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces &lt;code&gt;square_t00.geojson&lt;/code&gt; (value=0) through &lt;code&gt;square_t10.geojson&lt;/code&gt; (value=100).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Generate PMTiles with named layers (&lt;code&gt;generate-tiles.sh&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_SOURCE&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

tippecanoe &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_00:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t00.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_01:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t01.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_02:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t02.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_03:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t03.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_04:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t04.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_05:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t05.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_06:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t06.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_07:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t07.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_08:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t08.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_09:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t09.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_10:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t10.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square.pmtiles"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-z&lt;/span&gt; 8 &lt;span class="nt"&gt;-Z&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-simplification&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-tile-size-limit&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-feature-limit&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run with: &lt;code&gt;bash generate-tiles.sh&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Start the server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4 — Client animation (&lt;code&gt;index.html&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The full self-contained HTML file. No build step, no npm install — all dependencies come from &lt;code&gt;esm.sh&lt;/code&gt;, which rewrites bare module specifiers (like &lt;code&gt;pbf&lt;/code&gt; imported inside &lt;code&gt;ol/format/MVT&lt;/code&gt;) to absolute CDN URLs that the browser can resolve.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1.0"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;MVT Animation&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;https://cdn.jsdelivr.net/npm/ol@10.9.0/ol.css&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-sizing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;border-box&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;monospace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#eee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
           &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#map&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#hud&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#hud&lt;/span&gt; &lt;span class="nt"&gt;label&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;13px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#aaa&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#hud&lt;/span&gt; &lt;span class="nt"&gt;span&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#timestep-val&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#7af&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#value-val&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fa7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#color-swatch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;28px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;28px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#555&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#progress-bar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#progress-fill&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nb"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#00f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#f00&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#controls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#eee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#555&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
             &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt; &lt;span class="m"&gt;14px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;13px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#444&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#loading&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
               &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
               &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;999&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#loading&lt;/span&gt;&lt;span class="nc"&gt;.hidden&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"map"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"loading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Loading tiles…&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"hud"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&amp;lt;label&amp;gt;&lt;/span&gt;Timestep &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"timestep-val"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&amp;lt;label&amp;gt;&lt;/span&gt;Value &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"value-val"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"display:flex;align-items:center;gap:8px;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;Color &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"color-swatch"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"progress-bar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"progress-fill"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width:0%"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"controls"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"btn-playpause"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Pause&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"btn-reset"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Reset&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;              &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/Map&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;View&lt;/span&gt;             &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/View&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;TileLayer&lt;/span&gt;        &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/layer/Tile&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OSM&lt;/span&gt;              &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/source/OSM&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;VectorTileLayer&lt;/span&gt;  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/layer/VectorTile&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;VectorTileSource&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/source/VectorTile&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;MVT&lt;/span&gt;              &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/format/MVT&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Style&lt;/span&gt;            &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/style/Style&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Fill&lt;/span&gt;             &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/style/Fill&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stroke&lt;/span&gt;           &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/style/Stroke&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fromLonLat&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/proj&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PMTiles&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/pmtiles@4.4.1&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_02&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_03&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_04&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_05&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_06&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_07&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_08&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_09&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VALUES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STYLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Fill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`rgb(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;,0,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stroke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;archive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PMTiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;square.pmtiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VectorTileSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MVT&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;square.pmtiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;maxZoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;squareLayer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VectorTileLayer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;layer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;STYLES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TileLayer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OSM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;squareLayer&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;center&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fromLonLat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="na"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FRAME_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;intervalId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;squareLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;FRAME_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;intervalId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nx"&gt;squareLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
          &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;FRAME_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestepEl&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep-val&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valueEl&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value-val&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;swatchEl&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;color-swatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;progressFill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;progress-fill&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;layerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;layerName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;timestepEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;layerName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;valueEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="nx"&gt;swatchEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;STYLES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getFill&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getColor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;progressFill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loadingEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;loadingEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tileloadend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rendercomplete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;btn-playpause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;playing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Play&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;btn-reset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;playing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;btn-playpause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Running the full setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Install tippecanoe (macOS)&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;tippecanoe

&lt;span class="c"&gt;# 2. Create the 11 per-timestep GeoJSON files&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;0 10&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%02d"&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;i &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-60,-50],[60,-50],[60,50],[-60,50],[-60,-50]]]},"properties":{"value":'&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="s1"&gt;'}}]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; square_t&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.geojson
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# 3. Generate the PMTiles archive&lt;/span&gt;
bash generate-tiles.sh

&lt;span class="c"&gt;# 4. Start the server&lt;/span&gt;
npx serve

&lt;span class="c"&gt;# 5. Open the demo&lt;/span&gt;
open &amp;lt;http://localhost:3000/index.html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Conclusion: paint time, not flip it
&lt;/h2&gt;

&lt;p&gt;Don't stack separate tile sources for time-series animation. Bake timesteps into a single PMTiles archive as named MVT layers, load it once, and drive playback by updating a closure variable and calling &lt;code&gt;layer.changed()&lt;/code&gt;. Serve the archive with &lt;code&gt;npx serve&lt;/code&gt;. Start animation only after the first tile arrives.&lt;/p&gt;

&lt;p&gt;The result: smooth playback from first load, no flicker on hard refresh, no blank map with cache disabled.&lt;/p&gt;

</description>
      <category>gis</category>
      <category>openlayers</category>
      <category>datavisualization</category>
      <category>geospatial</category>
    </item>
    <item>
      <title>Argo CD GitLab Authentication: A Complete GitOps-Friendly Setup</title>
      <dc:creator>Paweł Swiridow</dc:creator>
      <pubDate>Wed, 01 Jul 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/argo-cd-gitlab-authentication-a-complete-gitops-friendly-setup-1ffe</link>
      <guid>https://dev.to/u11d/argo-cd-gitlab-authentication-a-complete-gitops-friendly-setup-1ffe</guid>
      <description>&lt;p&gt;Argo CD can use GitLab as an authentication provider, so users log in with their GitLab accounts instead of local Argo CD users. That gives you centralized identity, cleaner access control, and one less password database waiting to become a security incident with a logo.&lt;/p&gt;

&lt;p&gt;The setup uses OIDC through Dex, stores the GitLab OAuth credentials as encrypted Kubernetes secrets with SOPS and age, and lets Argo CD decrypt them with KSOPS during reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How authentication works&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Argo CD does not talk to GitLab directly for user login. It uses Dex as an identity broker.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User -&amp;gt; Argo CD -&amp;gt; Dex -&amp;gt; GitLab -&amp;gt; Dex -&amp;gt; Argo CD -&amp;gt; RBAC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The user opens Argo CD.&lt;/li&gt;
&lt;li&gt;Argo CD redirects the user to Dex.&lt;/li&gt;
&lt;li&gt;Dex redirects the user to GitLab.&lt;/li&gt;
&lt;li&gt;GitLab authenticates the user and returns OIDC claims.&lt;/li&gt;
&lt;li&gt;Argo CD reads those claims and applies RBAC rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Authentication tells Argo CD who the user is. RBAC tells Argo CD what damage they are allowed to do. Ideally, not much.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Create a GitLab OAuth application&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;First, create an OAuth application in GitLab.&lt;/p&gt;

&lt;p&gt;In GitLab, go to your user, group, or admin application settings and create a new application with these values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Argo CD&lt;/span&gt;
&lt;span class="na"&gt;Redirect URI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://argocd.example.com/api/dex/callback&lt;/span&gt;
&lt;span class="na"&gt;Scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openid, read_user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitLab gives you two important values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;Application ID&lt;/span&gt;
&lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Argo CD Dex config, these become:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dex.gitlab.clientID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;gitlab-application-id&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;dex.gitlab.clientSecret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;gitlab-application-secret&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;dex.gitlab.redirectURI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://argocd.example.com/api/dex/callback"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not commit the secret in plain YAML. That is not GitOps. That is just leaving your keys under the doormat and calling it automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Encrypt the Argo CD secret with SOPS and age&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Use SOPS with age to encrypt the Kubernetes secret before committing it.&lt;/p&gt;

&lt;p&gt;Generate an age key if you do not have one yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;age-keygen &lt;span class="nt"&gt;-o&lt;/span&gt; age.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the public key in&amp;nbsp;&lt;code&gt;.sops.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;creation_rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;encrypted_regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^(data|stringData)$'&lt;/span&gt;
    &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;age1examplepublickeyreplacewithyourrealpublickey&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create&amp;nbsp;&lt;code&gt;argocd-secret.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd-secret&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Opaque&lt;/span&gt;
&lt;span class="na"&gt;stringData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dex.gitlab.clientID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;gitlab-application-id&amp;gt;"&lt;/span&gt;
  &lt;span class="na"&gt;dex.gitlab.clientSecret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;gitlab-application-secret&amp;gt;"&lt;/span&gt;
  &lt;span class="na"&gt;dex.gitlab.redirectURI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://argocd.example.com/api/dex/callback"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Encrypt it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sops &lt;span class="nt"&gt;--encrypt&lt;/span&gt; argocd-secret.yml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; argocd-secret.sops.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then remove the unencrypted file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm &lt;/span&gt;argocd-secret.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, really remove it. Git has a long memory and an even longer blame history.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Add KSOPS to the Argo CD repo server&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Argo CD does not decrypt SOPS files by default. To make this work with Kustomize, install the KSOPS plugin in the Argo CD repo server.&lt;/p&gt;

&lt;p&gt;The plugin lives here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://github.com/viaduct-ai/kustomize-sops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you deploy Argo CD with Helm, add this to your values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repoServer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/kustomize&lt;/span&gt;
      &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/ksops&lt;/span&gt;
      &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ksops&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;initContainers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;install-ksops&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;viaductoss/ksops:v4.5.1&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;[&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/ksops"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;install"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--with-kustomize"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/custom-tools"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
        &lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/custom-tools&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This installs&amp;nbsp;&lt;code&gt;ksops&lt;/code&gt;&amp;nbsp;and a Kustomize binary with plugin support into the repo server container.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Provide the age private key to Argo CD&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The repo server needs the age private key to decrypt the SOPS file.&lt;/p&gt;

&lt;p&gt;Create a Kubernetes secret from your age key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd create secret generic sops-age-key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;age.key&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expose it to the repo server as&amp;nbsp;&lt;code&gt;SOPS_AGE_KEY&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repoServer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SOPS_AGE_KEY&lt;/span&gt;
      &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sops-age-key&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;key&lt;/span&gt;
  &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/kustomize&lt;/span&gt;
      &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/ksops&lt;/span&gt;
      &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ksops&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Protect this key properly. Anyone with the private key can decrypt the secrets. Kubernetes secrets are base64 encoded, not magically protected by tiny YAML elves.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Create the KSOPS generator&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Create&amp;nbsp;&lt;code&gt;secret-generator.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;viaduct.ai/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ksops&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd-secret-generator&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;config.kubernetes.io/function&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;exec:&lt;/span&gt;
        &lt;span class="s"&gt;path: ksops&lt;/span&gt;
&lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;argocd-secret.sops.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Kustomize to call&amp;nbsp;&lt;code&gt;ksops&lt;/code&gt;&amp;nbsp;and decrypt&amp;nbsp;&lt;code&gt;argocd-secret.sops.yml&lt;/code&gt;&amp;nbsp;during the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Reference the generator in Kustomize&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;In&amp;nbsp;&lt;code&gt;kustomization.yml&lt;/code&gt;, add the generator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;

&lt;span class="na"&gt;generators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;secret-generator.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Argo CD renders this application, Kustomize runs the KSOPS generator and produces the decrypted&amp;nbsp;&lt;code&gt;argocd-secret&lt;/code&gt;&amp;nbsp;resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Configure Dex in Argo CD&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Now configure Argo CD to use GitLab through Dex.&lt;/p&gt;

&lt;p&gt;In Helm values, configure&amp;nbsp;&lt;code&gt;argocd-cm&lt;/code&gt;&amp;nbsp;like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kustomize.buildOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--enable-alpha-plugins&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--enable-exec"&lt;/span&gt;
    &lt;span class="na"&gt;dex.config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;connectors:&lt;/span&gt;
        &lt;span class="s"&gt;- type: gitlab&lt;/span&gt;
          &lt;span class="s"&gt;id: gitlab&lt;/span&gt;
          &lt;span class="s"&gt;name: GitLab&lt;/span&gt;
          &lt;span class="s"&gt;config:&lt;/span&gt;
            &lt;span class="s"&gt;baseURL: https://gitlab.example.com&lt;/span&gt;
            &lt;span class="s"&gt;clientID: $dex.gitlab.clientID&lt;/span&gt;
            &lt;span class="s"&gt;clientSecret: $dex.gitlab.clientSecret&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The&amp;nbsp;&lt;code&gt;$dex.gitlab.*&lt;/code&gt;&amp;nbsp;values are loaded from&amp;nbsp;&lt;code&gt;argocd-secret&lt;/code&gt;. That is why the secret names must match exactly.&lt;/p&gt;

&lt;p&gt;The Kustomize build options matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kustomize.buildOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--enable-alpha-plugins&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--enable-exec"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows Kustomize to execute the KSOPS plugin.&lt;/p&gt;

&lt;p&gt;Do not enable exec plugins for repositories you do not trust. Otherwise, you have invented remote command execution with extra YAML. Security teams love that. Briefly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Configure Argo CD RBAC&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Authentication is not authorization. After GitLab login works, configure Argo CD RBAC.&lt;/p&gt;

&lt;p&gt;For simple user mapping by email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;valuesObject&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rbac&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;policy.csv&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;g, alice@example.com, role:admin&lt;/span&gt;
          &lt;span class="s"&gt;g, bob@example.com, role:readonly&lt;/span&gt;
        &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[email,groups]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This maps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;alice@example.com -&amp;gt; role:admin&lt;/span&gt;
&lt;span class="s"&gt;bob@example.com -&amp;gt; role:readonly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For larger teams, prefer GitLab groups instead of individual users. User by user RBAC starts cute and ends as spreadsheet archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Apply the configuration&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If Argo CD is managed by another Argo CD application, commit the files and let Argo CD sync them.&lt;/p&gt;

&lt;p&gt;If you are applying locally for a test, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kustomize build &lt;span class="nt"&gt;--enable-alpha-plugins&lt;/span&gt; &lt;span class="nt"&gt;--enable-exec&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; | kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart the Argo CD components if needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd rollout restart deployment argocd-server
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd rollout restart deployment argocd-dex-server
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd rollout restart deployment argocd-repo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Verify the setup&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Check that the secret exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd get secret argocd-secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the Dex logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd logs deploy/argocd-dex-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the Argo CD server logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd logs deploy/argocd-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the repo server if decryption fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd logs deploy/argocd-repo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open Argo CD in the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://argocd.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see a GitLab login option. After login, the user should land in Argo CD with the expected role.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Troubleshoot common failures&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If GitLab returns a redirect error, check the redirect URI.&lt;/p&gt;

&lt;p&gt;It must match exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://argocd.example.com/api/dex/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If login works but RBAC does not, check which claim Argo CD receives. The email in&amp;nbsp;&lt;code&gt;policy.csv&lt;/code&gt;&amp;nbsp;must match the user claim.&lt;/p&gt;

&lt;p&gt;If the secret does not render, check the repo server pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd describe pod &lt;span class="nt"&gt;-l&lt;/span&gt; app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;argocd-repo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;KSOPS is not installed in the repo server.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-enable-alpha-plugins --enable-exec&lt;/code&gt;&amp;nbsp;is missing.&lt;/li&gt;
&lt;li&gt;The age private key is not available in the repo server environment.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SOPS_AGE_KEY&lt;/code&gt;&amp;nbsp;is missing or points to the wrong Kubernetes secret key.&lt;/li&gt;
&lt;li&gt;The encrypted file name does not match&amp;nbsp;&lt;code&gt;secret-generator.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The secret keys do not match&amp;nbsp;&lt;code&gt;$dex.gitlab.clientID&lt;/code&gt;,&amp;nbsp;&lt;code&gt;$dex.gitlab.clientSecret&lt;/code&gt;, or&amp;nbsp;&lt;code&gt;$dex.gitlab.redirectURI&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can test decryption locally if you have the age key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SOPS_AGE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;age.key&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; argocd-secret.sops.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Security notes&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Keep the boring security parts boring. Boring is good. Boring means nobody is explaining an outage in a meeting with too many directors.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Encrypt the GitLab OAuth secret with SOPS.&lt;/li&gt;
&lt;li&gt;Protect the age private key.&lt;/li&gt;
&lt;li&gt;Limit who can change Argo CD applications that use Kustomize exec plugins.&lt;/li&gt;
&lt;li&gt;Use GitLab groups for larger teams.&lt;/li&gt;
&lt;li&gt;Rotate the GitLab OAuth secret periodically.&lt;/li&gt;
&lt;li&gt;Audit&amp;nbsp;&lt;code&gt;role:admin&lt;/code&gt;&amp;nbsp;assignments.&lt;/li&gt;
&lt;li&gt;Avoid giving admin access to users because they asked nicely in Slack.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Final result&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;With this setup, GitLab becomes the login provider for Argo CD, secrets stay encrypted in Git, and Argo CD RBAC controls what authenticated users can do.&lt;/p&gt;

&lt;p&gt;The moving parts are not complicated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitLab OAuth app
SOPS encrypted argocd-secret
KSOPS in repo server
Dex GitLab connector
Argo CD RBAC policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a reasonable amount of YAML for centralized authentication. Not small, obviously. This is Kubernetes. We measure configuration in geological layers.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitlab</category>
      <category>argocd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Reciprocal Rank Fusion on free Elasticsearch: licensing, workarounds, and the OpenSearch alternative</title>
      <dc:creator>Maciej Łopalewski</dc:creator>
      <pubDate>Wed, 24 Jun 2026 10:59:52 +0000</pubDate>
      <link>https://dev.to/u11d/reciprocal-rank-fusion-on-free-elasticsearch-licensing-workarounds-and-the-opensearch-alternative-56jk</link>
      <guid>https://dev.to/u11d/reciprocal-rank-fusion-on-free-elasticsearch-licensing-workarounds-and-the-opensearch-alternative-56jk</guid>
      <description>&lt;p&gt;&lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt; on Elasticsearch is gated to the Enterprise tier. On the Basic/free tier, querying with the &lt;code&gt;rrf&lt;/code&gt; retriever returns a 403:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"root_cause"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"security_exception"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"current license is non-compliant for [Reciprocal Rank Fusion (RRF)]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"license.expired.feature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Reciprocal Rank Fusion (RRF)"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"security_exception"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"current license is non-compliant for [Reciprocal Rank Fusion (RRF)]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"license.expired.feature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Reciprocal Rank Fusion (RRF)"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This has held since RRF first appeared as a technical preview in 8.8 (May 2023), through the 8.16 GA release (November 2024), and into the 9.x line. The linear retriever added in 8.18 / 9.0 is gated the same way, also at Enterprise. Three practical workarounds exist: implement RRF yourself in application code, combine BM25 and vector results with a normalized linear combination, or move to OpenSearch - which ships RRF, score normalization (including z-score), and late-interaction reranking by a field under Apache 2.0 license.&lt;/p&gt;

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

&lt;p&gt;Reciprocal Rank Fusion combines multiple ranked result sets into a single ranking based on document positions rather than raw scores. The formula is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score(d) = Σ 1 / (k + rank_i(d))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where &lt;code&gt;rank_i(d)&lt;/code&gt; is the document's position in the i-th result list and &lt;code&gt;k&lt;/code&gt; is a smoothing constant. The original paper from Cormack, Clarke, and Büttcher (SIGIR 2009) used &lt;code&gt;k=60&lt;/code&gt; and noted the choice was not critical. Their Table 1 shows MAP barely moves across &lt;code&gt;k ∈ [10, 100]&lt;/code&gt; - only at the extremes (&lt;code&gt;k=0&lt;/code&gt; or &lt;code&gt;k=500&lt;/code&gt;) does the score drop meaningfully. Elastic and OpenSearch both default the RRF rank constant to 60, following the original paper's convention.&lt;/p&gt;

&lt;p&gt;The reason RRF matters for hybrid search: BM25 scores are unbounded and query/corpus-dependent, while vector similarity scores live on a different scale and distribution depending on the similarity function and embedding model. Naively summing them lets one method dominate purely from scale. RRF discards the scores entirely and works only with ranks, which sidesteps the normalization problem with no tuning required.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Basic actually gets you, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;The Elastic subscriptions matrix splits hybrid-search-related features across three paid tiers. Knowing what's behind each wall is more useful than the headline "RRF is paid":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Basic / free&lt;/th&gt;
&lt;th&gt;Platinum&lt;/th&gt;
&lt;th&gt;Enterprise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vector search (kNN)&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard, kNN, pinned, rescorer retrievers&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Similarity functions for vector fields&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Synonym management&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ELSER (learned sparse encoder)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elastic Rerank&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inference API&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF for hybrid search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✓&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Linear, rule, text similarity re-ranker retrievers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✓&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rank Vectors (for MaxSim)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✓&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DiskBBQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✓&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Indexing vectors with GPUs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✓&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query Rules&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning to Rank&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Source: &lt;a href="https://www.elastic.co/subscriptions" rel="noopener noreferrer"&gt;https://www.elastic.co/subscriptions&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pattern is clear. Basic gives you the building blocks for client-side hybrid search - kNN runs, BM25 runs, both can be queried separately. Platinum unlocks Elastic-managed inference and ELSER. Enterprise is where the actual modern hybrid search features live: rank fusion, learned sparse rerankers, late interaction, GPU vector indexing, learning to rank.&lt;/p&gt;

&lt;p&gt;If you're on Basic and you want hybrid search, you're either reimplementing pieces of Enterprise in application code, switching engines, or starting a trial.&lt;/p&gt;

&lt;h2&gt;
  
  
  The licensing timeline
&lt;/h2&gt;

&lt;p&gt;The gating has been continuous and has expanded, not relaxed. The August 2024 license change that re-added AGPLv3 to Elasticsearch made the source code open source again but did not change which features the default ELv2 distribution gates behind paid tiers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;td&gt;August 2022&lt;/td&gt;
&lt;td&gt;First hybrid search support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.8&lt;/td&gt;
&lt;td&gt;May 2023&lt;/td&gt;
&lt;td&gt;RRF added as technical preview, gated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.14&lt;/td&gt;
&lt;td&gt;June 2024&lt;/td&gt;
&lt;td&gt;Retrievers framework introduced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-8.16&lt;/td&gt;
&lt;td&gt;August 2024&lt;/td&gt;
&lt;td&gt;Elastic announced AGPLv3 as an additional source-code license option; feature-tier gating in the default distribution unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;8.16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;November 2024&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;RRF and retrievers reach GA, gated to Enterprise&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;8.18 / 9.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;April 2025&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Linear retriever added, also gated to Enterprise&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9.0.1 Basic&lt;/td&gt;
&lt;td&gt;June 2025&lt;/td&gt;
&lt;td&gt;Linear retriever still throws license error in production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Late 2025&lt;/td&gt;
&lt;td&gt;September 2025&lt;/td&gt;
&lt;td&gt;Per-retriever weights added to RRF retriever (still Enterprise-gated)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Self-managed deployments can start a 30-day trial that gives access to all subscription features, including Enterprise-tier features, for evaluation. Elastic Cloud trials are 14 days. Elastic also publishes a trial extension form that grants one additional 30-day extension on request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workaround 1: Implement RRF in your application
&lt;/h2&gt;

&lt;p&gt;Run BM25 and kNN as two separate queries against Elasticsearch Basic, then fuse the result lists in application code. The fusion logic is roughly seven lines of Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rrf_fusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rankings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Combine multiple ranked lists of document IDs using Reciprocal Rank Fusion.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ranking&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rankings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling it with a BM25 result list and a kNN result list returns the fused ranking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;bm25_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;es&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;match_query&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="n"&gt;knn_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;es&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;knn_query&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;

&lt;span class="n"&gt;fused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rrf_fusion&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;bm25_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knn_ids&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;top_10_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-offs are clear. You pay two round trips instead of one, you lose retrievers-specific features like &lt;code&gt;inner_hits&lt;/code&gt; and the unified pagination model, and you have to rehydrate the document &lt;code&gt;_source&lt;/code&gt; after fusion - typically with an &lt;code&gt;mget&lt;/code&gt; call against the top IDs. In return, you get RRF ranking on the Basic tier with no licensing exposure. In many RAG-style systems embedding generation and kNN latency dominate, but the actual breakdown depends on cache state, candidate window size, and deployment topology - measure for your workload before assuming the extra round trip is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workaround 2: Linear combination with manual normalization
&lt;/h2&gt;

&lt;p&gt;The Basic tier still allows two separate queries fused with a weighted sum, as long as the math happens in your application rather than through the gated &lt;code&gt;linear&lt;/code&gt; retriever. Min-max normalize both score sets per query - using the min and max from each result list as the range - then combine with a weight &lt;code&gt;α&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;minmax_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;linear_fusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bm25_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;knn_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bm25_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bm25_hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bm25_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;minmax_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;knn_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;knn_hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;knn_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;minmax_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you have labeled query data, calibrated linear can beat RRF. Elastic's research on ELSER + BM25 across BEIR reports that around 40 annotated queries are enough for linear combination to start outperforming RRF, and with 300 calibration queries the optimized linear combination achieved a 6% NDCG@10 improvement over ELSER alone - compared to RRF's 1.4% improvement over the same baseline. Without calibration data, RRF is the safer default - it requires no tuning and is far less sensitive to score distribution mismatches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workaround 3: Switch to OpenSearch
&lt;/h2&gt;

&lt;p&gt;OpenSearch ships hybrid search and RRF under Apache 2.0 with no feature-tier licensing. The native &lt;code&gt;hybrid&lt;/code&gt; query and &lt;code&gt;normalization-processor&lt;/code&gt; (min_max, L2) landed in 2.10 (September 25, 2023). Native RRF landed in 2.19 (February 11, 2025). The 3.x line has continued to invest heavily in vector and hybrid search since then.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Hybrid search additions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2.10&lt;/td&gt;
&lt;td&gt;September 25, 2023&lt;/td&gt;
&lt;td&gt;First &lt;code&gt;hybrid&lt;/code&gt; query, &lt;code&gt;normalization-processor&lt;/code&gt; (min_max, L2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.19&lt;/td&gt;
&lt;td&gt;February 11, 2025&lt;/td&gt;
&lt;td&gt;Native RRF (&lt;code&gt;score-ranker-processor&lt;/code&gt;), pagination support, &lt;code&gt;hybrid_score_explanation&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;May 6, 2025&lt;/td&gt;
&lt;td&gt;z-score normalization, lower bound for min-max, inner hits in hybrid, GPU acceleration for vector index builds (experimental)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;td&gt;June 24, 2025&lt;/td&gt;
&lt;td&gt;GPU acceleration for vector index builds GA, hybrid query performance improvements (up to 65% latency reduction)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.3&lt;/td&gt;
&lt;td&gt;October 14, 2025&lt;/td&gt;
&lt;td&gt;Up to 20% faster hybrid for lexical subqueries, &lt;code&gt;lateInteractionScore&lt;/code&gt; for reranking by a field using externally hosted ColBERT/ColPali models&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trend continued after 3.3: OpenSearch 3.4, released in December 2025, added further vector-search investment such as k-NN memory-optimized search warmup, native FP16 vector scoring, and JDK 25 support. OpenSearch 3.5 followed in February 2026, and 3.6.0 - the project's first long-term support release - was published April 7, 2026. Refer to the OpenSearch version history and downloads page for the current state.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;score-ranker-processor&lt;/code&gt; documentation also shows that custom subquery weights are supported via the &lt;code&gt;parameters.weights&lt;/code&gt; array on RRF - useful when you want to weight the BM25 and vector legs differently rather than treat them equally. Elasticsearch added per-retriever weights to its RRF retriever in late 2025 as well, but because the RRF retriever itself is Enterprise-gated, this doesn't change the Basic-tier workaround story.&lt;/p&gt;

&lt;p&gt;The mapping to Elasticsearch's paid tiers is striking. Several broadly comparable capabilities that are Enterprise-only in Elasticsearch's built-in implementation are open-source in OpenSearch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Elasticsearch tier&lt;/th&gt;
&lt;th&gt;OpenSearch tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RRF rank fusion&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Apache 2.0 (since 2.19)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in weighted score fusion&lt;/td&gt;
&lt;td&gt;Enterprise (&lt;code&gt;linear&lt;/code&gt; retriever)&lt;/td&gt;
&lt;td&gt;Apache 2.0 score-based hybrid normalization/combination via search pipelines (since 2.10)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple normalization methods (min-max, L2, z-score)&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Apache 2.0 (z-score in 3.0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Late-interaction reranking by a field (ColBERT/ColPali workflows)&lt;/td&gt;
&lt;td&gt;Enterprise (Rank Vectors)&lt;/td&gt;
&lt;td&gt;Apache 2.0 (&lt;code&gt;lateInteractionScore&lt;/code&gt; in 3.3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU acceleration for vector index builds&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Apache 2.0 (3.0 experimental, 3.1 GA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning to Rank&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Apache 2.0 (LTR plugin)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the project is greenfield and does not depend on Elastic-specific features such as ELSER, Elastic Rerank, ES|QL, or the managed Inference API integrations, OpenSearch is the cleanest path. The migration cost from an existing Elasticsearch deployment is non-trivial - index format compatibility, client library differences, and Kibana versus OpenSearch Dashboards differences all add up - but the functional gap between the free tiers has generally widened for hybrid and vector-search use cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  When paying for Enterprise makes sense
&lt;/h2&gt;

&lt;p&gt;The trial-then-pay path is reasonable when the team needs &lt;code&gt;text_similarity_reranker&lt;/code&gt; for semantic reranking with hosted models, the calibrated linear retriever, MaxSim with rank vectors for ColBERT-style retrieval, GPU vector indexing for billion-scale corpora, learning-to-rank pipelines, or production support contracts. None of this comes for free, and reimplementing it is more expensive than the license for organizations with substantial search infrastructure. For teams whose only blocker is RRF specifically, the workarounds above usually win on cost and are straightforward to implement for simple two-leg hybrid retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  What most teams actually need
&lt;/h2&gt;

&lt;p&gt;Most teams that hit the RRF license error don't actually need RRF specifically - they need hybrid search to work. Manual linear combination with normalized scores is often sufficient for a first production hybrid-search implementation on any Elasticsearch version. The core RRF scoring loop is another twenty lines of code on top of that - production deployments will additionally want pagination, deduplication, an &lt;code&gt;mget&lt;/code&gt; rehydration step, tie-breaking, and observability around the fused ranking. And for projects starting fresh in 2026, the open-source answer keeps getting better with every OpenSearch release.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://u11d.com/blog/reciprocal-rank-fusion-on-free-elasticsearch-licensing-workarounds-and-the-open-search-alternative/" rel="noopener noreferrer"&gt;https://u11d.com&lt;/a&gt; on June 3, 2026.&lt;/p&gt;

</description>
      <category>elasticsearch</category>
      <category>opensearch</category>
      <category>semanticsearch</category>
    </item>
    <item>
      <title>How to Deploy Payload CMS on AWS Amplify with MongoDB Atlas for Free</title>
      <dc:creator>Michał Miler</dc:creator>
      <pubDate>Wed, 17 Jun 2026 07:12:00 +0000</pubDate>
      <link>https://dev.to/u11d/how-to-deploy-payload-cms-on-aws-amplify-with-mongodb-atlas-for-free-5en8</link>
      <guid>https://dev.to/u11d/how-to-deploy-payload-cms-on-aws-amplify-with-mongodb-atlas-for-free-5en8</guid>
      <description>&lt;p&gt;Organizations frequently over-invest in hosting infrastructure for low-traffic content sites. A landing page, blog, or small CMS rarely justifies the cost of a constantly-running server—whether VPS, platform-as-a-service, or managed container services. &lt;strong&gt;Serverless architectures solve this by shifting from per-minute billing to per-request pricing.&lt;/strong&gt; When traffic is very low, your infrastructure costs may approach zero.&lt;/p&gt;

&lt;p&gt;This guide demonstrates how to deploy Payload CMS with a Next.js frontend on AWS Amplify, AWS S3, and MongoDB Atlas in a way that can stay within free-tier thresholds for a small, low-traffic site. We'll walk through the stack rationale, step-by-step provisioning, caching strategy to minimize compute, and security trade-offs you inherit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;This setup combines AWS Amplify Hosting for serverless Next.js SSR, AWS S3 for media storage, MongoDB Atlas for the database, and Payload CMS for content operations. &lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/deploy-nextjs-app.html" rel="noopener noreferrer"&gt;Amplify SSR&lt;/a&gt; runs on AWS Lambda, so compute is billed by request and duration rather than by always-on server time; &lt;a href="https://aws.amazon.com/amplify/pricing/" rel="noopener noreferrer"&gt;Amplify pricing&lt;/a&gt; currently includes 1,000 build minutes, 500,000 SSR requests, 100 GB-hours of SSR duration, 15 GB data transfer out, and 5 GB stored on CDN per month at no cost. S3 keeps uploaded media outside of your app runtime with &lt;a href="https://aws.amazon.com/s3/pricing/" rel="noopener noreferrer"&gt;S3 pricing&lt;/a&gt;, while &lt;a href="https://www.mongodb.com/pricing" rel="noopener noreferrer"&gt;Atlas pricing&lt;/a&gt; offers a free M0 cluster with 512 MB storage for small content sites.&lt;/p&gt;

&lt;p&gt;This model is especially useful when usage is low or uneven: marketing sites with occasional campaigns, content teams publishing periodically, early-stage products validating demand, and internal tools or software services that are active only for short windows, for example one to two hours per day. In these cases, paying for permanent infrastructure often brings little business value.&lt;/p&gt;

&lt;p&gt;If you have sustained high traffic or volatile workloads, benchmark Amplify against Vercel and self-hosted options before committing. Vercel often handles cold starts and p95 latency more predictably for SSR-heavy apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AWS account (sign up for 12 months of &lt;a href="https://aws.amazon.com/free/" rel="noopener noreferrer"&gt;AWS Free Tier&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;GitHub repository with Next.js and Payload CMS (we will use &lt;a href="https://github.com/u11d-com/blog_payloadcms-locales-demo" rel="noopener noreferrer"&gt;demo repo&lt;/a&gt; for this tutorial)&lt;/li&gt;
&lt;li&gt;MongoDB Atlas free account&lt;/li&gt;
&lt;li&gt;Node.js 20+ and npm installed locally&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step A: MongoDB Atlas Free Cluster
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Create Cluster
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Visit &lt;a href="https://www.mongodb.com/cloud/atlas" rel="noopener noreferrer"&gt;Atlas&lt;/a&gt; and log in.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Build a Cluster&lt;/strong&gt; and select &lt;strong&gt;Free&lt;/strong&gt; tier (512 MB storage, shared vCPU, no cost).&lt;/li&gt;
&lt;li&gt;Choose a cloud provider and region matching your Amplify deployment (we'll use &lt;code&gt;us-east-1&lt;/code&gt; in AWS).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unselect&lt;/strong&gt; "Preload sample dataset."&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create Deployment&lt;/strong&gt;. This takes 1–3 minutes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faksv02zearlg6xpv7e2o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faksv02zearlg6xpv7e2o.png" alt=" " width="800" height="823"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Create Database User and Connection String
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6mlq3sxdyv4z1aqvgkdy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6mlq3sxdyv4z1aqvgkdy.png" alt=" " width="799" height="568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When prompted, create a database user. Use generated password or use your own &lt;a href="https://www.mongodb.com/docs/atlas/security/password-requirements/" rel="noopener noreferrer"&gt;password rules&lt;/a&gt;; copy it immediately—you'll need it to construct the connection string.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Choose a connection method&lt;/strong&gt; and select &lt;strong&gt;Drivers&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Copy the connection string (example: &lt;code&gt;mongodb+srv://username:password@cluster.mongodb.net/?appName=payload-cms&lt;/code&gt;). Replace &lt;code&gt;&amp;lt;password&amp;gt;&lt;/code&gt; with your copied password. This string includes the username; the password is separate for security.&lt;/li&gt;
&lt;li&gt;Store this securely—you'll add it to Amplify environment variables later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fysltkdzdxvpjaa536wfn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fysltkdzdxvpjaa536wfn.png" alt=" " width="800" height="558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Configure Network Access
&lt;/h3&gt;

&lt;p&gt;By default, &lt;a href="https://www.mongodb.com/docs/atlas/reference/free-shared-limitations/" rel="noopener noreferrer"&gt;Atlas limits&lt;/a&gt; restrict external connections. For Amplify Lambda functions to reach the database:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Security &amp;gt; Database &amp;amp; Network Access &amp;gt; IP Access List&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add IP Address&lt;/strong&gt; and enter &lt;code&gt;0.0.0.0/0&lt;/code&gt; to allow all IPs.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Confirm&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp1whkby8casbmq7voymm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp1whkby8casbmq7voymm.png" alt=" " width="800" height="637"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security trade-off:&lt;/strong&gt; This opens the database to the internet. Authentication via username/password is your primary defense. For production with higher budget, use &lt;a href="https://www.mongodb.com/docs/atlas/security/atlas-ip-access-list/" rel="noopener noreferrer"&gt;private network&lt;/a&gt; options like AWS PrivateLink or VPC peering.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your MongoDB cluster is ready. &lt;a href="https://www.mongodb.com/docs/atlas/reference/free-shared-limitations/" rel="noopener noreferrer"&gt;Auto-pause&lt;/a&gt; can add a few seconds to the first request after inactivity. In practice, proper Next.js caching usually hides most of this for end users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step B: AWS S3 Bucket and IAM User
&lt;/h2&gt;

&lt;p&gt;Payload CMS stores file uploads (images, documents) in backend storage. &lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/limits-api-gateway.html" rel="noopener noreferrer"&gt;Lambda limits&lt;/a&gt; include payload and response constraints. By uploading directly to S3 and storing S3 URLs in the database, you avoid Lambda serialization bottlenecks. For image optimization, see &lt;a href="https://u11d.com/blog/speed-up-your-next-js-app-optimizing-s3-images-with-cloudflare-images/" rel="noopener noreferrer"&gt;image guide&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create S3 Bucket
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F24dv9mcxpjgqldkaup2c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F24dv9mcxpjgqldkaup2c.png" alt=" " width="800" height="612"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Log into &lt;a href="https://console.aws.amazon.com/" rel="noopener noreferrer"&gt;AWS Console&lt;/a&gt;, select the same region where Amplify will be deployed (e.g., &lt;code&gt;us-east-1&lt;/code&gt;) and navigate to &lt;strong&gt;S3&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create bucket&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enter a globally unique bucket name (e.g., &lt;code&gt;blog-payloadcms-demo-12345&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Uncheck &lt;strong&gt;Block all public access&lt;/strong&gt; so Amplify and browsers can retrieve media files. Public access applies only to objects with public ACLs or bucket policies; Payload uploads are private by default.&lt;/li&gt;
&lt;li&gt;Accept remaining defaults and click &lt;strong&gt;Create bucket&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyacu92rfrmlly72lhuw6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyacu92rfrmlly72lhuw6.png" alt=" " width="800" height="732"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Create IAM User with S3 Permissions
&lt;/h3&gt;

&lt;p&gt;Payload needs AWS credentials to upload and read media objects in your S3 bucket from the running app. Creating a dedicated IAM user gives the app programmatic access without reusing your root account and lets you limit permissions to only the S3 actions your project needs.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Identity and Access Management (IAM)&lt;/strong&gt; &amp;gt; &lt;strong&gt;IAM Users&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create user&lt;/strong&gt; and name it.&lt;/li&gt;
&lt;li&gt;Uncheck &lt;strong&gt;Provide user access to the AWS Management Console&lt;/strong&gt; (API access only).&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Attach policies directly&lt;/strong&gt; and search for &lt;code&gt;AmazonS3FullAccess&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt; and &lt;strong&gt;Create user&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwziwkq5xmyz1lj2v3bp2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwziwkq5xmyz1lj2v3bp2.png" alt=" " width="800" height="645"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For production, follow &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#use-roles-to-delegate-permissions" rel="noopener noreferrer"&gt;least privilege&lt;/a&gt;. Create a custom policy allowing only &lt;code&gt;s3:PutObject&lt;/code&gt;, &lt;code&gt;s3:GetObject&lt;/code&gt;, and &lt;code&gt;s3:DeleteObject&lt;/code&gt; on your specific bucket.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Generate Access Keys
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Select the new IAM user and go to &lt;strong&gt;Security credentials&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create access key&lt;/strong&gt; and choose &lt;strong&gt;Other&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Copy both the &lt;strong&gt;Access Key ID&lt;/strong&gt; and &lt;strong&gt;Secret Access Key&lt;/strong&gt;. You will not see the secret again. Store these securely (AWS Secrets Manager or a password manager). &lt;strong&gt;Never commit credentials to git.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgxqnhxpwk4rnqwrk6yzx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgxqnhxpwk4rnqwrk6yzx.png" alt=" " width="800" height="621"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You now have all configuration data needed for S3 integration: &lt;code&gt;S3_BUCKET&lt;/code&gt;, &lt;code&gt;S3_REGION&lt;/code&gt;, &lt;code&gt;S3_ACCESS_KEY_ID&lt;/code&gt;, and &lt;code&gt;S3_SECRET_ACCESS_KEY&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Payload S3 config
&lt;/h3&gt;

&lt;p&gt;In our demonstration repository we used the following S3 plugin configuration in &lt;code&gt;src/payload.config.ts&lt;/code&gt;. It utilizes environment variables mentioned above and uses S3 hosting for &lt;code&gt;media&lt;/code&gt; collection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;s3Storage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;media&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_ACCESS_KEY_ID&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_SECRET_ACCESS_KEY&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_REGION&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_BUCKET&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_REGION&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`https://s3.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_REGION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.amazonaws.com`&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;forcePathStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_BUCKET&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;disableLocalStorage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this setup works well on Amplify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;endpoint&lt;/code&gt; + &lt;code&gt;forcePathStyle&lt;/code&gt; helps generate direct S3 URLs, which avoids routing large media responses through Lambda.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;disableLocalStorage: true&lt;/code&gt; ensures uploaded media is persisted only in S3, which is important for stateless serverless runtimes.&lt;/li&gt;
&lt;li&gt;Keeping credentials and bucket settings in environment variables makes the same code reusable across local, staging, and production deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your configuration may differ slightly, especially around endpoint strategy, custom domains, or local fallback behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step C: AWS Amplify Deployment
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/welcome.html" rel="noopener noreferrer"&gt;Amplify Hosting&lt;/a&gt; is a Git-based hosting service that watches your repository, runs your build in an isolated environment, deploys generated artifacts to the CDN and compute layer, and provisions HTTPS on an &lt;code&gt;*.amplifyapp.com&lt;/code&gt; domain.&lt;/p&gt;

&lt;p&gt;You can configure deployment fully in the Amplify UI, but keeping the build definition in &lt;code&gt;amplify.yml&lt;/code&gt; is usually safer for teams: it is versioned, reviewable in pull requests, reproducible across environments, and avoids silent UI drift. This is a lightweight Infrastructure as Code (IaC) approach: the deployment behavior is declared in code, not only clicked in a console. For background, see &lt;a href="https://aws.amazon.com/what-is/iac/" rel="noopener noreferrer"&gt;IaC guide&lt;/a&gt;. Build configuration lives in the repository as &lt;code&gt;amplify.yml&lt;/code&gt;. This ensures reproducible builds across your team.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configure Build Settings
&lt;/h3&gt;

&lt;p&gt;Before creating the Amplify app, make sure &lt;code&gt;amplify.yml&lt;/code&gt; is committed to the repository root. The file below is &lt;strong&gt;our&lt;/strong&gt; configuration. Yours may differ, especially in package manager commands (&lt;code&gt;npm&lt;/code&gt;, &lt;code&gt;pnpm&lt;/code&gt;, &lt;code&gt;yarn&lt;/code&gt;) and which environment variables must be exported into &lt;code&gt;.env.production&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Use this as a reference and adapt only what your app actually requires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;phases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;preBuild&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "Installing dependencies..."&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# &amp;lt;https://docs.aws.amazon.com/amplify/latest/userguide/ssr-environment-variables.html#:~:text=Amplify%20Hosting%20supports%20adding%20environment,in%20the%20build%20commands%20section&amp;gt;.&lt;/span&gt;
      &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "Building the application..."&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;rm -f .env.production&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;export NEXT_PUBLIC_SERVER_URL="${NEXT_PUBLIC_SERVER_URL:-&amp;lt;https://$&amp;gt;{AWS_BRANCH}.${AWS_APP_ID}.amplifyapp.com}"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "Using NEXT_PUBLIC_SERVER_URL=$NEXT_PUBLIC_SERVER_URL"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;env | grep -e '^NEXT_PUBLIC_' &amp;gt;&amp;gt; .env.production || &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;env | grep -e '^DATABASE_URL=' -e '^PAYLOAD_SECRET=' -e '^CRON_SECRET=' -e '^PREVIEW_SECRET=' -e '^S3_BUCKET=' -e '^S3_REGION=' -e '^S3_ACCESS_KEY_ID=' -e '^S3_SECRET_ACCESS_KEY=' &amp;gt;&amp;gt; .env.production || &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
    &lt;span class="na"&gt;postBuild&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "Post build complete"&lt;/span&gt;
  &lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;baseDirectory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.next&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**/*'&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.next/cache/**/*&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node_modules/**/*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key pattern is documented in &lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/ssr-environment-variables.html" rel="noopener noreferrer"&gt;env docs&lt;/a&gt;: expose only required variables during build, then write them to &lt;code&gt;.env.production&lt;/code&gt;. Keep package manager commands and env exports aligned with your own repository.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connect Repository to Amplify
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://console.aws.amazon.com/amplify/" rel="noopener noreferrer"&gt;Amplify Console&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Make sure you are in the same region where S3 bucket and Mongo Cluster were created&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create new app&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Select your Git provider (&lt;strong&gt;GitHub&lt;/strong&gt; in our case) and authorize Amplify to access your repository.&lt;/li&gt;
&lt;li&gt;Choose your repository and the branch to deploy (typically &lt;code&gt;main&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Add Environment Variables
&lt;/h3&gt;

&lt;p&gt;During app creation, add environment variables in step 3: &lt;strong&gt;App settings -&amp;gt; Advanced settings&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Expand &lt;strong&gt;Advanced settings&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Add the same variables shown in the screenshot (retrieved from previous steps).&lt;/li&gt;
&lt;li&gt;Continue deployment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9iwk5bfzsxilkmx765ie.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9iwk5bfzsxilkmx765ie.png" alt=" " width="800" height="618"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploy
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt; and &lt;strong&gt;Save and deploy&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Amplify now builds and deploys your app. First builds take 3–10 minutes.&lt;/li&gt;
&lt;li&gt;Once complete, you will see your application URL (e.g., &lt;code&gt;https://myapp-12345.amplifyapp.com&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F60xavzf3nqq8zmo26e0h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F60xavzf3nqq8zmo26e0h.png" alt=" " width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify Admin Access
&lt;/h3&gt;

&lt;p&gt;Navigate to your admin panel (e.g. &lt;code&gt;https://myapp-12345.amplifyapp.com/admin&lt;/code&gt;). You should see the Payload login page. Use &lt;a href="https://payloadcms.com/docs/authentication/user-management" rel="noopener noreferrer"&gt;user docs&lt;/a&gt; to create your first admin account.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8druyrqp21boprccsp68.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8druyrqp21boprccsp68.png" alt=" " width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix Media CORS (One-Time Setup)
&lt;/h3&gt;

&lt;p&gt;If images or media files fail to load with CORS errors, your S3 bucket needs CORS configuration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;S3 &amp;gt; Buckets&lt;/strong&gt; and select your bucket.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Permissions&lt;/strong&gt; and scroll to &lt;strong&gt;Cross-origin resource sharing (CORS)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Edit&lt;/strong&gt; and paste:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AllowedHeaders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AllowedMethods"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUT"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AllowedOrigins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;https://myapp-12345.amplifyapp.com&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ExposeHeaders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ETag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"x-amz-request-id"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"MaxAgeSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;myapp-12345.amplifyapp.com&lt;/code&gt; with your actual Amplify domain. Click &lt;strong&gt;Save&lt;/strong&gt;. Media should now load without errors.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhunkjzgfkygl9nkmmhun.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhunkjzgfkygl9nkmmhun.png" alt=" " width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This CORS configuration allows your Amplify domain to read media from S3; it does not expose your bucket publicly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching and Static Generation in Next.js
&lt;/h2&gt;

&lt;p&gt;Next.js caching directly impacts Amplify compute costs. See &lt;a href="https://nextjs.org/docs/app/getting-started/caching-and-revalidating" rel="noopener noreferrer"&gt;caching docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Static Site Generation (SSG)
&lt;/h3&gt;

&lt;p&gt;For content that changes infrequently (blog posts, landing pages), use Static Site Generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/posts/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="c1"&gt;// Revalidate once per day (ISR)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PostPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SERVER_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/posts?where[slug][equals]=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://nextjs.org/docs/app/api-reference/functions/revalidatePath" rel="noopener noreferrer"&gt;ISR docs&lt;/a&gt; explain how pages are pre-cached and revalidated in the background.&lt;/p&gt;

&lt;h3&gt;
  
  
  On-Demand Revalidation
&lt;/h3&gt;

&lt;p&gt;When an editor publishes a new post, trigger revalidation immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// `src/hooks/revalidateFrontend.ts` (Payload hook)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;revalidateFrontend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SERVER_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="c1"&gt;// Tell Next.js to invalidate the blog archive page&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts-archive&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://nextjs.org/docs/app/api-reference/functions/revalidateTag" rel="noopener noreferrer"&gt;revalidateTag docs&lt;/a&gt; cover tag invalidation across cached responses. This pattern helps keep content fresh without full rebuilds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache Strategy Summary
&lt;/h3&gt;

&lt;p&gt;For static pages such as landing pages and blog posts, SSG with a 24-hour ISR window minimizes compute and usually results in only periodic revalidation calls. Dynamic surfaces like search or personalized dashboards are still rendered per request, so they consume more SSR runtime. API routes can remain lightweight and use HTTP caching behavior where applicable. For low traffic, this mix typically stays inside Amplify free-tier limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing Notes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  AWS Amplify
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/amplify/pricing/" rel="noopener noreferrer"&gt;Amplify pricing&lt;/a&gt; is usage-based:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build minutes: 1,000/month included, then $0.01 per minute&lt;/li&gt;
&lt;li&gt;SSR request count: 500,000/month included, then $0.30 per 1 million requests&lt;/li&gt;
&lt;li&gt;SSR request duration: 100 GB-hours/month included, then $0.20 per GB-hour&lt;/li&gt;
&lt;li&gt;Data transfer out: 15 GB/month included, then $0.15 per GB&lt;/li&gt;
&lt;li&gt;CDN storage: 5 GB/month included, then $0.023 per GB-month&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  AWS S3
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/s3/pricing/" rel="noopener noreferrer"&gt;S3 pricing&lt;/a&gt; is also usage-based. For a small media library, storage and request costs are usually low, but the exact amount depends on object size, request volume, storage class, and data transfer.&lt;/p&gt;

&lt;h3&gt;
  
  
  MongoDB Atlas
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.mongodb.com/pricing/" rel="noopener noreferrer"&gt;Atlas pricing&lt;/a&gt; includes the free M0 cluster: 512 MB storage, shared RAM, shared vCPU, free forever. &lt;a href="https://www.mongodb.com/docs/atlas/reference/free-shared-limitations/" rel="noopener noreferrer"&gt;Atlas limits&lt;/a&gt; also note that auto-pause can slow the first request after inactivity.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Reconsider
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;High traffic or tail-latency sensitivity:&lt;/strong&gt; benchmark Amplify against Vercel and self-hosted options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unpredictable spikes:&lt;/strong&gt; verify how your cache hit rate and SSR load affect request count and duration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance or data residency:&lt;/strong&gt; ensure your MongoDB and Amplify regions match your requirements. See &lt;a href="https://aws.amazon.com/about-aws/global-infrastructure/" rel="noopener noreferrer"&gt;AWS regions&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Security Trade-Offs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. MongoDB IP Allowlist: Public Access
&lt;/h3&gt;

&lt;p&gt;Configuring &lt;code&gt;0.0.0.0/0&lt;/code&gt; means any IP can attempt to connect to your MongoDB cluster.&lt;/p&gt;

&lt;p&gt;Use long random credentials, limit the Mongo user to the minimum database scope, and avoid admin privileges for application access. For stronger isolation in production, use &lt;a href="https://www.mongodb.com/docs/atlas/security/ip-access-list/" rel="noopener noreferrer"&gt;network controls&lt;/a&gt; instead of public IP allowlists.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. S3 Public Access: Bucket Policy and CORS
&lt;/h3&gt;

&lt;p&gt;S3 blocks public access by default. Unchecking this allows you to create a public bucket policy.&lt;/p&gt;

&lt;p&gt;Keep policies narrow: allow only public read (&lt;code&gt;s3:GetObject&lt;/code&gt;) where needed and never expose write permissions publicly. If files are sensitive, prefer signed URLs with expiration instead of open object access; see &lt;a href="https://payloadcms.com/docs/storage/overview" rel="noopener noreferrer"&gt;storage docs&lt;/a&gt;. It also helps to separate public uploads from private objects by prefix and policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Secrets in Environment Variables
&lt;/h3&gt;

&lt;p&gt;Amplify stores secrets in managed service configuration rather than in your repository. These values are encrypted at rest and should never be printed in build logs.&lt;/p&gt;

&lt;p&gt;Keep IAM permissions minimal for deployment roles, avoid logging any secret values in build output, and consider &lt;a href="https://aws.amazon.com/secrets-manager/" rel="noopener noreferrer"&gt;Secrets Manager&lt;/a&gt; if you need managed rotation and stronger operational controls.&lt;/p&gt;

&lt;p&gt;For a detailed security review, see &lt;a href="https://u11d.com/blog/payload-cms-security-guide-2026-threats-prevention/" rel="noopener noreferrer"&gt;security guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: Custom Domain Setup
&lt;/h2&gt;

&lt;p&gt;Once your site is live on &lt;code&gt;myapp-12345.amplifyapp.com&lt;/code&gt;, you can add a custom domain (e.g., &lt;code&gt;blog.example.com&lt;/code&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In Amplify Console:&lt;/strong&gt; Go to &lt;strong&gt;Domain management&lt;/strong&gt; and click &lt;strong&gt;Add domain&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Point Route 53 DNS:&lt;/strong&gt; If your domain is in Route 53 (AWS), Amplify auto-creates records. Otherwise, add your registrar's CNAME records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSL/TLS:&lt;/strong&gt; Amplify generates a free certificate via AWS Certificate Manager. HTTPS is automatic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs:&lt;/strong&gt; Domain registration (~$10–15/year) + Route 53 hosting ($0.50/month) if applicable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For detailed steps, see &lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/to-add-a-custom-domain-managed-by-amazon-route-53.html" rel="noopener noreferrer"&gt;domain guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "502 Bad Gateway" or "413 Payload Too Large"
&lt;/h3&gt;

&lt;p&gt;If media uploads fail, the issue is likely the 6 MB Lambda response limit. Ensure &lt;code&gt;S3_ENDPOINT&lt;/code&gt; is configured in your Payload config to use direct S3 URLs, bypassing the Lambda gateway. See &lt;a href="https://www.notion.so/src/payload.config.ts" rel="noopener noreferrer"&gt;config file&lt;/a&gt; for the current setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Database Connection Timeout"
&lt;/h3&gt;

&lt;p&gt;Check that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;MongoDB Atlas IP allowlist includes &lt;code&gt;0.0.0.0/0&lt;/code&gt; or your Amplify IP.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DATABASE_URL&lt;/code&gt; is correct and includes the password.&lt;/li&gt;
&lt;li&gt;Database user exists and has correct credentials.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Media Not Loading (CORS Error)
&lt;/h3&gt;

&lt;p&gt;Ensure your S3 bucket CORS policy includes your Amplify domain (see &lt;a href="https://www.notion.so/Payload-CMS-free-deployment-Amplify-S3-and-MongoDB-step-by-step-tutorial-356bb6feb31880089ab2f3155d93368e?pvs=21" rel="noopener noreferrer"&gt;CORS step&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Logs and Debugging
&lt;/h3&gt;

&lt;p&gt;Use &lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/monitoring-logs.html" rel="noopener noreferrer"&gt;logs docs&lt;/a&gt; to inspect runtime errors, environment variable issues, and database connectivity problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary and Conclusions
&lt;/h2&gt;

&lt;p&gt;This architecture gives you a practical way to run a Payload CMS website with low baseline cost: Amplify for SSR compute, S3 for media, and MongoDB Atlas for managed data. For low-traffic landing pages and blogs, it can stay within free-tier thresholds when caching is configured correctly and media is served directly from S3.&lt;/p&gt;

&lt;p&gt;The key trade-off is operational behavior under low traffic: you may see cold starts and first-request latency, so monitor logs, keep SSR scope minimal, and add warming only when needed. As traffic, compliance, or latency requirements increase, benchmark against Vercel and self-hosted options before scaling the same setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next Steps
&lt;/h2&gt;

&lt;p&gt;Now that your site is deployed, consider:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Optimize images:&lt;/strong&gt; See &lt;a href="https://u11d.com/blog/speed-up-your-next-js-app-optimizing-s3-images-with-cloudflare-images/" rel="noopener noreferrer"&gt;image guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduce cold starts:&lt;/strong&gt; If you notice slow first loads after inactivity, use &lt;a href="https://www.notion.so/blog/amplify-cold-start-warming-playbook.md" rel="noopener noreferrer"&gt;warming guide&lt;/a&gt;. Vercel usually hides this problem better, especially for p95 latency, so benchmark both if first-response consistency matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor costs:&lt;/strong&gt; Set up &lt;a href="https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/budgets-create.html" rel="noopener noreferrer"&gt;AWS Budgets&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale content:&lt;/strong&gt; As you grow, plan for MongoDB upgrade to a paid cluster if storage exceeds 512 MB.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/welcome.html" rel="noopener noreferrer"&gt;Amplify docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/amplify/pricing/" rel="noopener noreferrer"&gt;Amplify pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/ssr-environment-variables.html" rel="noopener noreferrer"&gt;Amplify env&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/deploy-nextjs-app.html" rel="noopener noreferrer"&gt;Amplify Next.js&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/s3/pricing/" rel="noopener noreferrer"&gt;S3 pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/free/" rel="noopener noreferrer"&gt;AWS free tier&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/lambda/pricing/" rel="noopener noreferrer"&gt;Lambda pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.mongodb.com/pricing" rel="noopener noreferrer"&gt;Atlas pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.mongodb.com/docs/atlas/reference/free-shared-limitations/" rel="noopener noreferrer"&gt;Atlas limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/getting-started/caching-and-revalidating" rel="noopener noreferrer"&gt;Next.js caching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/api-reference/functions/revalidateTag" rel="noopener noreferrer"&gt;revalidateTag&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/api-reference/functions/revalidatePath" rel="noopener noreferrer"&gt;revalidatePath&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payloadcms.com/docs/production/deployment" rel="noopener noreferrer"&gt;Payload deploy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payloadcms.com/docs/configuration/environment-vars" rel="noopener noreferrer"&gt;Payload env&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html" rel="noopener noreferrer"&gt;IAM practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/to-add-a-custom-domain-managed-by-amazon-route-53.html" rel="noopener noreferrer"&gt;Domain setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amplify/latest/userguide/monitoring-logs.html" rel="noopener noreferrer"&gt;Amplify logs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See Also
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://u11d.com/blog/payload-cms-security-guide-2026-threats-prevention/" rel="noopener noreferrer"&gt;Security guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://u11d.com/blog/speed-up-your-next-js-app-optimizing-s3-images-with-cloudflare-images/" rel="noopener noreferrer"&gt;Image guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://u11d.com/blog/nextjs-16-caching-ecommerce-storefronts/" rel="noopener noreferrer"&gt;Caching guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://u11d.com/blog/nextjs-16-ppr-static-dynamic-components/" rel="noopener noreferrer"&gt;PPR guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://u11d.com/blog/ssg-isr-ssr-csr-which-strategy-should-i-use-in-my-next-js-e-commerce-platform/" rel="noopener noreferrer"&gt;Render modes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://u11d.com/blog/vercel-vs-aws-amplify-pricing-nextjs/" rel="noopener noreferrer"&gt;Pricing compare&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://u11d.com/blog/deploy-nextjs-16-ssg-aws-amplify/" rel="noopener noreferrer"&gt;Amplify SSG&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://u11d.com/blog/payload-cms-localization-default-locale-hints-arrays/" rel="noopener noreferrer"&gt;Locales guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>payload</category>
      <category>aws</category>
      <category>amplify</category>
      <category>devops</category>
    </item>
    <item>
      <title>Dynamic Email Domain Validation in Keycloak with a Custom Authenticator</title>
      <dc:creator>Bartek Gałęzowski</dc:creator>
      <pubDate>Wed, 10 Jun 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/dynamic-email-domain-validation-in-keycloak-with-a-custom-authenticator-2il3</link>
      <guid>https://dev.to/u11d/dynamic-email-domain-validation-in-keycloak-with-a-custom-authenticator-2il3</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Keycloak ships with a built-in mechanism for restricting user registration by email domain — but it's static. Changing the allow-list means touching realm configuration and redeploying. For B2B SaaS products that onboard new tenants regularly, that's an operational bottleneck you don't want.&lt;/p&gt;

&lt;p&gt;The right solution is to move domain policy out of Keycloak entirely and delegate it to a backend service that can be updated at runtime. This article walks through building a custom Keycloak Authenticator — called &lt;code&gt;domain-email-validator&lt;/code&gt; — that does exactly that: at login time, it calls an external API to decide whether the user's email domain is permitted.&lt;/p&gt;

&lt;p&gt;By the end, you'll understand the full architecture, the Java implementation, how to wire it into both browser and IDP flows, and the operational tradeoffs involved.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Static Domain Restrictions Fall Short in B2B Products
&lt;/h2&gt;

&lt;p&gt;Keycloak's native domain restriction works well for single-tenant deployments with a fixed list of approved domains. But in multi-tenant environments, you typically need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant domain rules&lt;/strong&gt; — Tenant A allows &lt;code&gt;acme.com&lt;/code&gt;; Tenant B allows &lt;code&gt;globex.com&lt;/code&gt; and &lt;code&gt;initech.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime updates&lt;/strong&gt; — A new customer signs a contract and needs access today, not after the next deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency across login methods&lt;/strong&gt; — The same rule should apply whether a user logs in via username/password or via Google SSO.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A custom Authenticator SPI solves all three. It's evaluated on every login attempt, it reads policy from an external source, and it plugs into both browser and post-broker flows with the same implementation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The approach is a thin decision layer inside Keycloak. The plugin does not own domain policy — it only enforces it.&lt;/p&gt;

&lt;p&gt;The contract is intentionally minimal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keycloak sends &lt;code&gt;{ domain, realmId }&lt;/code&gt; to a backend endpoint.&lt;/li&gt;
&lt;li&gt;Backend returns &lt;code&gt;200&lt;/code&gt; to allow or any non-&lt;code&gt;200&lt;/code&gt; to deny.&lt;/li&gt;
&lt;li&gt;Keycloak continues or blocks the flow accordingly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps all business logic — tenant configuration, domain lists, auditing — in your backend, where it belongs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Request Lifecycle
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Keycloak executes earlier steps (credential check or broker handshake).&lt;/li&gt;
&lt;li&gt;The domain validator step runs.&lt;/li&gt;
&lt;li&gt;It reads the user's email from the active context.&lt;/li&gt;
&lt;li&gt;It sends a small HTTP POST to the policy service.&lt;/li&gt;
&lt;li&gt;The backend returns allow or deny.&lt;/li&gt;
&lt;li&gt;Keycloak continues the flow or shows a denial message.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the decision is made per login attempt, domain policy changes take effect immediately — no cache warmup, no redeployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;p&gt;The authenticator is split into two standard Keycloak SPI parts: a &lt;strong&gt;factory&lt;/strong&gt; that registers the provider and exposes configuration fields, and an &lt;strong&gt;executor&lt;/strong&gt; that runs the validation logic at login time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Factory: Registering the Provider
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;DomainValidatorAuthenticatorFactory&lt;/code&gt; defines two configuration properties visible in the Keycloak Admin UI under the flow step's &lt;strong&gt;Config&lt;/strong&gt; tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DomainValidatorAuthenticator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIG_VALIDATION_URL&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Domain Validation URL"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProviderConfigProperty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STRING_TYPE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://my-server/api/keycloak/domain-check"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DomainValidatorAuthenticator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIG_SHARED_SECRET&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Shared Secret"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProviderConfigProperty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PASSWORD&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no hidden YAML or server-side config file. Everything is per-flow-execution and editable through the UI, which makes it easy to configure different endpoints per realm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Executor: The Core Validation Logic
&lt;/h3&gt;

&lt;p&gt;The central method is &lt;code&gt;authenticate(AuthenticationFlowContext context)&lt;/code&gt;. It first resolves the email from context — either from a submitted form or from a brokered identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolveBrokeredEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;flowType&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Post-broker flow: email came from the IDP identity token&lt;/span&gt;
    &lt;span class="n"&gt;flowType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"idp"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Standard browser flow: read from the submitted form&lt;/span&gt;
    &lt;span class="nc"&gt;MultivaluedMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;formData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHttpRequest&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getDecodedFormParameters&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;formData&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFirst&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"username"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;flowType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"form"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// If no email or no @ sign, pass through — Keycloak handles invalid credentials&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it reads the flow config and calls the policy service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAuthenticatorConfig&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getConfig&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;validationUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CONFIG_VALIDATION_URL&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sharedSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CONFIG_SHARED_SECRET&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;HttpPost&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpPost&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validationUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StringEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{\"domain\":\"%s\",\"realmId\":\"%s\"}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;escapeJson&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="n"&gt;escapeJson&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;realmId&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt;
    &lt;span class="nc"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON&lt;/span&gt;
&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sharedSecret&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;sharedSecret&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sharedSecret&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, it interprets the response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CloseableHttpResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatusLine&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getStatusCode&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IOException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;errorf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"DomainValidatorAuthenticator: HTTP call failed for domain '%s'"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
                    &lt;span class="s"&gt;" in realm '%s'"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;realmId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;failWithError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"domainValidationUnavailable"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;infof&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DomainValidatorAuthenticator: domain '%s' denied in realm '%s'"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
                &lt;span class="s"&gt;" (HTTP %d)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;realmId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;failWithError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"domainNotAllowed"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any &lt;code&gt;IOException&lt;/code&gt; — network failure, timeout, connection refused — results in &lt;code&gt;domainValidationUnavailable&lt;/code&gt;. Missing config results in &lt;code&gt;domainValidatorMisconfigured&lt;/code&gt;. The validator is &lt;strong&gt;fail-closed&lt;/strong&gt;: when in doubt, it denies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resolving the Brokered Email
&lt;/h3&gt;

&lt;p&gt;For post-broker flows (e.g. after Google SSO), the email comes from the already-resolved user object, not the form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;resolveBrokeredEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuthenticationFlowContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;UserModel&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;userEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEmail&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userEmail&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;userEmail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;userEmail&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the same authenticator class handles both input sources. The only difference is where the email comes from — the rest of the flow is identical.&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker Build
&lt;/h2&gt;

&lt;p&gt;The Dockerfile wires the Maven build and the Keycloak image together cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;quay.io/keycloak/keycloak:26.6.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base-keycloak&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;maven:3.9-eclipse-temurin-21&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;maven-builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /build&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; keycloak-domain-validator/pom.xml ./pom.xml&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;mvn dependency:resolve &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; keycloak-domain-validator/src ./src&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;mvn package &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-DskipTests&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base-keycloak&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;keycloak-builder&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=maven-builder /build/target/domain-validator.jar /opt/keycloak/providers/&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;/opt/keycloak/bin/kc.sh build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; base-keycloak&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=keycloak-builder /opt/keycloak/ /opt/keycloak/&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/opt/keycloak/bin/kc.sh"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The multi-stage build keeps the final image lean: Maven is only present during the build stage, and the compiled JAR is dropped directly into Keycloak's providers directory.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuring Authentication Flows
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Browser Login Flow (Username/Password)
&lt;/h3&gt;

&lt;p&gt;Go to &lt;strong&gt;Authentication → Flows.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fms9ymvs2j60sxtlnt5av.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fms9ymvs2j60sxtlnt5av.png" alt=" " width="800" height="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Duplicate the built-in &lt;code&gt;browser&lt;/code&gt; flow&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4t91m8woavcxs6fc0tch.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4t91m8woavcxs6fc0tch.png" alt=" " width="552" height="241"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Inside the &lt;strong&gt;Forms&lt;/strong&gt; sub-flow, add the &lt;strong&gt;Domain Email Validator&lt;/strong&gt; step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4nmwu05319upeldj41.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fen4nmwu05319upeldj41.png" alt=" " width="799" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Place it directly after &lt;code&gt;Username Password Form&lt;/code&gt; , and set its requirement to &lt;code&gt;Required&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvzteg3lk3i6bixwvm6p2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvzteg3lk3i6bixwvm6p2.png" alt=" " width="800" height="140"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Config&lt;/strong&gt; on the step and enter your validation URL and shared secret.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhw52ufbjyi8mnaxjbdlc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhw52ufbjyi8mnaxjbdlc.png" alt=" " width="547" height="608"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bind the duplicated flow as the active browser flow under &lt;strong&gt;Bindings&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnvp5aivuhfwukrn0kkz1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnvp5aivuhfwukrn0kkz1.png" alt=" " width="548" height="177"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The validator reads the email from the submitted &lt;code&gt;username&lt;/code&gt; field in the login form.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Google / IDP Login Flow (Post-Broker)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create a new dedicated flow for post-broker login.&lt;/li&gt;
&lt;li&gt;Add &lt;strong&gt;Domain Email Validator&lt;/strong&gt; as a &lt;code&gt;Required&lt;/code&gt; step.&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Identity Providers → Google → Post Login Flow&lt;/strong&gt; and assign the new flow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The validator reads the email from the resolved user identity returned by the IDP.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keep the validation endpoint fast and internal.&lt;/strong&gt; Since this check is synchronous and blocks login, endpoint latency directly affects user experience. Route it over internal networking and keep the response payload small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitor the endpoint as authentication-critical infrastructure.&lt;/strong&gt; Set up latency alerts and error rate tracking. A degraded policy service means users can't log in — treat it accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotate the shared secret regularly.&lt;/strong&gt; The bearer token is your only authorization layer between Keycloak and the policy service. Automate rotation through your secrets management tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always set the step as &lt;code&gt;Required&lt;/code&gt;.&lt;/strong&gt; An &lt;code&gt;Alternative&lt;/code&gt; or &lt;code&gt;Disabled&lt;/code&gt; requirement silently bypasses the check, which defeats the purpose entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ensure Google OAuth includes the &lt;code&gt;email&lt;/code&gt; scope.&lt;/strong&gt; Without it, the IDP flow won't have an email to validate, and the validator will pass through silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the API contract minimal and stable.&lt;/strong&gt; The &lt;code&gt;{ domain, realmId }&lt;/code&gt; payload and the HTTP status response are a clean, versioned interface. Resist the temptation to add fields over time unless there's a clear reason.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Posture
&lt;/h2&gt;

&lt;p&gt;The validator is intentionally conservative by design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It derives the domain from the server-side authentication context, not from client-supplied input.&lt;/li&gt;
&lt;li&gt;It supports a bearer secret for service-to-service authorization.&lt;/li&gt;
&lt;li&gt;It denies access when validation cannot be completed for any reason — misconfiguration, network failure, or unexpected response codes all result in denial.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words: authentication proceeds only when policy can be positively verified.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs and Alternatives
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Flexibility&lt;/th&gt;
&lt;th&gt;Operational cost&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Keycloak built-in domain restriction&lt;/td&gt;
&lt;td&gt;Low (static)&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Authenticator + external API&lt;/td&gt;
&lt;td&gt;High (dynamic)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keycloak scripting (deprecated)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom User Storage SPI&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The custom Authenticator approach hits the right balance for most B2B products: it's dynamic, tenant-aware, independently deployable, and doesn't require deep Keycloak internals knowledge to maintain.&lt;/p&gt;

&lt;p&gt;The main tradeoff is availability coupling — if your policy service goes down, so does login. Mitigate this with high-availability deployment, circuit breakers at the infrastructure layer, and solid monitoring.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: What happens if the policy service is unreachable during a login attempt?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The authenticator catches the &lt;code&gt;IOException&lt;/code&gt; and fails with &lt;code&gt;domainValidationUnavailable&lt;/code&gt;. Login is denied. This is the intentional fail-closed behavior — an unreachable policy service is treated as a denial rather than a bypass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can this authenticator be used across multiple realms with different endpoints?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The validation URL and shared secret are configured per flow execution, not globally. Each realm can have its own duplicated flow pointing to a different endpoint, or the same endpoint can handle per-realm routing using the &lt;code&gt;realmId&lt;/code&gt; field in the request body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does this affect performance at login time?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It adds one synchronous HTTP call per login attempt. In practice, if the policy service is on the same internal network and kept lightweight, the added latency is negligible — typically single-digit milliseconds. Treat the endpoint as latency-sensitive infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What error message does the user see when denied?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The authenticator calls &lt;code&gt;failWithError(context, "domainNotAllowed")&lt;/code&gt;, which maps to a message key in Keycloak's theme messages file. You can customize the displayed text by overriding that key in your realm's login theme.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;For multi-tenant B2B products, static email domain restrictions in Keycloak simply don't scale. A custom Authenticator SPI that delegates to an external policy service is a clean, maintainable pattern that keeps domain management out of Keycloak configuration and in the hands of your backend team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The plugin is a thin decision layer — it enforces policy but doesn't own it.&lt;/li&gt;
&lt;li&gt;The same authenticator class handles both browser and IDP (post-broker) login paths.&lt;/li&gt;
&lt;li&gt;The API contract is minimal: &lt;code&gt;{ domain, realmId }&lt;/code&gt; in, HTTP status out.&lt;/li&gt;
&lt;li&gt;The validator is fail-closed — network errors and misconfigurations result in denial, not bypass.&lt;/li&gt;
&lt;li&gt;Configuration lives in the Keycloak Admin UI, scoped per flow execution, making it easy to manage per realm.&lt;/li&gt;
&lt;li&gt;Treat the policy endpoint as authentication-critical infrastructure: monitor latency, alert on errors, and keep it highly available.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>keycloak</category>
      <category>security</category>
    </item>
    <item>
      <title>Next.js 16 Caching for E-Commerce: Cache Components, use cache, revalidateTag, and Fresh Product Data</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Tue, 02 Jun 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/nextjs-16-caching-for-e-commerce-cache-components-use-cache-revalidatetag-and-fresh-product-4ik1</link>
      <guid>https://dev.to/u11d/nextjs-16-caching-for-e-commerce-cache-components-use-cache-revalidatetag-and-fresh-product-4ik1</guid>
      <description>&lt;p&gt;Caching in e-commerce is never just about speed. A fast storefront is useful only if it still shows the right price, the correct stock level, and the right experience for the current customer.&lt;/p&gt;

&lt;p&gt;That is why caching in a Next.js storefront can be deceptively hard. Some data should be shared broadly and kept warm for SEO and performance. Some data should be refreshed often. Some should never be shared between users at all.&lt;/p&gt;

&lt;p&gt;Next.js 16 gives teams a much clearer toolbox for solving this problem with Cache Components, &lt;code&gt;use cache&lt;/code&gt;, tag-based invalidation, and explicit cache lifetime controls. Used properly, these features let you keep pages fast without drifting into stale commerce data.&lt;/p&gt;

&lt;p&gt;In this guide, I will walk through a practical way to think about caching in a modern storefront and show how to combine &lt;code&gt;use cache&lt;/code&gt;, &lt;code&gt;cacheLife&lt;/code&gt;, and &lt;code&gt;revalidateTag&lt;/code&gt; for real e-commerce use cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Caching Is Harder in E-Commerce Than in a Typical Content Site
&lt;/h2&gt;

&lt;p&gt;On a standard marketing site, most content changes infrequently. If a page is cached for a few minutes or even a few hours, the business impact is usually negligible.&lt;/p&gt;

&lt;p&gt;Commerce systems work differently.&lt;/p&gt;

&lt;p&gt;The same product page may contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stable product descriptions and category copy&lt;/li&gt;
&lt;li&gt;semi-dynamic data such as price, availability, shipping estimates, or promotion labels&lt;/li&gt;
&lt;li&gt;private data such as cart state, recently viewed items, or customer-specific pricing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treating all of that data the same way leads to one of two bad outcomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You cache too aggressively and serve stale prices or availability.&lt;/li&gt;
&lt;li&gt;You disable caching everywhere and lose the performance benefits that help SEO and conversion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The better approach is to split your data by volatility and audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Cache Boundaries That Matter Most
&lt;/h2&gt;

&lt;p&gt;For most commerce projects, the cleanest mental model is to divide data into three groups.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Stable catalog content
&lt;/h3&gt;

&lt;p&gt;This is the part of the page that usually changes only when content editors or merchandisers update the catalog.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;product title and description&lt;/li&gt;
&lt;li&gt;brand information&lt;/li&gt;
&lt;li&gt;CMS blocks, FAQs, and long-form copy&lt;/li&gt;
&lt;li&gt;category landing page copy&lt;/li&gt;
&lt;li&gt;SEO metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the best candidate for shared caching because it improves performance for everyone and is usually safe to invalidate on demand when content changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Shared but fast-changing commerce data
&lt;/h3&gt;

&lt;p&gt;This data is still shared between many users, but it changes more often and has stronger operational impact.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;current price for a product and region&lt;/li&gt;
&lt;li&gt;inventory status&lt;/li&gt;
&lt;li&gt;campaign badges&lt;/li&gt;
&lt;li&gt;delivery promises&lt;/li&gt;
&lt;li&gt;low-stock signals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This layer often benefits from a shorter cache lifetime and explicit invalidation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. User-specific or session-specific data
&lt;/h3&gt;

&lt;p&gt;This is the data that should not be globally cached.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cart contents&lt;/li&gt;
&lt;li&gt;logged-in customer state&lt;/li&gt;
&lt;li&gt;B2B contract pricing&lt;/li&gt;
&lt;li&gt;personalized recommendations&lt;/li&gt;
&lt;li&gt;account-specific discounts&lt;/li&gt;
&lt;li&gt;checkout calculations tied to the active session&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where teams most often make expensive mistakes. If the output depends on the current user, you need to keep that boundary private.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Next.js 16 Changes in Practice
&lt;/h2&gt;

&lt;p&gt;The major advantage of Next.js 16 is not just that it caches. It is that caching becomes more explicit.&lt;/p&gt;

&lt;p&gt;Instead of treating an entire route as fully static or fully dynamic, you can choose more precise boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cache a component or function with &lt;code&gt;use cache&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;assign cache tags to data and invalidate them later&lt;/li&gt;
&lt;li&gt;control lifetime with &lt;code&gt;cacheLife&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;keep per-user data private where needed&lt;/li&gt;
&lt;li&gt;stream dynamic parts separately instead of forcing the entire page into request-time rendering&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For e-commerce storefronts, that means you can serve a fast shell and still keep critical parts fresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Storefront Strategy
&lt;/h2&gt;

&lt;p&gt;Let us look at a product page. In a typical headless stack, product content might come from Medusa or another commerce backend, while editorial content may come from a CMS.&lt;/p&gt;

&lt;p&gt;The important point is not the backend itself. The important point is how you cache each layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache the stable product content
&lt;/h3&gt;

&lt;p&gt;If product descriptions and media do not change every minute, cache them explicitly and tag them so they can be invalidated when merchandisers update the catalog.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COMMERCE_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/products/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to load product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a good fit for data that should be broadly reusable and revalidated only when a product actually changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use a shorter cache boundary for price and stock
&lt;/h3&gt;

&lt;p&gt;Price and stock need more care. In some stores, they can still be shared safely for a short period. In others, especially B2B or highly promotional environments, they may need user-level or request-level handling.&lt;/p&gt;

&lt;p&gt;For shared price or inventory data, a short-lived cache can be a sensible compromise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getOffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`offer:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COMMERCE_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/offers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?region=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to load offer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the offer can be shared, but only briefly. That is often enough to reduce backend pressure while still keeping the storefront operationally safe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep customer-specific data out of the shared cache
&lt;/h3&gt;

&lt;p&gt;If pricing, recommendations, or entitlements depend on the active customer, do not treat them as generic shared data.&lt;/p&gt;

&lt;p&gt;This applies especially to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;B2B price lists&lt;/li&gt;
&lt;li&gt;customer group discounts&lt;/li&gt;
&lt;li&gt;partner-only catalog visibility&lt;/li&gt;
&lt;li&gt;personalized recommendation widgets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that point, a private boundary or request-time fetching becomes more appropriate than a shared cache.&lt;/p&gt;

&lt;p&gt;The principle is simple: if another customer must never see the same output, that data should not live in a shared cache.&lt;/p&gt;

&lt;p&gt;If you do want to cache user-specific data briefly to avoid repeating the same work during a session, keep that cache private instead of shared.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheLife&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getRecommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache: private&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECOMMENDATION_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/products/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/recommendations?customer=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to load recommendations&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps the result bounded to the current user context instead of letting it leak into global storefront output. It is also worth noting that&amp;nbsp;&lt;code&gt;'use cache: private'&lt;/code&gt;&amp;nbsp;does not store its cache entries on the server. The function still runs during server rendering, but the cached result is kept only in the browser’s memory and does not survive a full page reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters for Medusa and Other Headless Commerce Backends
&lt;/h2&gt;

&lt;p&gt;This pattern becomes especially valuable in headless commerce because the storefront is often responsible for combining multiple systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;commerce backend&lt;/li&gt;
&lt;li&gt;CMS&lt;/li&gt;
&lt;li&gt;search service&lt;/li&gt;
&lt;li&gt;recommendation engine&lt;/li&gt;
&lt;li&gt;analytics and experiment layers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If all of those systems are fetched at request time for every page view, the frontend becomes slower and more fragile than it needs to be.&lt;/p&gt;

&lt;p&gt;If all of them are aggressively cached together, the storefront becomes fast but operationally unsafe.&lt;/p&gt;

&lt;p&gt;The right answer is to cache by business meaning, not by convenience.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CMS content can usually be cached until editors publish changes.&lt;/li&gt;
&lt;li&gt;catalog copy can usually be cached and invalidated via tags.&lt;/li&gt;
&lt;li&gt;regional shared pricing can often be cached briefly.&lt;/li&gt;
&lt;li&gt;logged-in B2B pricing should stay private.&lt;/li&gt;
&lt;li&gt;cart and checkout state should stay request-scoped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the difference between a caching strategy that helps the business and one that only looks good in benchmarks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using &lt;code&gt;revalidateTag&lt;/code&gt; to Keep Data Fresh
&lt;/h2&gt;

&lt;p&gt;One of the most useful patterns in Next.js 16 is tag-based invalidation.&lt;/p&gt;

&lt;p&gt;Instead of revalidating whole pages blindly, you can invalidate the exact data domain that changed. That is particularly useful when updates come from webhooks triggered by your CMS, PIM, or commerce backend.&lt;/p&gt;

&lt;p&gt;For example, if a product is updated in Medusa or an editorial team changes CMS content linked to that product, you can invalidate the relevant tag immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`offer:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, this lets you keep product content cached most of the time while still reacting quickly to actual changes.&lt;/p&gt;

&lt;p&gt;That is a much better fit for commerce than short global revalidation windows everywhere.&lt;/p&gt;

&lt;p&gt;One operational note matters here: never expose a public revalidation endpoint without request validation. If invalidation is triggered by a CMS, Medusa, or ERP webhook, verify the source with a shared secret or signature check before calling &lt;code&gt;revalidateTag&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Teams Usually Get It Wrong
&lt;/h2&gt;

&lt;p&gt;There are a few caching mistakes that repeatedly appear in storefront projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 1: Caching personalized pricing globally
&lt;/h3&gt;

&lt;p&gt;This is the most dangerous one. If the result depends on customer identity, customer group, contract terms, or session data, do not let it leak into a shared cache boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 2: Making the whole page dynamic because one widget is dynamic
&lt;/h3&gt;

&lt;p&gt;A product page does not need to become fully request-time just because the cart badge or recommendation box is personalized.&lt;/p&gt;

&lt;p&gt;Keep the stable content cached and isolate the dynamic parts instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 3: Using &lt;code&gt;no-store&lt;/code&gt; too broadly
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;no-store&lt;/code&gt; is sometimes necessary, but if it becomes the default reaction to uncertainty, you throw away much of the value of the App Router architecture.&lt;/p&gt;

&lt;p&gt;Use it for truly request-bound data, not as a shortcut for unclear cache design.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 4: Forgetting business dimensions in cache keys
&lt;/h3&gt;

&lt;p&gt;Prices are rarely just “product price.” They are often tied to region, currency, channel, campaign, or customer group.&lt;/p&gt;

&lt;p&gt;If those dimensions are not reflected in the data boundary, the cache becomes unsafe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 5: Mixing editorial freshness with operational freshness
&lt;/h3&gt;

&lt;p&gt;A product description may be safe to cache until a content change occurs. Inventory and promotions may not be. These two layers should not be forced into the same lifetime.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Good Default Model for Product Pages
&lt;/h2&gt;

&lt;p&gt;If you need a practical starting point, this model works well for many storefronts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data type&lt;/th&gt;
&lt;th&gt;Suggested approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Product description, SEO copy, editorial blocks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;use cache&lt;/code&gt; with tags and on-demand invalidation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared regional price and stock&lt;/td&gt;
&lt;td&gt;short cache lifetime plus tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recommendations&lt;/td&gt;
&lt;td&gt;private or isolated dynamic boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cart state&lt;/td&gt;
&lt;td&gt;request-time or session-specific logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout totals and payment readiness&lt;/td&gt;
&lt;td&gt;request-time only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B2B contract pricing&lt;/td&gt;
&lt;td&gt;private boundary, never generic shared output&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is not a framework rule. It is a business-safe baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Works Well With Streaming and PPR Thinking
&lt;/h2&gt;

&lt;p&gt;If you have already explored Partial Prerendering in Next.js 16, this caching model should feel familiar.&lt;/p&gt;

&lt;p&gt;The idea is the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep the stable shell fast&lt;/li&gt;
&lt;li&gt;isolate dynamic or high-volatility parts&lt;/li&gt;
&lt;li&gt;avoid blocking the entire page for data that does not deserve that privilege&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For commerce, that means a product page can still load quickly for users and search engines while the truly dynamic fragments remain fresh.&lt;/p&gt;

&lt;p&gt;That is a much more scalable model than forcing every route into either “fully static” or “fully dynamic.”&lt;/p&gt;

&lt;h2&gt;
  
  
  A Note on Webhooks and Operational Ownership
&lt;/h2&gt;

&lt;p&gt;Caching only works well if invalidation is treated as part of the architecture, not as an afterthought.&lt;/p&gt;

&lt;p&gt;If your team updates product data in Medusa, content in a CMS, or prices in an ERP, your storefront should know how those changes trigger cache invalidation.&lt;/p&gt;

&lt;p&gt;Typical patterns include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Medusa webhooks invalidating product and offer tags&lt;/li&gt;
&lt;li&gt;CMS publish events invalidating page or content tags&lt;/li&gt;
&lt;li&gt;ERP sync jobs invalidating pricing-related tags&lt;/li&gt;
&lt;li&gt;stock update events invalidating inventory-related tags only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where many storefronts move from “fast in development” to “reliable in production.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The real value of caching in Next.js 16 is not that it makes everything static. It is that it helps you decide what should be shared, what should be refreshed, and what should remain private.&lt;/p&gt;

&lt;p&gt;For e-commerce teams, that distinction matters more than raw speed numbers. The fastest storefront is not the one that caches the most. It is the one that caches the right things.&lt;/p&gt;

&lt;p&gt;If you separate stable catalog content, shared operational data, and user-specific state into different cache boundaries, you can keep your storefront fast without risking stale or incorrect business data.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Kubernetes HPA Scale to Zero Without KEDA: Native Autoscaling for Idle Workloads</title>
      <dc:creator>Daniel Kraszewski</dc:creator>
      <pubDate>Wed, 27 May 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/kubernetes-hpa-scale-to-zero-without-keda-native-autoscaling-for-idle-workloads-4b00</link>
      <guid>https://dev.to/u11d/kubernetes-hpa-scale-to-zero-without-keda-native-autoscaling-for-idle-workloads-4b00</guid>
      <description>&lt;p&gt;If you run queue processors, batch workers, or event-driven workloads that sit idle for hours between bursts, you're paying for compute you don't need. Kubernetes HPA can scale these deployments to zero replicas — no KEDA, no Knative, no external controllers required. You need one feature gate, an external metrics source, and about twenty minutes of setup. When the queue is empty your pods disappear, and if you pair this with cluster autoscaler, the nodes disappear too. Real scale-to-zero, using nothing but native Kubernetes primitives.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Quick Reference&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Feature gate&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HPAScaleToZero=true&lt;/code&gt;&amp;nbsp;(available since Kubernetes 1.16)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum replicas&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;minReplicas: 0&lt;/code&gt;&amp;nbsp;in the HPA spec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metrics source&lt;/td&gt;
&lt;td&gt;Must use external or custom metrics (not CPU/memory)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale-to-zero trigger&lt;/td&gt;
&lt;td&gt;Metric value drops to zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale-from-zero trigger&lt;/td&gt;
&lt;td&gt;Metric value rises above zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why not CPU/memory?&lt;/td&gt;
&lt;td&gt;No pods means no resource metrics to observe — the HPA controller needs a signal that exists independently of the pods&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Local Setup with kind&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Since&amp;nbsp;&lt;code&gt;HPAScaleToZero&lt;/code&gt;&amp;nbsp;requires an explicit feature gate, we need a cluster that has it enabled on both the API server and the controller manager. kind makes this straightforward — especially on Kubernetes 1.36, which at the time of writing is too new for most managed providers.&lt;/p&gt;

&lt;p&gt;If you haven't already built the node image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kind build node-image &lt;span class="nt"&gt;--type&lt;/span&gt; release v1.36.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a cluster config that enables the feature gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kind-config.yaml&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind.x-k8s.io/v1alpha4&lt;/span&gt;
&lt;span class="na"&gt;featureGates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;HPAScaleToZero&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;control-plane&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; hpa-scale-to-zero &lt;span class="nt"&gt;--config&lt;/span&gt; kind-config.yaml &lt;span class="nt"&gt;--image&lt;/span&gt; kindest/node:v1.36.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The&amp;nbsp;&lt;code&gt;featureGates&lt;/code&gt;&amp;nbsp;field at the cluster level propagates the flag to all relevant components (kube-apiserver, kube-controller-manager, kubelet). No need to manually patch component configs.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Metrics Pipeline&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The HPA controller can't scale from zero based on CPU or memory — there are no pods to measure. You need a metric that exists outside the workload itself. For queue-based workloads, the natural choice is the queue length: how many items are waiting to be processed.&lt;/p&gt;

&lt;p&gt;We'll use Redis as the queue (specifically a Redis list), expose its length via a redis-exporter sidecar, scrape it with Prometheus, and surface it to the Kubernetes metrics API through prometheus-adapter. This is the same pipeline pattern you'd use in production — the only difference is we're running everything in a single kind cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Deploy Redis with an Exporter&lt;/strong&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add bitnami https://charts.bitnami.com/bitnami
helm &lt;span class="nb"&gt;install &lt;/span&gt;redis bitnami/redis &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;architecture&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;standalone &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; auth.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; metrics.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; metrics.extraArgs.check-keys&lt;span class="o"&gt;=&lt;/span&gt;work-queue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The&amp;nbsp;&lt;code&gt;metrics.enabled=true&lt;/code&gt;&amp;nbsp;deploys a redis-exporter sidecar alongside Redis. The&amp;nbsp;&lt;code&gt;--check-keys work-queue&lt;/code&gt;&amp;nbsp;argument tells the exporter to emit&amp;nbsp;&lt;code&gt;redis_key_size{key="work-queue"}&lt;/code&gt;&amp;nbsp;— the length of our Redis list. That's the metric we'll use to drive the HPA. The chart also sets up the correct Prometheus scrape annotations automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Deploy Prometheus and the Adapter&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The prometheus-community Helm charts get us running in two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add prometheus-community https://prometheus-community.github.io/helm-charts

helm &lt;span class="nb"&gt;install &lt;/span&gt;prometheus prometheus-community/prometheus &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; server.service.type&lt;span class="o"&gt;=&lt;/span&gt;ClusterIP &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; alertmanager.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; kube-state-metrics.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; prometheus-node-exporter.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; prometheus-pushgateway.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives us a minimal Prometheus that auto-discovers pods annotated with&amp;nbsp;&lt;code&gt;prometheus.io/scrape: "true"&lt;/code&gt;&amp;nbsp;— which the Bitnami Redis chart already configures.&lt;/p&gt;

&lt;p&gt;Now deploy the prometheus-adapter with a custom external metrics rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;prometheus-adapter prometheus-community/prometheus-adapter &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; prometheus.url&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://prometheus-server"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; prometheus.port&lt;span class="o"&gt;=&lt;/span&gt;80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set-json&lt;/span&gt; &lt;span class="s1"&gt;'rules.external=[{"seriesQuery":"{__name__=\"redis_key_size\",key=\"work-queue\"}","metricsQuery":"sum(&amp;lt;&amp;lt;.Series&amp;gt;&amp;gt;{&amp;lt;&amp;lt;.LabelMatchers&amp;gt;&amp;gt;})","name":{"as":"redis_queue_length"},"resources":{"namespaced":false}}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rule tells the adapter: take the&amp;nbsp;&lt;code&gt;redis_key_size&lt;/code&gt;&amp;nbsp;metric where&amp;nbsp;&lt;code&gt;key="work-queue"&lt;/code&gt;, expose it as an external metric called&amp;nbsp;&lt;code&gt;redis_queue_length&lt;/code&gt;, and don't filter by namespace (since the queue is a cluster-wide resource — there's only one Redis).&lt;/p&gt;

&lt;p&gt;After a minute or so, verify the external metric is available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get &lt;span class="nt"&gt;--raw&lt;/span&gt; &lt;span class="s2"&gt;"/apis/external.metrics.k8s.io/v1beta1/namespaces/default/redis_queue_length"&lt;/span&gt; | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see a response with a value of&amp;nbsp;&lt;code&gt;0&lt;/code&gt;&amp;nbsp;(since the queue is empty).&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Worker and HPA&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The worker is deliberately simple — a shell script that pops items from the Redis list and "processes" them by sleeping for a second. In production this would be your actual consumer code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt;&amp;nbsp;start the deployment with&amp;nbsp;&lt;code&gt;replicas: 1&lt;/code&gt;, not zero. The HPA controller uses a&amp;nbsp;&lt;code&gt;ScaledToZero&lt;/code&gt;&amp;nbsp;condition internally — it only scales from zero if it was the one that previously scaled the workload to zero. A deployment that starts at zero replicas with a fresh HPA will never scale up. Let the HPA handle the initial scale-down to zero on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# worker.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;queue-worker&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;# start at 1 — the HPA will scale to zero once metrics are available&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;queue-worker&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;queue-worker&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/sh&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;while true; do&lt;/span&gt;
                &lt;span class="s"&gt;item=$(redis-cli -h redis-master BRPOP work-queue 30)&lt;/span&gt;
                &lt;span class="s"&gt;if [ -n "$item" ]; then&lt;/span&gt;
                  &lt;span class="s"&gt;echo "Processing: $item"&lt;/span&gt;
                  &lt;span class="s"&gt;sleep 1  # simulate work&lt;/span&gt;
                &lt;span class="s"&gt;fi&lt;/span&gt;
              &lt;span class="s"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the HPA that scales this deployment between 0 and 10 replicas based on the queue length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# hpa.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;autoscaling/v2&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HorizontalPodAutoscaler&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;queue-worker&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scaleTargetRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;queue-worker&lt;/span&gt;
  &lt;span class="na"&gt;minReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;  &lt;span class="c1"&gt;# this is what HPAScaleToZero enables&lt;/span&gt;
  &lt;span class="na"&gt;maxReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;External&lt;/span&gt;
      &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;metric&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis_queue_length&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Value&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;  &lt;span class="c1"&gt;# one pod per 5 queue items&lt;/span&gt;
  &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;scaleDown&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;stabilizationWindowSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
      &lt;span class="na"&gt;policies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Percent&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
          &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The&amp;nbsp;&lt;code&gt;target.value: "5"&lt;/code&gt;&amp;nbsp;means the HPA will try to maintain a ratio of one pod per five pending items. When the queue has 20 items, you get 4 pods. When it drops to zero, you get zero pods. The&amp;nbsp;&lt;code&gt;behavior.scaleDown&lt;/code&gt;&amp;nbsp;section shortens the stabilization window from the default 5 minutes to 60 seconds — useful for demos and workloads where you want faster scale-to-zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Seeing It in Action&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Apply the worker and HPA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; worker.yaml &lt;span class="nt"&gt;-f&lt;/span&gt; hpa.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open a watch in one terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get hpa queue-worker &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since the queue is empty and the deployment starts at 1 replica, the HPA will first scale it down to zero. This initial scale-down is critical — it sets the internal&amp;nbsp;&lt;code&gt;ScaledToZero&lt;/code&gt;&amp;nbsp;condition that enables future scale-from-zero behavior. After about a minute (our shortened stabilization window), you'll see replicas drop to 0.&lt;/p&gt;

&lt;p&gt;Now push 20 items into the queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;redis-master-0 &lt;span class="nt"&gt;-c&lt;/span&gt; redis &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
  for i in $(seq 1 20); do
    redis-cli LPUSH work-queue "job-$i"
  done
'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within 30-60 seconds (Prometheus scrape interval + adapter refresh + HPA sync period), you'll see the HPA scale the deployment from 0 to 4 replicas. The workers will process the items — one per second per worker — and as the queue drains, the replica count drops. Once the queue is empty and the metric reads zero, the HPA scales the deployment back to zero replicas. Pods gone.&lt;/p&gt;

&lt;p&gt;Here's what the&amp;nbsp;&lt;code&gt;kubectl get hpa queue-worker -w&lt;/code&gt;&amp;nbsp;output looks like over the full cycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME           REFERENCE                 TARGETS   MINPODS   MAXPODS   REPLICAS
queue-worker   Deployment/queue-worker   0/5       0         10        1
queue-worker   Deployment/queue-worker   0/5       0         10        0
queue-worker   Deployment/queue-worker   20/5      0         10        0
queue-worker   Deployment/queue-worker   20/5      0         10        4
queue-worker   Deployment/queue-worker   8/5       0         10        4
queue-worker   Deployment/queue-worker   0/5       0         10        2
queue-worker   Deployment/queue-worker   0/5       0         10        0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push items again — the HPA scales from zero immediately, no manual intervention needed. The cycle repeats indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Cluster Autoscaler Integration&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When a deployment scales to zero, its pods vanish. If those pods were the only workload on a node, the node becomes empty. Cluster autoscaler (or any node lifecycle manager) will notice the idle node and remove it after its configurable cool-down period — typically 10 minutes.&lt;/p&gt;

&lt;p&gt;This is where the real cost savings live. You're not just saving pod-level resources; you're saving entire node-hours. For a workload that processes a queue for two hours a day and sits idle for twenty-two, you go from paying for 24 node-hours to paying for roughly 2.5 (accounting for scale-up overhead).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cold-start chain matters.&lt;/strong&gt;&amp;nbsp;When an item hits the queue after a period of silence, here's what happens in sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prometheus scrapes the redis-exporter (scrape interval, typically 15s)&lt;/li&gt;
&lt;li&gt;Prometheus-adapter picks up the new value (adapter refresh, typically 30s)&lt;/li&gt;
&lt;li&gt;HPA sync loop fires (default 15s) and decides to scale up&lt;/li&gt;
&lt;li&gt;Scheduler assigns the pod to a node — if no nodes are available, cluster autoscaler provisions one (60-120s for cloud providers)&lt;/li&gt;
&lt;li&gt;Kubelet pulls the container image and starts the pod&lt;/li&gt;
&lt;li&gt;Pod becomes Ready&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In a warm cluster (nodes already present), steps 1-5 take about 30-60 seconds total. When a new node must be provisioned, add 1-2 minutes. For workloads where a minute or two of latency is unacceptable, consider these mitigations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smaller container images&lt;/strong&gt;&amp;nbsp;reduce pull time. Distroless or scratch-based images start faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority-based overprovisioning&lt;/strong&gt;: deploy a low-priority "placeholder" pod that reserves a node. When real work arrives, the placeholder gets preempted and the worker starts immediately — no node provisioning needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shorter scrape intervals&lt;/strong&gt;&amp;nbsp;on Prometheus reduce the detection lag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For batch and queue workloads, the 30-60 second cold start is typically fine. You're processing a backlog, not serving interactive requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;When to Use This vs. KEDA&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://keda.sh/" rel="noopener noreferrer"&gt;KEDA&lt;/a&gt;&amp;nbsp;is the most popular alternative for event-driven autoscaling in Kubernetes. It supports 60+ scalers (Redis, SQS, RabbitMQ, Kafka, Azure Queue, and many more), handles the metrics plumbing internally, and can scale to zero without any feature gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use native HPA scale-to-zero when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You already have Prometheus and a metrics adapter deployed&lt;/li&gt;
&lt;li&gt;You want fewer moving parts and no additional CRDs&lt;/li&gt;
&lt;li&gt;Your scaling logic is simple (one metric, linear scaling)&lt;/li&gt;
&lt;li&gt;You prefer to stay within the boundaries of native Kubernetes APIs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use KEDA when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You don't have Prometheus or don't want to maintain the adapter&lt;/li&gt;
&lt;li&gt;You need advanced scaling logic (multiple triggers, complex cooldowns, cron-based scaling)&lt;/li&gt;
&lt;li&gt;You want out-of-the-box support for dozens of event sources without manual adapter configuration&lt;/li&gt;
&lt;li&gt;You need ScaledJobs (scaling Kubernetes Jobs rather than Deployments)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no shame in either choice. KEDA is excellent software. The native approach is simpler when your infrastructure already includes Prometheus — you're just connecting pieces that already exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Gotchas&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You cannot use CPU or memory metrics to scale from zero.&lt;/strong&gt;&amp;nbsp;This is the most common mistake. When replica count is zero, there are no pods, so there's no CPU or memory to measure. The HPA controller requires at least one non-resource metric (external or custom) to make scaling decisions at zero replicas. If you configure only resource metrics, the HPA will refuse to scale from zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deployment must not start at zero replicas.&lt;/strong&gt;&amp;nbsp;This is the subtlest gotcha and I haven't seen it documented clearly anywhere. The HPA controller maintains a&amp;nbsp;&lt;code&gt;ScaledToZero&lt;/code&gt;&amp;nbsp;condition internally. It only scales FROM zero if it was the one that previously scaled the workload TO zero. If you deploy with&amp;nbsp;&lt;code&gt;replicas: 0&lt;/code&gt;&amp;nbsp;and create a fresh HPA, the controller sees zero replicas but no&amp;nbsp;&lt;code&gt;ScaledToZero&lt;/code&gt;&amp;nbsp;condition, and sets&amp;nbsp;&lt;code&gt;ScalingActive: False&lt;/code&gt;&amp;nbsp;with reason&amp;nbsp;&lt;code&gt;ScalingDisabled&lt;/code&gt;. The fix is simple: start with&amp;nbsp;&lt;code&gt;replicas: 1&lt;/code&gt;&amp;nbsp;and let the HPA scale it down naturally. Once the HPA has performed that initial scale-to-zero, the&amp;nbsp;&lt;code&gt;ScaledToZero&lt;/code&gt;&amp;nbsp;condition is set to&amp;nbsp;&lt;code&gt;True&lt;/code&gt;&amp;nbsp;and all subsequent scale-from-zero operations work correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stabilization window still applies at zero.&lt;/strong&gt;&amp;nbsp;By default, the HPA waits 5 minutes of sustained low metrics before scaling down. This applies to the transition from 1 to 0 as well. If you want faster scale-to-zero, configure the&amp;nbsp;&lt;code&gt;behavior.scaleDown&lt;/code&gt;&amp;nbsp;section with a shorter&amp;nbsp;&lt;code&gt;stabilizationWindowSeconds&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The feature gate is alpha — but don't let that scare you.&lt;/strong&gt;&amp;nbsp;&lt;code&gt;HPAScaleToZero&lt;/code&gt;&amp;nbsp;has been available since Kubernetes 1.16 without breaking changes. It hasn't graduated to beta primarily due to KEP process inertia, not because of instability. The implementation is a small conditional in the HPA controller that allows&amp;nbsp;&lt;code&gt;minReplicas: 0&lt;/code&gt;&amp;nbsp;when the feature gate is enabled. It's been running in production clusters for years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PodDisruptionBudgets at zero replicas.&lt;/strong&gt;&amp;nbsp;A PDB with&amp;nbsp;&lt;code&gt;minAvailable: 1&lt;/code&gt;&amp;nbsp;on a deployment that's at zero replicas won't prevent the scale-to-zero — PDBs apply to voluntary eviction of running pods, and scaling down is a different path. However, consider what happens if you have a PDB and scale from zero to one: the single pod is protected by the PDB immediately upon becoming ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Wrapping Up&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The combination of native HPA scale-to-zero, external metrics from Prometheus, and cluster autoscaler gives you genuine pay-for-what-you-use economics on idle workloads. No third-party controllers, no additional CRDs, no vendor lock-in. The feature gate has been stable for twenty releases. Enable it, point your HPA at a queue metric, set&amp;nbsp;&lt;code&gt;minReplicas: 0&lt;/code&gt;, and stop paying for compute that's doing nothing.&lt;/p&gt;

&lt;p&gt;The KEP tracking this feature is&amp;nbsp;&lt;a href="https://github.com/kubernetes/enhancements/issues/2021" rel="noopener noreferrer"&gt;KEP-2021: HPA Scale to Zero&lt;/a&gt;&amp;nbsp;— if you want to see it graduate to beta and become the default, that's the issue to watch and engage with.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>AWS CloudFront Cache Policies: Complete Guide</title>
      <dc:creator>Maciej Łopalewski</dc:creator>
      <pubDate>Wed, 20 May 2026 11:30:24 +0000</pubDate>
      <link>https://dev.to/u11d/aws-cloudfront-cache-policies-complete-guide-4cdi</link>
      <guid>https://dev.to/u11d/aws-cloudfront-cache-policies-complete-guide-4cdi</guid>
      <description>&lt;p&gt;A CloudFront cache policy controls two things: the &lt;strong&gt;cache key&lt;/strong&gt; (which combination of URL, headers, cookies, and query strings makes a request unique) and the &lt;strong&gt;TTL&lt;/strong&gt; (how long CloudFront keeps an object at the edge before re-checking the origin). Those two settings together determine your cache hit ratio.&lt;/p&gt;

&lt;p&gt;The CloudFront console currently shows fifteen managed cache policies. Five are broadly useful on a typical self-managed distribution: &lt;code&gt;CachingOptimized&lt;/code&gt;, &lt;code&gt;CachingOptimizedForUncompressedObjects&lt;/code&gt;, &lt;code&gt;CachingDisabled&lt;/code&gt;, &lt;code&gt;UseOriginCacheControlHeaders&lt;/code&gt;, and &lt;code&gt;UseOriginCacheControlHeaders-QueryStrings&lt;/code&gt;. One (&lt;code&gt;Elemental-MediaPackage&lt;/code&gt;) is for AWS Elemental MediaPackage video origins. The remaining nine are Amplify-related — a standalone &lt;code&gt;Amplify&lt;/code&gt; policy for Amplify origins plus eight &lt;code&gt;Amplify-*&lt;/code&gt; policies that Amplify Hosting attaches to its own distributions automatically. You can also build a custom policy when none of the managed ones fit the shape of your traffic.&lt;/p&gt;

&lt;p&gt;This is a follow-up to &lt;a href="https://u11d.com/blog/aws-cloudfront-cache-origin-response-policies/" rel="noopener noreferrer"&gt;my earlier post on CloudFront's three policy types&lt;/a&gt;, going one level deeper on just cache policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a cache policy controls
&lt;/h2&gt;

&lt;p&gt;A cache policy has three categories of settings: policy information (name and description, just metadata), TTL settings (Minimum, Maximum, Default), and cache key settings (which headers, cookies, and query strings to include). Everything that affects caching behavior lives in the latter two.&lt;/p&gt;

&lt;p&gt;The cache key is the unique identifier CloudFront uses to look up an object at an edge location. If two viewer requests produce the same cache key, the second one is a cache hit. If they differ — say, one request includes a &lt;code&gt;?ref=twitter&lt;/code&gt; query string and the other does not, and your policy includes query strings in the cache key — they get treated as separate objects, even when the response body is identical. Cache key shape is the single biggest lever for hit ratio.&lt;/p&gt;

&lt;p&gt;The TTL settings work alongside &lt;code&gt;Cache-Control&lt;/code&gt; and &lt;code&gt;Expires&lt;/code&gt; headers from your origin to determine how long a cached object stays valid at the edge. They behave subtly differently from each other; more on that next.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Minimum, Maximum, and Default TTL actually work
&lt;/h2&gt;

&lt;p&gt;The three TTL settings are not redundant. Each one applies in a different scenario, and getting them confused is one of the more common ways to either over-cache stale content or hammer your origin unnecessarily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default TTL&lt;/strong&gt; is used when your origin sends no &lt;code&gt;Cache-Control&lt;/code&gt; or &lt;code&gt;Expires&lt;/code&gt; header at all. CloudFront falls back to this value, subject to the Minimum TTL floor — if Minimum TTL is greater than Default TTL, CloudFront caches for at least the Minimum TTL. The default for the AWS managed &lt;code&gt;CachingOptimized&lt;/code&gt; policy is 86,400 seconds (24 hours).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maximum TTL&lt;/strong&gt; caps the TTL when your origin &lt;em&gt;does&lt;/em&gt; send &lt;code&gt;Cache-Control&lt;/code&gt; or &lt;code&gt;Expires&lt;/code&gt;. If your origin says &lt;code&gt;Cache-Control: max-age=5184000&lt;/code&gt; (60 days) and Maximum TTL is 31,536,000 (365 days), CloudFront honors the origin's 60 days. If your origin says &lt;code&gt;max-age=63072000&lt;/code&gt; (730 days), CloudFront caps it at 365 days. This setting only matters when you want to override an origin that's claiming overly aggressive cache durations.&lt;/p&gt;

&lt;p&gt;One nuance worth knowing: if your origin sends both &lt;code&gt;max-age&lt;/code&gt; and &lt;code&gt;s-maxage&lt;/code&gt;, CloudFront uses &lt;code&gt;s-maxage&lt;/code&gt; for its own caching decisions and lets browsers use &lt;code&gt;max-age&lt;/code&gt;. This is how you get different cache durations at the edge versus the browser without writing two policies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum TTL&lt;/strong&gt; is the floor. CloudFront keeps the object for at least this long, no matter what. This one comes with a sharp edge: if Minimum TTL is greater than 0, CloudFront ignores &lt;code&gt;Cache-Control: no-cache&lt;/code&gt;, &lt;code&gt;no-store&lt;/code&gt;, and &lt;code&gt;private&lt;/code&gt; directives from your origin. The object gets cached anyway, for at least the Minimum TTL duration. Both &lt;code&gt;CachingOptimized&lt;/code&gt; and &lt;code&gt;CachingOptimizedForUncompressedObjects&lt;/code&gt; have Minimum TTL of 1 second, and the standalone &lt;code&gt;Amplify&lt;/code&gt; policy has Minimum TTL of 2 seconds — meaning you cannot reliably stop caching with origin headers alone if you are using them.&lt;/p&gt;

&lt;p&gt;If you set all three TTLs to 0, caching is effectively disabled — which is exactly what &lt;code&gt;CachingDisabled&lt;/code&gt; does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache key settings: headers, cookies, query strings, and compression
&lt;/h2&gt;

&lt;p&gt;Each of the three viewer-data sources (headers, cookies, query strings) can be configured independently. Cookies and query strings have four modes: include none, include all, include specifically named ones, or include all-except-named-ones. Headers are the exception — they support only "none" or a specific list. There is no "all headers" option, because that would be unbounded and would fragment the cache catastrophically.&lt;/p&gt;

&lt;p&gt;When you include a header, cookie, or query string in the cache key, CloudFront also automatically forwards it to the origin on cache misses. The cache key and the origin request are coupled by default; the only way to forward something to the origin &lt;em&gt;without&lt;/em&gt; affecting the cache key is to add it to a separate origin request policy. This is the most common confusion with cache policies and the reason CloudFront split them apart in the first place.&lt;/p&gt;

&lt;p&gt;A subtle but important detail: cache key matching uses header, cookie, and query string &lt;em&gt;names&lt;/em&gt;, but the matching is on the full name+value. Specifying &lt;code&gt;session_id&lt;/code&gt; in the cache key means every distinct value of &lt;code&gt;session_id&lt;/code&gt; produces a different cache key — so if every visitor has a unique session ID, every request is a cache miss. This is why "include all cookies" rarely makes sense for general traffic.&lt;/p&gt;

&lt;p&gt;The compression settings (&lt;code&gt;EnableAcceptEncodingGzip&lt;/code&gt;, &lt;code&gt;EnableAcceptEncodingBrotli&lt;/code&gt;) tell CloudFront to normalize the &lt;code&gt;Accept-Encoding&lt;/code&gt; header before adding it to the cache key. With both enabled, the cache key sees one of &lt;code&gt;br,gzip&lt;/code&gt;, &lt;code&gt;gzip&lt;/code&gt;, or &lt;code&gt;br&lt;/code&gt; (depending on what the viewer supports), or no &lt;code&gt;Accept-Encoding&lt;/code&gt; at all when the viewer supports neither — in that last case CloudFront sends &lt;code&gt;Accept-Encoding: identity&lt;/code&gt; to the origin instead. Without normalization, every browser variation of &lt;code&gt;Accept-Encoding: gzip, deflate, br, zstd&lt;/code&gt; would produce a distinct cache key. Enable this when your origin returns compressed responses or when CloudFront edge compression is on. Leave it off otherwise.&lt;/p&gt;

&lt;p&gt;One gotcha: if you enable Gzip or Brotli in the cache policy, do not also include &lt;code&gt;Accept-Encoding&lt;/code&gt; in an origin request policy attached to the same behavior. CloudFront handles that header itself when compression is enabled, and adding it to the origin request policy has no effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AWS managed cache policies
&lt;/h2&gt;

&lt;p&gt;This is the comparison table for the policies you'll actually attach to a self-managed distribution by hand. The Amplify Hosting policies (&lt;code&gt;Amplify-*&lt;/code&gt; and &lt;code&gt;Amplify-*-V2&lt;/code&gt;) are intentionally excluded — see the Amplify section below for why.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;TTL (min/default/max)&lt;/th&gt;
&lt;th&gt;Cookies&lt;/th&gt;
&lt;th&gt;Query strings&lt;/th&gt;
&lt;th&gt;Headers in cache key&lt;/th&gt;
&lt;th&gt;Compression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CachingOptimized&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1s / 24h / 365d&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Gzip + Brotli&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CachingOptimizedForUncompressedObjects&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1s / 24h / 365d&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CachingDisabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 / 0 / 0&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UseOriginCacheControlHeaders&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 / 0 / 365d&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Host, Origin, method overrides&lt;/td&gt;
&lt;td&gt;Gzip + Brotli&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UseOriginCacheControlHeaders-QueryStrings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 / 0 / 365d&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;Host, Origin, method overrides&lt;/td&gt;
&lt;td&gt;Gzip + Brotli&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Elemental-MediaPackage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 / 24h / 365d&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;aws.manifestfilter, start, end, m&lt;/td&gt;
&lt;td&gt;Origin&lt;/td&gt;
&lt;td&gt;Gzip&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  CachingOptimized
&lt;/h3&gt;

&lt;p&gt;ID: &lt;code&gt;658327ea-f89d-4fab-a63d-7e88639e58f6&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The default choice for static content — S3 buckets, image assets, JS and CSS bundles, anything that does not change based on who is asking. The cache key is just the requested object plus the normalized &lt;code&gt;Accept-Encoding&lt;/code&gt;. No headers, no cookies, no query strings. This produces the highest possible cache hit ratio for static content.&lt;/p&gt;

&lt;p&gt;Watch the Minimum TTL of 1 second: even with &lt;code&gt;Cache-Control: no-store&lt;/code&gt; on your origin, CloudFront holds the object for at least one second. That is usually fine, but if you are using this policy on something where origin-side cache busting needs to take effect immediately, swap to &lt;code&gt;UseOriginCacheControlHeaders&lt;/code&gt; or &lt;code&gt;CachingDisabled&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  CachingOptimizedForUncompressedObjects
&lt;/h3&gt;

&lt;p&gt;ID: &lt;code&gt;b2884449-e4de-46a7-ac36-70bc7f1ddd6d&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Identical to &lt;code&gt;CachingOptimized&lt;/code&gt; except compression is off. Use this when your origin does not return Gzip or Brotli (raw binary files, video segments, pre-compressed media formats like MP4 or WebP) and you are not using CloudFront edge compression. With compression off, the &lt;code&gt;Accept-Encoding&lt;/code&gt; header is excluded from the cache key entirely, which keeps things simple.&lt;/p&gt;

&lt;p&gt;If you cannot decide between this and &lt;code&gt;CachingOptimized&lt;/code&gt;, default to &lt;code&gt;CachingOptimized&lt;/code&gt;. The compression setting only causes problems when your origin produces objects that do not benefit from compression — and even then, it is a minor inefficiency rather than a correctness bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  CachingDisabled
&lt;/h3&gt;

&lt;p&gt;ID: &lt;code&gt;4135ea2d-6df8-44a3-9df3-4b5a84be39ad&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;All TTLs at 0. Nothing in the cache key. Every request goes straight through to the origin. Use this for API behaviors, dynamic GET endpoints that should never be cached, real-time data, WebSockets, and anywhere caching would be incorrect rather than just suboptimal. (POST, PUT, and DELETE aren't cached by CloudFront in the first place — only GET, HEAD, and optionally OPTIONS are — so attaching &lt;code&gt;CachingDisabled&lt;/code&gt; to those methods is more about being explicit than functionally necessary.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;UseOriginCacheControlHeaders&lt;/code&gt;, &lt;code&gt;UseOriginCacheControlHeaders-QueryStrings&lt;/code&gt;, and &lt;code&gt;Elemental-MediaPackage&lt;/code&gt; also have Minimum TTL of 0, so they too will respect &lt;code&gt;Cache-Control: no-store&lt;/code&gt; from your origin. The difference is that &lt;code&gt;CachingDisabled&lt;/code&gt; never caches anything regardless of origin headers, while the others cache when the origin tells them to.&lt;/p&gt;

&lt;h3&gt;
  
  
  UseOriginCacheControlHeaders
&lt;/h3&gt;

&lt;p&gt;ID: &lt;code&gt;83da9c7e-98b4-4e11-a168-04f0df8e2c65&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Defers caching duration to your origin's &lt;code&gt;Cache-Control&lt;/code&gt; and &lt;code&gt;Expires&lt;/code&gt; headers. If the origin says &lt;code&gt;max-age=600&lt;/code&gt;, CloudFront caches for 10 minutes. If the origin says &lt;code&gt;no-store&lt;/code&gt;, CloudFront does not cache at all (because Minimum TTL is 0).&lt;/p&gt;

&lt;p&gt;This is the right choice for CMS-backed sites, mixed static-and-dynamic apps, and anywhere your application code already knows what should be cached and for how long. WordPress, Drupal, server-rendered Next.js, and most traditional web apps fit here.&lt;/p&gt;

&lt;p&gt;The cache key includes all cookies plus &lt;code&gt;Host&lt;/code&gt;, &lt;code&gt;Origin&lt;/code&gt;, and three method-override headers (&lt;code&gt;X-HTTP-Method-Override&lt;/code&gt;, &lt;code&gt;X-HTTP-Method&lt;/code&gt;, &lt;code&gt;X-Method-Override&lt;/code&gt;). The cookie inclusion is significant: if your application sets per-user session cookies on every response, you will fragment the cache per user. Either avoid setting cookies on cacheable responses, or stick with &lt;code&gt;CachingOptimized&lt;/code&gt; for paths that should be shared across users.&lt;/p&gt;

&lt;h3&gt;
  
  
  UseOriginCacheControlHeaders-QueryStrings
&lt;/h3&gt;

&lt;p&gt;ID: &lt;code&gt;4cc15a8a-d715-48a4-82b8-cc0b614638fe&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Same as &lt;code&gt;UseOriginCacheControlHeaders&lt;/code&gt; but with all query strings included in the cache key. Use this when your origin returns different responses based on query string values — search results, filtered listings, paginated content — and you want CloudFront to cache each variant separately.&lt;/p&gt;

&lt;p&gt;The trade-off is cache fragmentation. URLs that include UTM parameters, click IDs, or other tracking junk get cached independently, which both lowers your hit ratio and bloats CloudFront's storage of your content. If you can strip those server-side or with a CloudFront Function before this policy applies, do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Amplify (and the eight related Amplify policies)
&lt;/h3&gt;

&lt;p&gt;ID: &lt;code&gt;2e54312d-136d-493c-8eb9-b001f22f67d2&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two distinct things share the Amplify name. The standalone &lt;code&gt;Amplify&lt;/code&gt; policy is documented as a regular CloudFront managed policy designed for use with an Amplify web app origin — Min TTL 2 seconds, Max TTL 600 seconds (10 minutes), Default TTL 2 seconds, with &lt;code&gt;Authorization&lt;/code&gt;, &lt;code&gt;CloudFront-Viewer-Country&lt;/code&gt;, and &lt;code&gt;Host&lt;/code&gt; in the cache key plus all cookies and all query strings. AWS doesn't warn against using it; it's just narrowly tuned for Amplify-shaped workloads. The 2-second Minimum TTL means even no-store responses get cached briefly, which is rarely what you want outside of that specific architecture.&lt;/p&gt;

&lt;p&gt;The eight &lt;code&gt;Amplify-*&lt;/code&gt; policies are something else entirely: &lt;code&gt;Amplify-Default&lt;/code&gt;, &lt;code&gt;Amplify-DefaultNoCookies&lt;/code&gt;, &lt;code&gt;Amplify-ImageOptimization&lt;/code&gt;, &lt;code&gt;Amplify-StaticContent&lt;/code&gt;, plus a &lt;code&gt;-V2&lt;/code&gt; variant of each. These are managed by Amplify Hosting itself — Amplify attaches them to the distributions it provisions and resets them on every deploy. AWS explicitly says "we don't recommend that you use these policies for your distributions." If you need similar cache key shapes for a non-Amplify app, copy the settings into a custom policy.&lt;/p&gt;

&lt;p&gt;The V2 variants appear to be tied to the August 2024 Amplify Hosting caching overhaul, which raised default static asset cache duration from 2 seconds to 1 year and Maximum TTL from 10 minutes to 1 year. AWS hasn't documented the V2 policies in the public CloudFront developer guide as of this writing — they're visible in the console but the documentation only covers the four originals.&lt;/p&gt;

&lt;h3&gt;
  
  
  Elemental-MediaPackage
&lt;/h3&gt;

&lt;p&gt;ID: &lt;code&gt;08627262-05a9-4f76-9ded-b50ca2e3a84f&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;For AWS Elemental MediaPackage origins specifically — HLS and DASH video streaming. The cache key includes the four query string parameters that MediaPackage actually uses for manifest filtering and time-shifted playback (&lt;code&gt;aws.manifestfilter&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt;, &lt;code&gt;end&lt;/code&gt;, &lt;code&gt;m&lt;/code&gt;) plus the &lt;code&gt;Origin&lt;/code&gt; header for CORS. Other query strings are excluded, which keeps the cache key tight even when player libraries append cache-busting noise.&lt;/p&gt;

&lt;p&gt;If you are not using MediaPackage, do not use this policy. If you are, use it — it is tuned for the specific request shape MediaPackage produces.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose a cache policy
&lt;/h2&gt;

&lt;p&gt;Match your origin and content type to the policy that is already tuned for it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Recommended policy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 static site, image bucket, asset CDN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CachingOptimized&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video files (MP4, WebP), pre-compressed binaries&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CachingOptimizedForUncompressedObjects&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API endpoints, dynamic GET responses, real-time data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CachingDisabled&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WordPress, Drupal, server-rendered apps with origin Cache-Control&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UseOriginCacheControlHeaders&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same as above, but query strings affect the response&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UseOriginCacheControlHeaders-QueryStrings&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Elemental MediaPackage HLS/DASH origin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Elemental-MediaPackage&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom requirements that do not match any of the above&lt;/td&gt;
&lt;td&gt;Custom cache policy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a single distribution serving multiple content types, you typically attach different policies to different cache behaviors. A common setup: &lt;code&gt;CachingOptimized&lt;/code&gt; on the default behavior (static assets), &lt;code&gt;CachingDisabled&lt;/code&gt; on &lt;code&gt;/api/*&lt;/code&gt;, &lt;code&gt;UseOriginCacheControlHeaders&lt;/code&gt; on &lt;code&gt;/blog/*&lt;/code&gt; if blog pages set their own &lt;code&gt;Cache-Control&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to create a custom cache policy
&lt;/h2&gt;

&lt;p&gt;Reach for a custom policy when you need to include something specific in the cache key that the managed policies do not cover. The most common reasons are device-type segmentation using &lt;code&gt;CloudFront-Is-Mobile-Viewer&lt;/code&gt; (serving different markup to phones versus desktops), country-based variation using &lt;code&gt;CloudFront-Viewer-Country&lt;/code&gt; (geo-targeted content where edge handling beats origin-side detection), language negotiation via &lt;code&gt;Accept-Language&lt;/code&gt;, and tier-based content where a coarse-grained auth bucket determines which response to serve.&lt;/p&gt;

&lt;p&gt;Here is a Terraform example for a custom policy that varies on viewer country and a coarse auth-tier cookie:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudfront_cache_policy"&lt;/span&gt; &lt;span class="s2"&gt;"country_and_tier"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"country-and-tier"&lt;/span&gt;
  &lt;span class="nx"&gt;default_ttl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;       &lt;span class="c1"&gt;# 1 hour when origin sends no Cache-Control&lt;/span&gt;
  &lt;span class="nx"&gt;max_ttl&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;      &lt;span class="c1"&gt;# cap origin Cache-Control at 24 hours&lt;/span&gt;
  &lt;span class="nx"&gt;min_ttl&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;          &lt;span class="c1"&gt;# respect origin no-store&lt;/span&gt;

  &lt;span class="nx"&gt;parameters_in_cache_key_and_forwarded_to_origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# normalize Accept-Encoding so cache key collapses across browsers&lt;/span&gt;
    &lt;span class="nx"&gt;enable_accept_encoding_gzip&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;enable_accept_encoding_brotli&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="nx"&gt;headers_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;header_behavior&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"whitelist"&lt;/span&gt;
      &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CloudFront-Viewer-Country"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# geo-target at the edge&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;cookies_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;cookie_behavior&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"whitelist"&lt;/span&gt;
      &lt;span class="nx"&gt;cookies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;# coarse bucket like anonymous/free/premium — NOT a per-user session ID&lt;/span&gt;
        &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"auth_tier"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;query_strings_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;query_string_behavior&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cookie name matters here. &lt;code&gt;auth_tier&lt;/code&gt; with values like &lt;code&gt;anonymous&lt;/code&gt;, &lt;code&gt;free&lt;/code&gt;, and &lt;code&gt;premium&lt;/code&gt; produces three cache variants per country, which is reasonable. A per-user session token in the same slot would produce one cache variant per user, which is the cache-buster pattern the article warns against earlier.&lt;/p&gt;

&lt;p&gt;A few things to know when going custom. CloudFront-generated headers like &lt;code&gt;CloudFront-Viewer-Country&lt;/code&gt; are not sent to the origin by default — you need to either include them in the cache key (which automatically forwards them to the origin) or add them to an origin request policy if you want them at the origin without varying the cache. The cache compression toggle should match what your origin returns; turning it on when your origin does not compress can fragment the cache without benefit. And keep Minimum TTL at 0 unless you specifically want to override origin no-store directives, because the surprise-caching behavior burns a lot of debugging hours when you forget about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cache hit ratio tanks after a policy change.&lt;/strong&gt; Almost always means the cache key got too specific. Check whether the new policy includes cookies or query strings the previous one did not, and re-check whether your origin sets per-user cookies on responses that should be shared across users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Origin keeps getting hit despite a long Default TTL.&lt;/strong&gt; Default TTL only applies when the origin sends no &lt;code&gt;Cache-Control&lt;/code&gt; or &lt;code&gt;Expires&lt;/code&gt; header. If your origin sets &lt;code&gt;Cache-Control: max-age=60&lt;/code&gt;, CloudFront uses 60 seconds, not the policy's Default TTL. Either change the origin or use Maximum TTL to cap origin-controlled durations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;no-cache&lt;/code&gt; from origin is being ignored.&lt;/strong&gt; Several managed policies (&lt;code&gt;CachingOptimized&lt;/code&gt;, &lt;code&gt;CachingOptimizedForUncompressedObjects&lt;/code&gt;, and &lt;code&gt;Amplify&lt;/code&gt;) have a Minimum TTL greater than 0, which overrides origin no-cache directives. If you need origin-side cache busting to take effect immediately, switch to &lt;code&gt;CachingDisabled&lt;/code&gt; or build a custom policy with Minimum TTL = 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forwarding &lt;code&gt;CloudFront-Viewer-Country&lt;/code&gt; to origin does not work.&lt;/strong&gt; For CloudFront-generated headers like &lt;code&gt;CloudFront-Viewer-Country&lt;/code&gt;, make sure the header is explicitly enabled via a cache policy or an origin request policy — they aren't sent to your origin by default. If it's in the cache key, it's also forwarded to the origin automatically. If you only need it at the origin and don't want to vary the cache, put it in an origin request policy instead. (Note: a few CloudFront-* headers, including &lt;code&gt;CloudFront-Viewer-Address&lt;/code&gt;, &lt;code&gt;CloudFront-Viewer-ASN&lt;/code&gt;, and the TLS-related ones, can only be added via an origin request policy and not in a cache policy.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Authorization&lt;/code&gt; header is not reaching the origin.&lt;/strong&gt; CloudFront removes it by default. To forward it, either include &lt;code&gt;Authorization&lt;/code&gt; in the cache key with a cache policy, or use an origin request policy that forwards all viewer headers (the managed &lt;code&gt;Managed-AllViewer&lt;/code&gt; policy does this). You cannot forward only &lt;code&gt;Authorization&lt;/code&gt; via an origin request policy. Be careful: putting &lt;code&gt;Authorization&lt;/code&gt; in the cache key creates per-token cache variants and is usually inappropriate for broadly shared cacheable content.&lt;/p&gt;

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

&lt;p&gt;If you came here looking for "which cache policy should I use," the short answer is &lt;code&gt;CachingOptimized&lt;/code&gt; for static content, &lt;code&gt;CachingDisabled&lt;/code&gt; for APIs, &lt;code&gt;UseOriginCacheControlHeaders&lt;/code&gt; for content where your origin can express its own caching intent. Everything else is tuning at the margins.&lt;/p&gt;

&lt;p&gt;The next post in this series goes deep on origin request policies — where the separation between cache key and origin forwarding really earns its keep — followed by response headers policies, which can replace a lot of security middleware with a single CloudFront config.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloudfront</category>
      <category>cdn</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Secure Access to Private EKS Clusters Without Bastion Hosts Using SSM</title>
      <dc:creator>Paweł Swiridow</dc:creator>
      <pubDate>Wed, 13 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/secure-access-to-private-eks-clusters-without-bastion-hosts-using-ssm-10o</link>
      <guid>https://dev.to/u11d/secure-access-to-private-eks-clusters-without-bastion-hosts-using-ssm-10o</guid>
      <description>&lt;h2&gt;
  
  
  Accessing Private EKS Clusters Without Losing Your Mind
&lt;/h2&gt;

&lt;p&gt;Locking down your Kubernetes control plane is a basic requirement for any production environment. Exposing the EKS API server to the public internet is just asking for automated scanners to ruin your weekend. However, securing the endpoint creates an operational headache: how do you actually run &lt;code&gt;kubectl&lt;/code&gt; when the API is sealed inside a private subnet?&lt;/p&gt;

&lt;p&gt;The traditional answer was a bastion host. But managing SSH keys, rotating credentials, and maintaining yet another publicly exposed EC2 instance is tedious. We all know that a "temporary" bastion host spun up on a Friday afternoon will inevitably become a load-bearing production pillar by Monday.&lt;/p&gt;

&lt;p&gt;Instead, we can use AWS Systems Manager (SSM) Session Manager. By leveraging the SSM agent already running on your EKS worker nodes, we can securely tunnel our local traffic directly to the private API endpoint without opening inbound ports or managing SSH keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mechanics of the SSM Tunnel
&lt;/h3&gt;

&lt;p&gt;The flow is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your local machine initiates an SSM port forwarding session targeting a specific EKS worker node.&lt;/li&gt;
&lt;li&gt;The SSM session is instructed to forward traffic to a remote host (the private EKS API endpoint URL) on port 443.&lt;/li&gt;
&lt;li&gt;You update your &lt;code&gt;kubeconfig&lt;/code&gt; to point to &lt;code&gt;localhost&lt;/code&gt; on your chosen forwarded port.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the worker node is already in the VPC and authorized to talk to the EKS control plane, it acts as a highly secure, identity-aware proxy. Access is governed entirely by IAM, meaning you can audit every connection via CloudTrail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisite: IAM Configuration
&lt;/h3&gt;

&lt;p&gt;For this to work, your EKS worker nodes must have the SSM agent installed (the official EKS optimized AMIs have this by default) and the correct IAM permissions.&lt;/p&gt;

&lt;p&gt;Here is a Terraform snippet demonstrating how to attach the necessary SSM policy to your existing EKS node IAM role.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Assumes you already have an aws_iam_role defined for your worker nodes&lt;/span&gt;
&lt;span class="c1"&gt;# named 'eks_node_role'&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy_attachment"&lt;/span&gt; &lt;span class="s2"&gt;"ssm_managed_instance_core"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;policy_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks_node_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Optional but recommended: Restrict who can start sessions in IAM&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy"&lt;/span&gt; &lt;span class="s2"&gt;"ssm_user_access"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"EKS-SSM-Tunnel-Access"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allows users to port forward to EKS nodes"&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ssm:StartSession"&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="s2"&gt;"arn:aws:ec2:*:*:instance/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"arn:aws:ssm:*:*:document/AWS-StartPortForwardingSessionToRemoteHost"&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="c1"&gt;# In a real environment, restrict the instance resource via tags&lt;/span&gt;
        &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s2"&gt;"ssm:resourceTag/eks:cluster-name"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-production-cluster"&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures your nodes can communicate with the SSM service and restricts which IAM users can actually initiate the tunnel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Establishing the Tunnel
&lt;/h3&gt;

&lt;p&gt;Once the nodes are registered in SSM, you need a script to extract a valid instance ID, locate the cluster API endpoint, and start the tunnel.&lt;/p&gt;

&lt;p&gt;Here is a Bash script you can execute locally to handle the heavy lifting. It requires the AWS CLI and the Session Manager plugin to be installed on your workstation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;CLUSTER_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"my-production-cluster"&lt;/span&gt;
&lt;span class="nv"&gt;REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="nv"&gt;LOCAL_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"8443"&lt;/span&gt;

&lt;span class="c"&gt;# Fetch the private endpoint of the EKS cluster&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Fetching EKS endpoint for &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLUSTER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
&lt;span class="nv"&gt;EKS_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws eks describe-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLUSTER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REGION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"cluster.endpoint"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/https:\/\///'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Find an active worker node instance ID using tags&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Finding an active worker node..."&lt;/span&gt;
&lt;span class="nv"&gt;INSTANCE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-instances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REGION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="s2"&gt;"Name=tag:eks:cluster-name,Values=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLUSTER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"Name=instance-state-name,Values=running"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"Reservations[0].Instances[0].InstanceId"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"None"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Error: No running worker nodes found."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Establishing SSM tunnel through &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INSTANCE_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; to &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EKS_ENDPOINT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Leave this terminal open. Access EKS via https://localhost:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LOCAL_PORT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Start the port forwarding session&lt;/span&gt;
aws ssm start-session &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REGION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INSTANCE_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--document-name&lt;/span&gt; AWS-StartPortForwardingSessionToRemoteHost &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--parameters&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;host&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:[&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EKS_ENDPOINT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;],&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;portNumber&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:[&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;443&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;],&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;localPortNumber&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:[&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LOCAL_PORT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this script, and it will bind &lt;code&gt;localhost:8443&lt;/code&gt; to the private API endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Updating Kubeconfig
&lt;/h3&gt;

&lt;p&gt;The final step is modifying your local Kubernetes configuration. You cannot simply run &lt;code&gt;aws eks update-kubeconfig&lt;/code&gt; and call it a day, because that will configure the private AWS endpoint, which your machine still cannot route to directly.&lt;/p&gt;

&lt;p&gt;You need to manually alter the &lt;code&gt;server&lt;/code&gt; field for your cluster to point to the local port. &lt;/p&gt;

&lt;p&gt;When you port-forward the EKS API server to your local machine, connecting to &lt;code&gt;https://localhost:8443&lt;/code&gt; introduces a new problem. The API server presents a TLS certificate minted for its internal AWS endpoint (e.g., &lt;code&gt;1234567890ABCDEF.yl4.us-east-1.eks.amazonaws.com&lt;/code&gt;), not &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The quick, dirty fix is to add &lt;code&gt;insecure-skip-tls-verify: true&lt;/code&gt; to your &lt;code&gt;kubeconfig&lt;/code&gt;. But nothing screams "I definitely passed my SOC2 audit" quite like explicitly disabling TLS validation in production. It is the infrastructure equivalent of putting black tape over a check engine light.&lt;/p&gt;

&lt;p&gt;Instead of turning off validation, we can instruct &lt;code&gt;kubectl&lt;/code&gt; to connect via our local port but validate the TLS certificate against the actual EKS endpoint hostname. We do this by utilizing the &lt;code&gt;tls-server-name&lt;/code&gt; parameter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;clusters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cluster&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://localhost:8443&lt;/span&gt;
    &lt;span class="c1"&gt;# Validate the certificate against the real AWS endpoint&lt;/span&gt;
    &lt;span class="na"&gt;tls-server-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1234567890ABCDEF.yl4.us-east-1.eks.amazonaws.com&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:eks:us-east-1:123456789012:cluster/my-production-cluster&lt;/span&gt;
&lt;span class="c1"&gt;# ... contexts and users remain unchanged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once saved, &lt;code&gt;kubectl get pods&lt;/code&gt; will route securely through the SSM tunnel, across the worker node, and hit the control plane.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrap-Up
&lt;/h3&gt;

&lt;p&gt;Relying on SSM port forwarding eliminates the need for VPNs, bastion hosts, and complex routing rules just to run operational commands against an isolated EKS cluster. By utilizing the existing IAM-integrated agent on your worker nodes, you shrink your external attack surface while maintaining strict audit trails for developer access.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>aws</category>
      <category>eks</category>
    </item>
  </channel>
</rss>
