<?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: Nazmul Ahasan</title>
    <description>The latest articles on DEV Community by Nazmul Ahasan (@nrejve).</description>
    <link>https://dev.to/nrejve</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3399906%2F028b2a6d-c1ee-492e-a53c-d0f72a4d772f.jpg</url>
      <title>DEV Community: Nazmul Ahasan</title>
      <link>https://dev.to/nrejve</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nrejve"/>
    <language>en</language>
    <item>
      <title>Binance Hack DevSecOps Lessons: The Chain Did What It Was Told</title>
      <dc:creator>Nazmul Ahasan</dc:creator>
      <pubDate>Wed, 04 Nov 2026 19:03:41 +0000</pubDate>
      <link>https://dev.to/nrejve/binance-hack-devsecops-lessons-the-chain-did-what-it-was-told-20d4</link>
      <guid>https://dev.to/nrejve/binance-hack-devsecops-lessons-the-chain-did-what-it-was-told-20d4</guid>
      <description>&lt;p&gt;On October 29, 2026, Binance lost about $6.3 billion. Nobody broke the cryptography, and no consensus bug was involved. The blockchain processed signed transactions exactly as it was designed to. As far as the chain could tell, everything was in order.&lt;/p&gt;

&lt;p&gt;That is the part I keep returning to. The chain did what it was told. Everything that failed happened off-chain: people, laptops, credentials, deploy pipelines, and the policy around signing. Those are platform and DevSecOps problems. Most of us run systems shaped like that, whether or not crypto is involved.&lt;/p&gt;

&lt;p&gt;This post covers what is publicly known, why a valid signature tells you nothing about intent, and the controls I think platform teams should be building. It ends with a checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What happened
&lt;/h2&gt;

&lt;p&gt;Here is the public record so far. I've stuck to points confirmed by the sources linked below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The size.&lt;/strong&gt; Binance put the loss at about $6.3 billion, &lt;a href="https://www.investing.com/news/economy-news/binance-says-hackers-used-fake-job-interview-to-compromise-computer-before-63-billion-attack-4995603" rel="noopener noreferrer"&gt;per Reuters&lt;/a&gt;. Chainalysis has a slightly higher estimate of about $6.4 billion in its &lt;a href="https://www.chainalysis.com/blog/binance-exploit-latest-findings-and-response/" rel="noopener noreferrer"&gt;latest findings&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How they got in.&lt;/strong&gt; Binance's early finding pointed at one engineer's machine. Its November 3 report replaced that story with a broader one. As &lt;a href="https://www.coindesk.com/business/2026/11/03/binance-s-usd6-3-billion-hack-began-with-a-fake-job-interview-exchange-says" rel="noopener noreferrer"&gt;CoinDesk reports&lt;/a&gt;, the campaign was multi-stage and began in June. It used a trojanized tool download and a fake job interview. Two wallet developers' machines were compromised. The attackers then used stolen credentials to deploy a container that ran a malicious script. This was not one laptop and not one engineer's permissions. It was a patient campaign that moved through several layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The signatures were valid.&lt;/strong&gt; The transfers carried valid signatures (&lt;a href="https://www.coindesk.com/tech/2026/10/30/here-s-how-one-ethereum-permission-let-an-attacker-drain-usd2-5-billion-from-binance" rel="noopener noreferrer"&gt;CoinDesk&lt;/a&gt;, &lt;a href="https://www.chainalysis.com/blog/binance-exploit-latest-findings-and-response/" rel="noopener noreferrer"&gt;Chainalysis&lt;/a&gt;), and Binance says it has found no evidence that a private key leaked. &lt;a href="https://cointelegraph.com/news/binance-hack-security-ethereum-eip-7702-role" rel="noopener noreferrer"&gt;Cointelegraph's coverage&lt;/a&gt; frames it the same way: the keys were misused, not broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mechanism.&lt;/strong&gt; The attacker relied on EIP-7702 delegation. A hot wallet labelled "Binance 14" received 50 authorizations that delegated it to a sweeper contract, according to &lt;a href="https://www.coindesk.com/tech/2026/10/30/here-s-how-one-ethereum-permission-let-an-attacker-drain-usd2-5-billion-from-binance" rel="noopener noreferrer"&gt;CoinDesk's breakdown&lt;/a&gt; and &lt;a href="https://blockaid.io/blog/binance-55b-hack-using-advanced-ai-models-blockaid-detects-the-biggest-theft-in-crypto-history" rel="noopener noreferrer"&gt;Blockaid's analysis&lt;/a&gt;. EIP-7702 lets an externally owned account point at contract code. Once that delegation exists, the account behaves the way the delegated code says. One permission ended up controlling a lot of money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The freezes.&lt;/strong&gt; Stablecoin issuers moved fast. In early reporting, &lt;a href="https://www.cnbc.com/2026/10/29/circle-tether-freeze-funds-impacted-by-potential-binance-security-issue.html" rel="noopener noreferrer"&gt;CNBC&lt;/a&gt; said Tether had frozen more than $1.5 billion and Circle more than $800 million. Since then, &lt;a href="https://blockaid.io/blog/binance-55b-hack-using-advanced-ai-models-blockaid-detects-the-biggest-theft-in-crypto-history" rel="noopener noreferrer"&gt;Blockaid&lt;/a&gt; and Binance have put the total frozen across issuers at about $3 billion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attribution.&lt;/strong&gt; In an &lt;a href="https://www.fbi.gov/investigate/cyber/alerts/2026/north-korea-responsible-for-6-billion-binance-attack" rel="noopener noreferrer"&gt;October 31 alert&lt;/a&gt;, the FBI attributed the attack to North Korea's TraderTraitor, also known as Lazarus. &lt;a href="https://www.chainalysis.com/blog/binance-exploit-latest-findings-and-response/" rel="noopener noreferrer"&gt;Chainalysis&lt;/a&gt; agrees with that attribution. See also coverage from &lt;a href="https://apnews.com/article/binance-cryptocurrency-security-hack-north-korea-550e9b876550f71f07098494fe735beb" rel="noopener noreferrer"&gt;AP&lt;/a&gt; and &lt;a href="https://therecord.media/fbi-blames-tradertraitor-for-binance-hack" rel="noopener noreferrer"&gt;The Record&lt;/a&gt;. Fake job interviews and trojanized developer tools are standard tradecraft for that group, so the entry path fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why valid signatures aren't valid intent
&lt;/h2&gt;

&lt;p&gt;A signature proves one thing: whoever controls the key, or a system authorized to use it, approved these bytes. It doesn't tell you whether a human meant to approve them, whether the business would have approved them, or whether the code that requested the signature was the code you shipped.&lt;/p&gt;

&lt;p&gt;Most signing setups assume that anything able to reach the signer is trustworthy. That assumption holds only while the path to the signer stays clean. In this incident the path ran through a developer endpoint, then stolen credentials, then a container deploy, then a script, then the signer. All of those links are off-chain, and every one of them is something a platform team owns.&lt;/p&gt;

&lt;p&gt;I think of it like this. Cryptography authenticates. It doesn't authorize. Authorization lives in policy, and policy has to sit somewhere the attacker can't simply deploy around. If your only gate is "does this request come from a workload with access to the signer," the attacker's job becomes "get a workload with access to the signer." In practice that means stealing deploy credentials.&lt;/p&gt;

&lt;p&gt;EIP-7702 makes the point sharper. A delegation authorization is a signed message that can hand ongoing control of an account to contract code. If your policy treats it like any other low-value message, you have left a door open that is worth far more than any single transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Practical controls for platform teams
&lt;/h2&gt;

&lt;p&gt;None of what follows is new. That's my point. The incident used familiar paths, so the defenses are familiar too. What matters is whether they are actually enforced at the layers that count.&lt;/p&gt;

&lt;h3&gt;
  
  
  Endpoint hardening and EDR for engineers
&lt;/h3&gt;

&lt;p&gt;Engineers' machines are production-adjacent. They hold SSH keys, cloud sessions, kubeconfigs, and repository access. Treat them that way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run EDR on every engineering endpoint and actually staff the alert triage.&lt;/li&gt;
&lt;li&gt;Block or sandbox unsigned binaries and tools from unknown sources. A "tool download" sent during a "job interview" should never run on a machine that holds production credentials.&lt;/li&gt;
&lt;li&gt;Separate the device you browse and take calls on from the one you use for privileged access. Privileged access workstations, or at minimum isolated browser profiles and VMs, shrink the blast radius of a single click.&lt;/li&gt;
&lt;li&gt;Watch for credential-store access and odd token use on developer machines.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phishing-resistant MFA
&lt;/h3&gt;

&lt;p&gt;TOTP codes and push approvals get phished and fatigued. Use FIDO2/WebAuthn hardware keys or platform passkeys for SSO, cloud consoles, CI/CD, and anything else that can deploy. Bind sessions to the device where you can.&lt;/p&gt;

&lt;h3&gt;
  
  
  Short-lived credentials
&lt;/h3&gt;

&lt;p&gt;Stolen credentials only matter while they work. Swap long-lived keys for short-lived ones issued through OIDC federation, so CI gets cloud roles by workload identity, humans get just-in-time elevation, and tokens expire in minutes, not months. Static deploy credentials sitting on a developer laptop are an incident that hasn't been triggered yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  A signing-service policy engine
&lt;/h3&gt;

&lt;p&gt;This is the control I would put first for anyone holding keys. The signer should never be a dumb oracle. Put a policy engine in front of it that evaluates every request against limits and context before any key material is touched. It should run separately from the workloads that call it, with its own deploy path and its own approvers.&lt;/p&gt;

&lt;p&gt;Pseudo policy:&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;signing_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny&lt;/span&gt;

  &lt;span class="na"&gt;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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hot-wallet-withdrawals&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;wallet_tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hot&lt;/span&gt;
        &lt;span class="na"&gt;tx_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;transfer&lt;/span&gt;
      &lt;span class="na"&gt;require&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;destination_in_allowlist&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;amount_usd_max_per_tx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;250000&lt;/span&gt;
        &lt;span class="na"&gt;velocity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
          &lt;span class="na"&gt;amount_usd_max&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2000000&lt;/span&gt;
          &lt;span class="na"&gt;tx_count_max&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
        &lt;span class="na"&gt;caller_workload_identity_in&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;withdrawal-service-prod&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;caller_image_digest_in&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;approved_release_digests&lt;/span&gt;
      &lt;span class="na"&gt;on_violation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;block_and_page&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;delegation-and-permission-changes&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;tx_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;eip7702_authorization&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;approve&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;set_owner&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;upgrade&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;require&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;human_approvals&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;          &lt;span class="c1"&gt;# distinct people, distinct devices&lt;/span&gt;
        &lt;span class="na"&gt;approver_auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;webauthn&lt;/span&gt;
        &lt;span class="na"&gt;delegate_target_in_allowlist&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;change_ticket_linked&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;on_violation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;block_and_page&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;anomaly-circuit-breaker&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&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;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;outflow_usd_15m &amp;gt; 3 * baseline_p99&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;new_destinations_15m &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;halt_hot_wallet_signing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three ideas carry the weight. Default deny. Velocity limits that stop a drain partway even when each individual request looks fine. Treating permission and delegation changes, such as EIP-7702 authorizations, as high-risk operations that need multiple humans, never as routine traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Least privilege and admission control for container deploys
&lt;/h3&gt;

&lt;p&gt;In this incident, stolen credentials were enough to deploy a container running a malicious script. Admission control is where you make "can authenticate to the cluster" different from "can run arbitrary code next to the signer."&lt;/p&gt;

&lt;p&gt;Here is a Kyverno policy that only admits signed images from your registry into the signing namespace:&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;kyverno.io/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;ClusterPolicy&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;signer-namespace-signed-images-only&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;validationFailureAction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enforce&lt;/span&gt;
  &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;verify-image-signature&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&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;kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Pod&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;wallet-signing&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;verifyImages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;imageReferences&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;registry.example.com/wallet/*"&lt;/span&gt;
          &lt;span class="na"&gt;mutateDigest&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;verifyDigest&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;attestors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;entries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;keyless&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;issuer&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://token.actions.githubusercontent.com"&lt;/span&gt;
                    &lt;span class="na"&gt;subject&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://github.com/example-org/wallet-service/.github/workflows/release.yml@refs/heads/main"&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;block-other-registries&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&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;kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Pod&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;wallet-signing&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Only&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;images&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;registry.example.com/wallet&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;are&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;allowed."&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&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;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;registry.example.com/wallet/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ties what runs to an image built by the release pipeline from main. A human holding stolen kubeconfig can no longer push an ad-hoc image. Pair it with RBAC under which no human identity can create pods in that namespace directly, and with a ban on &lt;code&gt;kubectl exec&lt;/code&gt; into signer workloads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blast-radius segmentation
&lt;/h3&gt;

&lt;p&gt;Keep hot wallets small. Put signing infrastructure in its own cluster or account with its own identity provider boundaries. Don't let the network path from general workloads to the signer exist at all. Cap hot wallet balances so that the worst case is a bad day and not an existential one. Rebalancing from cold storage should be slow, deliberate, and reviewed by humans.&lt;/p&gt;

&lt;h3&gt;
  
  
  Detection on anomalous deploys and withdrawals
&lt;/h3&gt;

&lt;p&gt;Your detections should cover both sides of the off-chain path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deploys:&lt;/strong&gt; a new image digest in a sensitive namespace, a deploy from outside the pipeline, a deploy by a human identity, a deploy at an unusual hour, or a new service account token mounted somewhere unexpected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Withdrawals:&lt;/strong&gt; outflow above baseline, a spike in new destinations, any delegation or approval transaction, and patterns that look like a sweep.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wire these to automatic circuit breakers, not only pages. A detection that waits for someone to wake up is too slow when money moves in minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] EDR on every engineering endpoint, with alerts triaged by a named owner&lt;/li&gt;
&lt;li&gt;[ ] Policy against running unvetted tools on machines holding production credentials&lt;/li&gt;
&lt;li&gt;[ ] Privileged access separated from everyday browsing and calls&lt;/li&gt;
&lt;li&gt;[ ] Phishing-resistant MFA (FIDO2/passkeys) for SSO, cloud, CI/CD, and cluster access&lt;/li&gt;
&lt;li&gt;[ ] No long-lived deploy credentials; OIDC federation and just-in-time elevation&lt;/li&gt;
&lt;li&gt;[ ] Signing service behind a default-deny policy engine&lt;/li&gt;
&lt;li&gt;[ ] Per-transaction limits, destination allowlists, and velocity caps&lt;/li&gt;
&lt;li&gt;[ ] Delegation and permission changes (including EIP-7702) require multi-party approval&lt;/li&gt;
&lt;li&gt;[ ] Admission control enforcing signed images from the release pipeline in signer namespaces&lt;/li&gt;
&lt;li&gt;[ ] No direct human pod creation or exec in signer namespaces&lt;/li&gt;
&lt;li&gt;[ ] Signing infrastructure segmented in its own cluster or account&lt;/li&gt;
&lt;li&gt;[ ] Hot wallet balances capped; cold-storage refills reviewed by humans&lt;/li&gt;
&lt;li&gt;[ ] Detections for out-of-pipeline deploys and new digests in sensitive namespaces&lt;/li&gt;
&lt;li&gt;[ ] Detections for withdrawal anomalies, with automatic signing halt&lt;/li&gt;
&lt;li&gt;[ ] Incident runbook covering stablecoin issuer and partner freeze contacts&lt;/li&gt;
&lt;li&gt;[ ] Hiring and interview processes covered in security awareness (fake recruiters are a known vector)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;I don't read this incident as a story about blockchains failing. The chain was the one component that behaved exactly as specified. It's a story about trust that leaked from a developer's machine into credentials, from credentials into a deploy, and from a deploy into a signer that didn't ask enough questions. Those layers belong to platform engineering. If you own them, the job is to make sure that something being signed is never enough on its own to show that it was intended.&lt;/p&gt;

&lt;p&gt;I wrote shorter takes on &lt;a href="https://www.linkedin.com/feed/update/urn:li:activity:7523813676802494464/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; and &lt;a href="https://x.com/nrejve/status/2118050974463934731" rel="noopener noreferrer"&gt;X&lt;/a&gt;. More of my writing is on &lt;a href="https://nazmul-ahasan.pro.bd/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I lead platform engineering for fintech. See more of my work or how we can work together at &lt;a href="https://nazmul-ahasan.pro.bd/work-with-me/" rel="noopener noreferrer"&gt;Nazmul Ahasan&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.fbi.gov/investigate/cyber/alerts/2026/north-korea-responsible-for-6-billion-binance-attack" rel="noopener noreferrer"&gt;FBI alert: North Korea responsible for $6 billion Binance attack (Oct 31, 2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://therecord.media/fbi-blames-tradertraitor-for-binance-hack" rel="noopener noreferrer"&gt;The Record: FBI blames TraderTraitor for Binance hack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apnews.com/article/binance-cryptocurrency-security-hack-north-korea-550e9b876550f71f07098494fe735beb" rel="noopener noreferrer"&gt;AP: Binance security hack, North Korea&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blockaid.io/blog/binance-55b-hack-using-advanced-ai-models-blockaid-detects-the-biggest-theft-in-crypto-history" rel="noopener noreferrer"&gt;Blockaid: Binance hack analysis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.chainalysis.com/blog/binance-exploit-latest-findings-and-response/" rel="noopener noreferrer"&gt;Chainalysis: Binance exploit latest findings and response&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.investing.com/news/economy-news/binance-says-hackers-used-fake-job-interview-to-compromise-computer-before-63-billion-attack-4995603" rel="noopener noreferrer"&gt;Reuters via Investing.com: Binance says hackers used fake job interview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.coindesk.com/business/2026/11/03/binance-s-usd6-3-billion-hack-began-with-a-fake-job-interview-exchange-says" rel="noopener noreferrer"&gt;CoinDesk, Nov 3: Binance's $6.3 billion hack began with a fake job interview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.coindesk.com/tech/2026/10/30/here-s-how-one-ethereum-permission-let-an-attacker-drain-usd2-5-billion-from-binance" rel="noopener noreferrer"&gt;CoinDesk, Oct 30: How one Ethereum permission let an attacker drain Binance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cointelegraph.com/news/binance-hack-security-ethereum-eip-7702-role" rel="noopener noreferrer"&gt;Cointelegraph: EIP-7702's role in the Binance hack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.cnbc.com/2026/10/29/circle-tether-freeze-funds-impacted-by-potential-binance-security-issue.html" rel="noopener noreferrer"&gt;CNBC: Circle and Tether freeze funds&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devsecops</category>
      <category>security</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Forward Deployed Engineer challenge: I built a company ERP portal in 2 days with AI</title>
      <dc:creator>Nazmul Ahasan</dc:creator>
      <pubDate>Wed, 21 Oct 2026 19:09:43 +0000</pubDate>
      <link>https://dev.to/nrejve/forward-deployed-engineer-challenge-i-built-a-company-erp-portal-in-2-days-with-ai-2afc</link>
      <guid>https://dev.to/nrejve/forward-deployed-engineer-challenge-i-built-a-company-erp-portal-in-2-days-with-ai-2afc</guid>
      <description>&lt;h1&gt;
  
  
  Forward Deployed Engineer challenge: I built a company ERP portal in 2 days with AI
&lt;/h1&gt;

&lt;p&gt;A company posted a Forward Deployed Engineer (FDE) job ad with a take-home brief: build an internal operations portal in three working days. I took the challenge and shipped it alone in two, with AI doing much of the typing. The company took the ad down and adopted the portal. Today about 700 employees use it.&lt;/p&gt;

&lt;p&gt;I'm Nazmul Ahasan. I run Platform Engineering as a Senior Engineering Manager, and I write about this kind of work on my site, &lt;a href="https://nazmul-ahasan.pro.bd/" rel="noopener noreferrer"&gt;Nazmul Ahasan&lt;/a&gt;. To be clear up front: I didn't hold the FDE title. I took the FDE challenge. This post covers what I built, how I'd use AI on a build like this, what you must check by hand, and the part I think matters most: the approval engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Forward Deployed Engineer challenge asked for
&lt;/h2&gt;

&lt;p&gt;The brief described an internal ops/ERP portal. I called it OpsFlow (the full write-up is in the &lt;a href="https://nazmul-ahasan.pro.bd/case-studies/opsflow-internal-ops-portal.html" rel="noopener noreferrer"&gt;OpsFlow case study&lt;/a&gt;). The scope I delivered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-step approvals, driven by config rather than hard-coded flows&lt;/li&gt;
&lt;li&gt;Leave requests: employee → line manager → HR → automatic email → leave balance deducted&lt;/li&gt;
&lt;li&gt;Salary certificates&lt;/li&gt;
&lt;li&gt;Payroll&lt;/li&gt;
&lt;li&gt;Advance salary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The stack was boring on purpose: Next.js, Prisma and PostgreSQL, running on AWS EC2. Boring is what you want when you have two days. Every tool in that list is one I could debug without a search engine, which matters when the AI gets something confidently wrong.&lt;/p&gt;

&lt;p&gt;That's the real point of an FDE-style brief, I think. It isn't asking whether you can write code. It's asking whether you can sit with a messy business problem, pick a shape for it fast, and put something real in front of users. AI changes how fast you can write code. It doesn't change who is responsible for the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd use AI on a build like this
&lt;/h2&gt;

&lt;p&gt;I used AI coding assistants throughout. The way I think about them: a fast, tireless junior engineer with zero context about the business and a habit of sounding certain. That framing decides everything else.&lt;/p&gt;

&lt;p&gt;Where AI earns its place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scaffolding.&lt;/strong&gt; Next.js pages, forms, table views, Prisma model boilerplate, API route shells. High volume, low risk, and where most of two days would otherwise go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repetitive variations.&lt;/strong&gt; Once one approval flow works, the next ones are mostly "same pattern, different fields and steps". AI is very good at following a pattern you've already set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rubber-ducking the design.&lt;/strong&gt; Talking through the data model before writing it surfaces awkward cases early, like what happens when the approver is also the requester.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prompting workflow I'd insist on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Design first, by hand.&lt;/strong&gt; Write the data model and the workflow config format yourself. Everything else depends on them, and they're small enough to think through properly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the model the contract.&lt;/strong&gt; Paste the schema and types into the prompt and ask for code that fits them, instead of asking for "an approval system" and getting a generic one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small, reviewable chunks.&lt;/strong&gt; One route, one component, one function at a time. If you can't review a diff in a couple of minutes, the request was too big.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never accept code you can't explain.&lt;/strong&gt; If the output uses something you don't understand, learn it or rewrite it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trap with AI-assisted speed is that you can generate code faster than you can understand it. The fix isn't to slow the AI down. It's to keep the parts that need real thought small, and keep them in your own hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts you must check by hand
&lt;/h2&gt;

&lt;p&gt;Some code is cheap to get wrong. A misaligned table is a bug report. A wrong payroll calculation breaks trust with hundreds of people. Split the codebase along that line. This is the checklist I'd hold any AI-assisted internal tool to:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth and RBAC.&lt;/strong&gt; Every server action and API route that changes state must check who the user is and what role they hold, on the server. AI-generated code tends to put the check in the UI (hide the button) and forget the endpoint. Read every mutation path for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payroll and salary math.&lt;/strong&gt; Money logic gets read line by line: rounding, deductions, advance salary recovery, partial months. Check worked examples against what HR already calculates, not against what the model says is correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrations.&lt;/strong&gt; Prisma makes schema changes easy, which is exactly why they need reading. Every generated migration gets a human look before it touches a real database, especially anything that drops or renames a column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approval edge cases.&lt;/strong&gt; Double clicks. Two approvers acting at once. An approver acting on a step that has already moved on. A requester approving their own request. A rejected request being approved later. These are the bugs that make people stop trusting a system, and AI-generated happy-path code misses them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing.&lt;/strong&gt; The highest-value tests are on the state-transition function, because every workflow goes through it. After that, walk each flow end to end as each role.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approval engine design
&lt;/h2&gt;

&lt;p&gt;This is the part I'd defend in a design review. Leave, salary certificates and advance salary all have the same shape: someone asks, a sequence of people approve or reject, and when it's done, something happens. So I built one engine and made each flow a piece of config.&lt;/p&gt;

&lt;p&gt;The model has four parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ApprovalWorkflow&lt;/strong&gt;: a named workflow, such as "leave".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ApprovalStep&lt;/strong&gt;: an ordered step in a workflow, with the role that's allowed to act on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ApprovalRequest&lt;/strong&gt;: one instance, such as one employee's leave request, with its current step and status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ApprovalAction&lt;/strong&gt;: an append-only record of who did what, and when.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Prisma schema sketch
&lt;/h3&gt;

&lt;p&gt;This is a sanitized sketch, not the production schema.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;enum RequestStatus {
  PENDING
  APPROVED
  REJECTED
  CANCELLED
}

enum ActionType {
  APPROVE
  REJECT
}

model ApprovalWorkflow {
  id        String            @id @default(cuid())
  key       String            @unique // e.g. "leave"
  name      String
  steps     ApprovalStep[]
  requests  ApprovalRequest[]
  createdAt DateTime          @default(now())
}

model ApprovalStep {
  id         String           @id @default(cuid())
  workflowId String
  workflow   ApprovalWorkflow @relation(fields: [workflowId], references: [id])
  order      Int
  key        String           // e.g. "line_manager"
  role       String           // role allowed to act on this step

  @@unique([workflowId, order])
}

model ApprovalRequest {
  id          String           @id @default(cuid())
  workflowId  String
  workflow    ApprovalWorkflow @relation(fields: [workflowId], references: [id])
  requesterId String
  currentStep Int              @default(0)
  status      RequestStatus    @default(PENDING)
  payload     Json             // flow-specific data, e.g. leave dates
  version     Int              @default(0) // optimistic concurrency
  actions     ApprovalAction[]
  createdAt   DateTime         @default(now())
  updatedAt   DateTime         @updatedAt
}

model ApprovalAction {
  id             String          @id @default(cuid())
  requestId      String
  request        ApprovalRequest @relation(fields: [requestId], references: [id])
  stepOrder      Int
  actorId        String
  type           ActionType
  comment        String?
  idempotencyKey String          @unique
  createdAt      DateTime        @default(now())

  @@unique([requestId, stepOrder]) // one decision per step
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two constraints do a lot of quiet work here. &lt;code&gt;idempotencyKey&lt;/code&gt; is unique, so a retried request can't record a second action. And &lt;code&gt;@@unique([requestId, stepOrder])&lt;/code&gt; means a step can only be decided once, whatever the application code does.&lt;/p&gt;

&lt;h3&gt;
  
  
  A config-driven workflow definition
&lt;/h3&gt;

&lt;p&gt;The steps live in the database so they're inspectable, but the behaviour lives in typed config. Adding a workflow means adding a definition, not writing a new state machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EMPLOYEE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LINE_MANAGER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FINANCE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;StepDef&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;key&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="nl"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CompletionContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;P&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TransactionClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Prisma transaction client&lt;/span&gt;
  &lt;span class="nl"&gt;requestId&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="nl"&gt;requesterId&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="nl"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;P&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;enqueueEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;to&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="nl"&gt;template&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="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;WorkflowDef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;P&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;key&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="nl"&gt;name&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="nl"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StepDef&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;P&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;onComplete&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CompletionContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;P&lt;/span&gt;&lt;span class="o"&gt;&amp;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;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LeavePayload&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;leaveType&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="nl"&gt;startDate&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="nl"&gt;endDate&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="nl"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&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;leaveWorkflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorkflowDef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;LeavePayload&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;leave&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;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;Leave request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;steps&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="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;line_manager&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LINE_MANAGER&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HR&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;validate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;parseLeavePayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// throws on bad input&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onComplete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requesterId&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;enqueueEmail&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leaveBalance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;employeeId_leaveType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;employeeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requesterId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;leaveType&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;leaveType&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decrement&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;days&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;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requesterId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;leave-approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;startDate&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;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;endDate&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;endDate&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 employee is the implicit first step: creating the request is their action. After that it's line manager, then HR, then the completion hook sends the email and deducts the balance.&lt;/p&gt;

&lt;p&gt;This is the way I'd design it: the balance deduction runs inside the same transaction as the final approval, so if the deduction fails, the approval doesn't happen. A request marked "approved" with an untouched balance is worse than a clear error. And the email should be queued rather than sent inline, because you can't roll back an email. That's the design that holds up.&lt;/p&gt;

&lt;h3&gt;
  
  
  The state-transition function
&lt;/h3&gt;

&lt;p&gt;Every approve and reject goes through one function. Role check, step check, idempotency and concurrency all live here, inside a transaction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Prisma&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PrismaClient&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="s2"&gt;@prisma/client&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;prisma&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;PrismaClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;APPROVE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;REJECT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;decide&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;requestId&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="nl"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&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="nl"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;idempotencyKey&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="nl"&gt;comment&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;comment&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$transaction&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="nx"&gt;tx&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="c1"&gt;// 1. Idempotency: same key, same answer.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&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;existing&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUniqueOrThrow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestId&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;request&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUniqueOrThrow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;workflow&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="k"&gt;if &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;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PENDING&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;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="s2"&gt;Request is no longer pending&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;def&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getWorkflowDef&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;workflow&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&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;currentStep&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;step&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="s2"&gt;No active step&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// 2. Role and separation-of-duties checks, on the server.&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;actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&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="s2"&gt;Not allowed to act on this step&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&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;requesterId&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="s2"&gt;Cannot approve your own request&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="c1"&gt;// 3. Record the decision. Unique constraints back this up.&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;stepOrder&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;currentStep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;actorId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;actor&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;idempotencyKey&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;isLastStep&lt;/span&gt; &lt;span class="o"&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;currentStep&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;REJECT&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;REJECTED&lt;/span&gt;&lt;span class="dl"&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="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isLastStep&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;APPROVED&lt;/span&gt;&lt;span class="dl"&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="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;currentStep&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;currentStep&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="c1"&gt;// 4. Optimistic concurrency: only update the version we read.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updated&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&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;version&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;data&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;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;increment&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="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;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&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="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="s2"&gt;Request changed, please retry&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="c1"&gt;// 5. Side effects run in the same transaction.&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;decision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;APPROVE&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="nx"&gt;isLastStep&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onComplete&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;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onComplete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;requesterId&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;requesterId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&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;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="na"&gt;enqueueEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&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;enqueueEmailInTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&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;return&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUniqueOrThrow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestId&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="na"&gt;isolationLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TransactionIsolationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Serializable&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;A few choices worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Belt and braces.&lt;/strong&gt; The idempotency lookup handles the polite retry. The unique constraints handle the impolite race. The version check catches two approvers acting on the same step at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separation of duties is explicit.&lt;/strong&gt; "Can't approve your own request" is one line. It's also exactly the line AI-generated code tends to leave out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit by construction.&lt;/strong&gt; &lt;code&gt;ApprovalAction&lt;/code&gt; is append-only, so "who approved this and when" is a query, not an investigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One place to test.&lt;/strong&gt; Leave, salary certificates and advance salary all go through &lt;code&gt;decide&lt;/code&gt;. Get it right once and every flow benefits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ideas for hardening it further down the line: delegation when an approver is on leave, escalation for stuck requests, and versioning workflow definitions so in-flight requests keep the rules they started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying it
&lt;/h2&gt;

&lt;p&gt;The portal runs on AWS EC2 with PostgreSQL behind it. For a short build, my bias is the simplest deploy that's repeatable, and here's what I'd insist on: one build command, migrations applied as an explicit step and not on app boot, a database backup before any migration, TLS in front, and a process manager that restarts the app if it dies. Platform engineering habits don't switch off because the deadline is short. If anything, they matter more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;AI made the two-day timeline possible. It didn't make the product trustworthy. The things that made people willing to use it, with the right people approving, correct balances and correct money, came from deciding which parts got careful human attention.&lt;/p&gt;

&lt;p&gt;If you're looking at a Forward Deployed Engineer role, or any role where you build directly for the business, this is the skill I'd practise. Not prompting. Judgement about where AI output is good enough and where it has to be proven.&lt;/p&gt;

&lt;p&gt;The company took the job ad down and adopted the portal. About 700 people use it. That's the outcome I care about more than the two days.&lt;/p&gt;

&lt;p&gt;I lead platform engineering for fintech. Read the full case study &lt;a href="https://nazmul-ahasan.pro.bd/case-studies/opsflow-internal-ops-portal.html" rel="noopener noreferrer"&gt;here&lt;/a&gt; or see how we can work together at &lt;a href="https://nazmul-ahasan.pro.bd/work-with-me/" rel="noopener noreferrer"&gt;Nazmul Ahasan&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Next.js basePath 404 Nginx: Running Two Next.js Apps on One Domain</title>
      <dc:creator>Nazmul Ahasan</dc:creator>
      <pubDate>Wed, 21 Oct 2026 19:09:39 +0000</pubDate>
      <link>https://dev.to/nrejve/nextjs-basepath-404-nginx-running-two-nextjs-apps-on-one-domain-480h</link>
      <guid>https://dev.to/nrejve/nextjs-basepath-404-nginx-running-two-nextjs-apps-on-one-domain-480h</guid>
      <description>&lt;h1&gt;
  
  
  Next.js basePath 404 Nginx: Running Two Next.js Apps on One Domain
&lt;/h1&gt;

&lt;p&gt;If you've ever hit a Next.js basePath 404 behind Nginx, you already know the feeling: the HTML loads, the page looks half-alive, and every script and stylesheet comes back 404. I ran into that, and three other problems, while getting two Next.js apps to live under one domain behind a single Nginx server. This post covers all four, what was actually happening in each case, and the config I'd use now.&lt;/p&gt;

&lt;p&gt;Some context. I'm Nazmul Ahasan, a Senior Engineering Manager in Platform Engineering. I write about this kind of thing on my site, &lt;a href="https://nazmul-ahasan.pro.bd/" rel="noopener noreferrer"&gt;Nazmul Ahasan&lt;/a&gt;. At FundedNext I picked up the "Help Me Choose My Plan" AI wizard when it was an MVP about 10% done and took it to about 90%. It's live in production now, and roughly $1M a month in plan purchases go through it. That's why I care about the plumbing. When that much checkout traffic goes through a page, a broken asset path is not a cosmetic bug.&lt;/p&gt;

&lt;p&gt;The wizard is one of two Next.js apps behind one Nginx domain. Everything below uses generic examples: &lt;code&gt;example.com&lt;/code&gt;, an app at &lt;code&gt;/app-a&lt;/code&gt;, the wizard at &lt;code&gt;/wizard&lt;/code&gt;, and the loopback address &lt;code&gt;127.0.0.1&lt;/code&gt;, and upstream ports 3000 and 3001. &lt;strong&gt;Those are placeholders, not our real setup.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: two Next.js apps, one Nginx
&lt;/h2&gt;

&lt;p&gt;The shape is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;https://example.com/app-a/...&lt;/code&gt; → Next.js app A on &lt;code&gt;127.0.0.1:3000&lt;/code&gt; (example port)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;https://example.com/wizard/...&lt;/code&gt; → the wizard on &lt;code&gt;127.0.0.1:3001&lt;/code&gt; (example port)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both apps are built and run on their own. Nginx owns the domain and routes by path prefix. On paper it's a ten-line config. In practice, every one of my four war stories comes from the gap between "Nginx routes by prefix" and "Next.js assumes it owns the root."&lt;/p&gt;

&lt;h2&gt;
  
  
  War story 1: the Next.js basePath 404 behind Nginx
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What's actually going on
&lt;/h3&gt;

&lt;p&gt;By default a Next.js app thinks it lives at &lt;code&gt;/&lt;/code&gt;. Its HTML points at assets like &lt;code&gt;/_next/static/chunks/main-abc123.js&lt;/code&gt;. Put two apps on one domain and both of them want &lt;code&gt;/_next/static&lt;/code&gt;. Nginx can only send that path to one upstream, so the other app's assets 404. Or worse, they get answered by the wrong app.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;basePath&lt;/code&gt; fixes this by making Next.js own its prefix completely. Pages, links, and static assets all move under it, so the wizard's assets become &lt;code&gt;/wizard/_next/static/...&lt;/code&gt; and stop colliding with app A.&lt;/p&gt;

&lt;p&gt;I hit a basePath 404 on the wizard. The symptom of this class of bug is consistent: the HTML loads, then requests under &lt;code&gt;/_next/static&lt;/code&gt; fail, so the page shows up unstyled and dead. The usual culprit is one of three things. &lt;code&gt;basePath&lt;/code&gt; isn't set, &lt;code&gt;basePath&lt;/code&gt; doesn't match the Nginx location, or Nginx strips the prefix before the request reaches the app. The check I'd run first is the Network tab. Take the exact URL of a failing chunk and ask whether it carries the prefix the app was built with.&lt;/p&gt;

&lt;h3&gt;
  
  
  The next.config.js
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js for the wizard app (example values)&lt;/span&gt;
&lt;span class="cm"&gt;/** @type {import('next').NextConfig} */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Every route and every /_next asset now lives under /wizard.&lt;/span&gt;
  &lt;span class="na"&gt;basePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/wizard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;// Leave assetPrefix alone unless static assets come from a different&lt;/span&gt;
  &lt;span class="c1"&gt;// origin (a CDN, say). basePath already prefixes /_next/static.&lt;/span&gt;
  &lt;span class="c1"&gt;// assetPrefix: 'https://cdn.example.com/wizard',&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;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;App A gets &lt;code&gt;basePath: '/app-a'&lt;/code&gt; the same way.&lt;/p&gt;

&lt;p&gt;Two things trip people up here. First, &lt;code&gt;basePath&lt;/code&gt; is applied at &lt;strong&gt;build time&lt;/strong&gt;. Change it and you have to rebuild; restarting the server isn't enough. Second, &lt;code&gt;basePath&lt;/code&gt; and &lt;code&gt;assetPrefix&lt;/code&gt; are different jobs. &lt;code&gt;basePath&lt;/code&gt; moves the whole app. &lt;code&gt;assetPrefix&lt;/code&gt; only changes where the browser fetches &lt;code&gt;/_next/static&lt;/code&gt; from. I've seen people set both to &lt;code&gt;/wizard&lt;/code&gt; and wonder why asset URLs come out wrong. If you serve everything from the same origin, &lt;code&gt;basePath&lt;/code&gt; alone is enough.&lt;/p&gt;

&lt;p&gt;Also, hardcoded paths don't get rewritten for you. &lt;code&gt;next/link&lt;/code&gt; and &lt;code&gt;next/image&lt;/code&gt; respect &lt;code&gt;basePath&lt;/code&gt;. A raw &lt;code&gt;&amp;lt;img src="/logo.png"&amp;gt;&lt;/code&gt; or &lt;code&gt;fetch('/api/plans')&lt;/code&gt; does not. That's a quieter version of the same 404.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Nginx side
&lt;/h3&gt;

&lt;p&gt;This is where the classic bug lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# WRONG: the trailing slash on proxy_pass strips /wizard/ from the request&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/wizard/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3001/&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;When &lt;code&gt;proxy_pass&lt;/code&gt; includes a URI (even just &lt;code&gt;/&lt;/code&gt;), Nginx replaces the matched location prefix with it. A request for &lt;code&gt;/wizard/_next/static/chunks/x.js&lt;/code&gt; reaches Next.js as &lt;code&gt;/_next/static/chunks/x.js&lt;/code&gt;. But the app was built with &lt;code&gt;basePath: '/wizard'&lt;/code&gt; and expects the prefix, so it returns 404. Leave the URI off and Nginx forwards the path untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Example config: example.com, ports 3000/3001 are placeholders&lt;/span&gt;
&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;app_a&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;wizard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt;              &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt;         &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;   &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# --- Wizard (basePath: '/wizard') ---&lt;/span&gt;
    &lt;span class="c1"&gt;# Hashed build assets: safe to cache forever.&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/wizard/_next/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://wizard&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;# no URI: keep /wizard in the path&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_hide_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;max-age=31536000,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/wizard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="n"&gt;/wizard/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/wizard/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://wizard&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_hide_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"no-store"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# --- App A (basePath: '/app-a') ---&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app-a/_next/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://app_a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_hide_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;max-age=31536000,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/app-a&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="n"&gt;/app-a/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app-a/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://app_a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_hide_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"no-store"&lt;/span&gt; &lt;span class="s"&gt;always&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 rule I follow now: &lt;strong&gt;the path Nginx forwards must still contain the basePath.&lt;/strong&gt; If you really want Nginx to strip the prefix, then don't use &lt;code&gt;basePath&lt;/code&gt;. Pick one place to own the prefix, not both.&lt;/p&gt;

&lt;p&gt;One caveat on the &lt;code&gt;/_next/static&lt;/code&gt; blocks. Next.js already sends &lt;code&gt;immutable&lt;/code&gt; for those files in production, so the explicit header is belt and braces. I keep it so the intent is visible in Nginx.&lt;/p&gt;

&lt;h2&gt;
  
  
  War story 2: Next.js hydration not working in production
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What a corrupt build looks like
&lt;/h3&gt;

&lt;p&gt;This one is nastier than a 404 because the page &lt;em&gt;looks&lt;/em&gt; fine. Server-rendered HTML arrives, the layout is right, the text is right. Then nothing responds. Buttons and inputs do nothing. The client never took over.&lt;/p&gt;

&lt;p&gt;Hydration is React attaching event handlers and state to HTML the server already rendered. To do that, the browser needs the exact JavaScript chunks that build produced. If the HTML comes from one build and the chunks come from another, or the chunks on disk are incomplete, hydration fails. Common ways to get there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A build interrupted mid-write, leaving &lt;code&gt;.next&lt;/code&gt; partly stale.&lt;/li&gt;
&lt;li&gt;An incremental build reusing a cached &lt;code&gt;.next&lt;/code&gt; that doesn't match the current source or dependencies.&lt;/li&gt;
&lt;li&gt;Rolling or multi-instance deploys where instances run different builds, so HTML from instance one asks for chunks that only instance two has.&lt;/li&gt;
&lt;li&gt;Stale HTML cached somewhere pointing at chunks that no longer exist (more on that in story 3).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I hit this with a corrupt build. The usual culprit is a stale or partial &lt;code&gt;.next&lt;/code&gt; directory, and the typical first symptom is a page that renders but ignores every click.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to spot it
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open the console first.&lt;/strong&gt; Look for &lt;code&gt;ChunkLoadError&lt;/code&gt;, &lt;code&gt;Loading chunk ... failed&lt;/code&gt;, 404s on &lt;code&gt;/_next/static/chunks/*.js&lt;/code&gt; in the Network tab, or React hydration errors such as "Hydration failed because the initial UI does not match what was rendered on the server." A page that renders but has no interactivity and &lt;em&gt;no&lt;/em&gt; errors is rare. Usually something is shouting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare build IDs.&lt;/strong&gt; Every Next.js build gets an ID, written to &lt;code&gt;.next/BUILD_ID&lt;/code&gt; on the server. With the Pages Router you can read the one the HTML was rendered with from the page:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;   &lt;span class="c1"&gt;// In the browser console (Pages Router)&lt;/span&gt;
   &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__NEXT_DATA__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buildId&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then on each server instance:&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;cat&lt;/span&gt; .next/BUILD_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If they differ, or differ between instances behind the same upstream, you've found your mismatch. With the App Router there's no &lt;code&gt;__NEXT_DATA__&lt;/code&gt;. Compare the hashed chunk URLs in the HTML source against what's actually in &lt;code&gt;.next/static&lt;/code&gt; on the server.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do a clean rebuild.&lt;/strong&gt; Don't trust the cache when you're debugging this:
&lt;/li&gt;
&lt;/ol&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; &lt;span class="nt"&gt;-rf&lt;/span&gt; .next
   npm ci
   npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a clean build fixes it, the problem was the build artifact, not your code.&lt;/p&gt;

&lt;p&gt;What fixes it, nearly every time, is the clean rebuild.&lt;/p&gt;

&lt;p&gt;My opinion: make clean builds the default in CI, and make sure every instance serving a basePath runs the same &lt;code&gt;BUILD_ID&lt;/code&gt;. Build caching speeds things up until it hands you a production incident that costs more than all the minutes it ever saved.&lt;/p&gt;

&lt;h2&gt;
  
  
  War story 3: HTML cached for a year
&lt;/h2&gt;

&lt;p&gt;I hit this one too: HTML getting cached for a year. Here's how it usually happens. Someone writes one broad caching rule like "cache everything under this prefix for a year", because the static assets deserve it. But HTML also lives under that prefix. Now the browser, or any cache in between, holds a year-old HTML document. That document points at hashed chunk filenames from a build that's long gone.&lt;/p&gt;

&lt;p&gt;Hashed assets and HTML have opposite caching needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/_next/static/*&lt;/code&gt; filenames contain a content hash. A new build means new filenames, so the old file never changes. &lt;code&gt;max-age=31536000, immutable&lt;/code&gt; is correct.&lt;/li&gt;
&lt;li&gt;HTML has a stable URL (&lt;code&gt;/wizard/&lt;/code&gt;) but its contents change with every deploy. Cache it for a year and you pin users to an old build. That old build's chunks may 404 after a deploy, and you're back in story 2: a page that renders and doesn't hydrate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I switched the HTML to &lt;code&gt;no-store&lt;/code&gt;. &lt;code&gt;no-cache&lt;/code&gt; is also valid. It lets caches store the response but forces revalidation every time. For a checkout-adjacent flow I'd rather pay for a fresh render than reason about revalidation behavior across every proxy in the path.&lt;/p&gt;

&lt;p&gt;The usual culprit is a single Nginx &lt;code&gt;location&lt;/code&gt; or CDN rule that applies a long &lt;code&gt;max-age&lt;/code&gt; to a whole path prefix. The check I'd run first is &lt;code&gt;curl -I&lt;/code&gt; on the HTML URL itself, not on an asset.&lt;/p&gt;

&lt;h3&gt;
  
  
  Next.js headers() vs Nginx
&lt;/h3&gt;

&lt;p&gt;You might reach for &lt;code&gt;headers()&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js — fine for most custom headers&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;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;basePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/wizard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Frame-Options&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DENY&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="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;That works for headers like &lt;code&gt;X-Frame-Options&lt;/code&gt;, but be careful with &lt;code&gt;Cache-Control&lt;/code&gt;. Next.js docs warn that &lt;code&gt;Cache-Control&lt;/code&gt; values set this way can be overwritten in production for pages and static assets. Next manages those itself. For dynamically rendered pages Next already sends &lt;code&gt;private, no-cache, no-store, max-age=0, must-revalidate&lt;/code&gt;. For static or ISR pages it sends &lt;code&gt;s-maxage&lt;/code&gt; values tied to revalidation. So for a hard guarantee I enforce &lt;code&gt;Cache-Control&lt;/code&gt; at Nginx, as in the config above: &lt;code&gt;proxy_hide_header Cache-Control&lt;/code&gt; to drop the upstream value, then &lt;code&gt;add_header ... always&lt;/code&gt; so it also applies to error responses.&lt;/p&gt;

&lt;p&gt;One Nginx gotcha worth knowing: &lt;code&gt;add_header&lt;/code&gt; directives are inherited from the parent block &lt;strong&gt;only if the child block defines none of its own&lt;/strong&gt;. Add a single &lt;code&gt;add_header&lt;/code&gt; inside a &lt;code&gt;location&lt;/code&gt;, and every server-level &lt;code&gt;add_header&lt;/code&gt; (security headers included) silently disappears for that location. Repeat them, or put them in an &lt;code&gt;include&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;Then verify, don't assume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/wizard/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cache-control
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/wizard/_next/static/chunks/&amp;lt;some-chunk&amp;gt;.js | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cache-control
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  War story 4: resetting wizard state with a key
&lt;/h2&gt;

&lt;p&gt;A multi-step wizard holds a lot of local state: current step, answers, derived recommendations. At some point you need a real "start over". The instinct is to write a &lt;code&gt;reset()&lt;/code&gt; that sets every piece of state back to its initial value. That works until someone adds a new &lt;code&gt;useState&lt;/code&gt; and forgets to add it to &lt;code&gt;reset()&lt;/code&gt;. Now "start over" leaves one stale field behind.&lt;/p&gt;

&lt;p&gt;React already has a cleaner tool. A component's identity is its position in the tree plus its &lt;code&gt;key&lt;/code&gt;. Change the &lt;code&gt;key&lt;/code&gt; and React treats it as a different component. It unmounts the old instance, throwing away all its state, effects, and refs, and mounts a fresh one with initial state everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&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;useState&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;react&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;PlanWizard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;onRestart&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStep&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setAnswers&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt;
  &lt;span class="c1"&gt;// ...every other piece of state, no manual reset needed&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* steps... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onRestart&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Start over&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;WizardPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRunId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="c1"&gt;// Bumping the key remounts PlanWizard with completely fresh state.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PlanWizard&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onRestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;setRunId&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&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;n&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="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;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 resets everything inside &lt;code&gt;PlanWizard&lt;/code&gt;, including state you'll add next year. The trade-offs are worth knowing. Effects run cleanup and fire again on remount, so any fetch on mount happens again. State that lives &lt;em&gt;outside&lt;/em&gt; the component (a context above it, a global store, &lt;code&gt;localStorage&lt;/code&gt;, URL query params) is not reset by a key change. You still have to clear that yourself.&lt;/p&gt;

&lt;p&gt;That's how I reset the wizard's state: a new key, a clean remount. The usual trigger for this pattern is an explicit "start over", or a change of context that makes the previous answers meaningless. If you use it, check whether anything outside the component also holds wizard state, and clear it in the same handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself before starting
&lt;/h2&gt;

&lt;p&gt;Running two Next.js apps behind one Nginx domain is a reasonable setup. It just punishes vague ownership. The rules I work by now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One owner per prefix.&lt;/strong&gt; Either &lt;code&gt;basePath&lt;/code&gt; in Next.js or prefix rewriting in Nginx, never both. Keep &lt;code&gt;proxy_pass&lt;/code&gt; free of a URI when using &lt;code&gt;basePath&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Builds are artifacts, not suggestions.&lt;/strong&gt; Clean builds in CI, one &lt;code&gt;BUILD_ID&lt;/code&gt; per deployed version, and check the console before you check the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache by content type, not by prefix.&lt;/strong&gt; Hashed assets get &lt;code&gt;immutable&lt;/code&gt; for a year. HTML gets &lt;code&gt;no-store&lt;/code&gt; or &lt;code&gt;no-cache&lt;/code&gt;. Enforce it where you can actually guarantee it, and verify with &lt;code&gt;curl -I&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let React do resets.&lt;/strong&gt; A changed &lt;code&gt;key&lt;/code&gt; is the most complete reset you can write, because you don't write it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. The trap is that each failure shows up somewhere other than its cause.&lt;/p&gt;

&lt;p&gt;I lead platform engineering for fintech. Read the full case study &lt;a href="https://nazmul-ahasan.pro.bd/case-studies/fundednext-plan-wizard.html" rel="noopener noreferrer"&gt;here&lt;/a&gt; or see how we can work together at &lt;a href="https://nazmul-ahasan.pro.bd/work-with-me/" rel="noopener noreferrer"&gt;Nazmul Ahasan&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>nginx</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Migrate DigitalOcean Spaces to S3: 12 Million Objects, Two Hops, Zero Failures</title>
      <dc:creator>Nazmul Ahasan</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:13:31 +0000</pubDate>
      <link>https://dev.to/nrejve/how-i-migrate-digitalocean-spaces-to-s3-12-million-objects-two-hops-zero-failures-48a0</link>
      <guid>https://dev.to/nrejve/how-i-migrate-digitalocean-spaces-to-s3-12-million-objects-two-hops-zero-failures-48a0</guid>
      <description>&lt;h1&gt;
  
  
  How I Migrate DigitalOcean Spaces to S3: 12 Million Objects, Two Hops, Zero Failures
&lt;/h1&gt;

&lt;p&gt;Recently I had to migrate DigitalOcean Spaces to S3 for a fintech platform I run infra for. The source was a Space in an EU region and the destination was S3 in an APAC region: about 2.5 TiB across about 12 million objects. The final hop finished with every object succeeded and 0 failed.&lt;/p&gt;

&lt;p&gt;That result is clean. The route to it wasn't. This is the version of the story with the mistakes left in, because the mistakes are where the useful lessons are.&lt;/p&gt;

&lt;p&gt;I'm Nazmul Ahasan, and I lead Platform Engineering at NEXT Ventures. I write about AWS, cost and reliability work at &lt;a href="https://nazmul-ahasan.pro.bd/" rel="noopener noreferrer"&gt;nazmul-ahasan.pro.bd&lt;/a&gt;, and the business side of this move is in the &lt;a href="https://nazmul-ahasan.pro.bd/case-studies/object-storage-migration.html" rel="noopener noreferrer"&gt;object storage migration case study&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;Look at the average object size before anything else: about 220 KB. This isn't a "move a few big files" job. It's a massive-small-file workload, which means the limit is requests per second, not bandwidth. Every object costs you a GET, a PUT, and possibly a HEAD or two. Multiply that by about twelve million.&lt;/p&gt;

&lt;p&gt;The second fact is geography. EU to APAC is roughly 250–280 ms round trip. A copy tool running anywhere along that path pays that latency on every small object. You can throw concurrency at it, but you'll hit the source's rate limits long before you beat physics.&lt;/p&gt;

&lt;p&gt;So I didn't do a direct copy. I did two hops:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hop 1, same metro:&lt;/strong&gt; rclone on an EC2 instance in the same EU metro, copying DO Spaces to an S3 staging bucket in &lt;code&gt;&amp;lt;eu-region&amp;gt;&lt;/code&gt;. Low latency on both sides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hop 2, server-side:&lt;/strong&gt; S3 Batch Operations copying staging to the production bucket in the APAC region. The bytes travel over the AWS backbone and never pass through a machine I manage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you take one thing from this post, take that one. Do the slow, chatty part of the transfer where latency is cheap, then let AWS move the bulk between its own regions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hop 1: rclone, DO Spaces to S3 in the EU
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Config, and my first dumb mistake
&lt;/h3&gt;

&lt;p&gt;Spaces keys come from the DigitalOcean control panel under API → Spaces Keys. The secret is shown exactly once. Copy it straight into your secret store.&lt;/p&gt;

&lt;p&gt;The working config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dospaces]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;s3&lt;/span&gt;
&lt;span class="py"&gt;provider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;DigitalOcean&lt;/span&gt;
&lt;span class="py"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;do-eu-region&amp;gt;.digitaloceanspaces.com&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;do-eu-region&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;access_key_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;SPACES_KEY&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;secret_access_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;SPACES_SECRET&amp;gt;&lt;/span&gt;

&lt;span class="nn"&gt;[s3eu]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;s3&lt;/span&gt;
&lt;span class="py"&gt;provider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;AWS&lt;/span&gt;
&lt;span class="py"&gt;env_auth&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;eu-region&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first version had the Space name baked into the endpoint host, &lt;code&gt;my-space.&amp;lt;do-eu-region&amp;gt;.digitaloceanspaces.com&lt;/code&gt;, because that's the URL you see in the DO console. &lt;code&gt;rclone lsd dospaces:&lt;/code&gt; came back empty. No error, just nothing. The endpoint should be the regional host, and the bucket goes in the path: &lt;code&gt;dospaces:my-source-space&lt;/code&gt;. Five minutes lost, and a reminder that a silent empty result is worse than an error.&lt;/p&gt;

&lt;p&gt;A smaller red herring: on the AWS side the instance role was scoped to specific buckets, so &lt;code&gt;rclone lsd s3eu:&lt;/code&gt; returned a 403 on &lt;code&gt;ListAllMyBuckets&lt;/code&gt;. That's harmless. You don't need to list every bucket in the account. Go straight to &lt;code&gt;s3eu:my-staging-bucket-eu&lt;/code&gt; and move on. Don't widen the policy to make a cosmetic error go away.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tuning: SlowDown, then crawling
&lt;/h3&gt;

&lt;p&gt;My first serious run fired a lot of concurrency at Spaces and got &lt;strong&gt;503 SlowDown&lt;/strong&gt; back. That's DO telling you to back off. Two things were burning requests: raw parallelism, and rclone doing a HEAD on objects it didn't need to inspect.&lt;/p&gt;

&lt;p&gt;Once the 503s stopped it was still crawling at about 150 objects per second. The cause was modtime comparison. To compare modification times, rclone HEADs the object on &lt;em&gt;both&lt;/em&gt; sides. Two extra round trips per object for twelve million objects is a lot of waiting. For a one-shot migration into an empty bucket, size is a perfectly good comparison.&lt;/p&gt;

&lt;p&gt;I raised the request rate step by step, &lt;code&gt;--tpslimit&lt;/code&gt; 300 → 600 → 800, and watched for throttling at each step. This is the command I finished with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rclone copy dospaces:my-source-space s3eu:my-staging-bucket-eu &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--size-only&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transfers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;96 &lt;span class="nt"&gt;--checkers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;32 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tpslimit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;800 &lt;span class="nt"&gt;--tpslimit-burst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--s3-no-head-object&lt;/span&gt; &lt;span class="nt"&gt;--s3-no-head&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--s3-upload-concurrency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="nt"&gt;--s3-chunk-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5M &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--s3-no-check-bucket&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10 &lt;span class="nt"&gt;--low-level-retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--stats&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1m &lt;span class="nt"&gt;--stats-one-line&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rclone-hop1.log &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the flags matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--size-only&lt;/code&gt; removes the modtime HEADs. It's the biggest single speedup.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--s3-no-head&lt;/code&gt; and &lt;code&gt;--s3-no-head-object&lt;/code&gt; skip the HEAD before and after each upload. For small files, those HEADs are a big share of the total work.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--s3-upload-concurrency=2&lt;/code&gt; and &lt;code&gt;--s3-chunk-size=5M&lt;/code&gt; fit objects of about 220 KB. Big multipart settings do nothing here except use memory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--s3-no-check-bucket&lt;/code&gt; stops rclone from checking or creating the bucket over and over.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--tpslimit=800&lt;/code&gt; is the number that matters. At 800 TPS I saw zero throttling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It sustained about 400 objects per second, peaking around 145 MiB/s. Hop 1 took about a day, tuning included.&lt;/p&gt;

&lt;h3&gt;
  
  
  The six hours I lost
&lt;/h3&gt;

&lt;p&gt;This was my most expensive mistake, and it had nothing to do with clever flags.&lt;/p&gt;

&lt;p&gt;The instance started as a &lt;code&gt;c6i.xlarge&lt;/code&gt; with 8 GB of RAM. rclone builds directory listings in memory, and a full listing of 12 million objects holds about 7 GB. The first real run ran out of memory and froze the box. Nothing crashed loudly. The process just stopped making progress, and I didn't notice for about &lt;strong&gt;six hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I also tried &lt;code&gt;--fast-list&lt;/code&gt;, which is supposed to cut list calls by fetching everything in one go. On this bucket it sat idle for around 30 minutes building the listing before it copied anything. On a box that's already short of memory, that's the wrong trade.&lt;/p&gt;

&lt;p&gt;The fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Resize to an &lt;code&gt;r6i.4xlarge&lt;/code&gt;. Memory-optimised, because the listing is the workload.&lt;/li&gt;
&lt;li&gt;Work per top-level folder instead of the whole bucket at once.&lt;/li&gt;
&lt;li&gt;Monitor long runs. Tail &lt;code&gt;--stats-one-line&lt;/code&gt; output, alert if the transferred count stops moving, watch memory. A &lt;code&gt;watch&lt;/code&gt; loop on the log would have saved me most of a working day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My opinion: any copy job expected to run longer than lunch needs a liveness check. "It's probably still going" doesn't count as monitoring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying hop 1
&lt;/h3&gt;

&lt;p&gt;A full-bucket &lt;code&gt;rclone check&lt;/code&gt; hit the same memory wall, so I checked per folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rclone check dospaces:my-source-space/Invoices s3eu:my-staging-bucket-eu/Invoices &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--one-way&lt;/span&gt; &lt;span class="nt"&gt;--size-only&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--missing-on-dst&lt;/span&gt; missing.txt &lt;span class="nt"&gt;--differ&lt;/span&gt; differ.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--one-way&lt;/code&gt; asks only one question: is everything at the source present at the destination? For a migration, that's the right question.&lt;/p&gt;

&lt;p&gt;The gaps were real, and they were all explainable. The application was still live. New uploads under the current month's &lt;code&gt;&amp;lt;YYYY/MM&amp;gt;&lt;/code&gt; prefix kept arriving after the bulk pass had listed them. Some files had been regenerated in place with a different size. Both kinds got targeted re-copies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rclone copy dospaces:my-source-space s3eu:my-staging-bucket-eu &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--files-from&lt;/span&gt; missing.txt &lt;span class="nt"&gt;--no-traverse&lt;/span&gt; &lt;span class="nt"&gt;--size-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feeding the list in with &lt;code&gt;--files-from&lt;/code&gt; plus &lt;code&gt;--no-traverse&lt;/code&gt; means rclone copies exactly those keys without listing the bucket again. For a sense of scale, the top-ups were a few thousand files in total, mostly certificates and invoices, plus a handful of agreements, photos and dispute files with size differences. This bucket holds user documents like invoices and certificates. "Mostly there" isn't acceptable for data like that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hop 2: S3 Batch Operations to migrate DigitalOcean Spaces to S3 in APAC
&lt;/h2&gt;

&lt;p&gt;Once staging matched the source, the rest was meant to be the easy part. It was, eventually.&lt;/p&gt;

&lt;h3&gt;
  
  
  The IAM role
&lt;/h3&gt;

&lt;p&gt;Batch Operations assumes a role that trusts &lt;code&gt;batchoperations.s3.amazonaws.com&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&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;"Service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"batchoperations.s3.amazonaws.com"&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;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&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;Permissions, split by purpose:&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SourceRead"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&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;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObjectTagging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObjectVersion"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                 &lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetBucketLocation"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&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;"arn:aws:s3:::my-staging-bucket-eu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-staging-bucket-eu/*"&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;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DestWrite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&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;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObjectTagging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObjectAcl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                 &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetBucketLocation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&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;"arn:aws:s3:::my-prod-bucket-apac"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-prod-bucket-apac/*"&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;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ManifestAndReports"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&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;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&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;"arn:aws:s3:::my-prod-bucket-apac/batch-manifests/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                   &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-prod-bucket-apac/batch-reports/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Four failures before one success
&lt;/h3&gt;

&lt;p&gt;I tried to be clever with the generated manifest first. The console and API can build the manifest for you from filters, so you don't write a CSV yourself. That's how the day went:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instant failure from the &lt;code&gt;EligibleForReplication&lt;/code&gt; filter.&lt;/strong&gt; That filter only works on versioned buckets, and staging wasn't versioned. I removed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing &lt;code&gt;PutInventoryConfiguration&lt;/code&gt;.&lt;/strong&gt; The auto-generated manifest uses inventory machinery under the hood, and the role needs that permission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;301 PermanentRedirect, 100% failure.&lt;/strong&gt; An auto-manifest job runs in the &lt;em&gt;source&lt;/em&gt; region. Writing cross-region to APAC from an EU job redirected every task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CrossRegionOperationProhibited.&lt;/strong&gt; When I tried to fix the region, Batch Operations made it clear it can't generate a manifest across regions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lesson: if source and destination are in different regions, build the manifest yourself and run the job in the &lt;strong&gt;destination&lt;/strong&gt; region.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building the manifest by hand
&lt;/h3&gt;

&lt;p&gt;s5cmd is far faster than the AWS CLI at listing millions of keys. I piped its output through &lt;code&gt;sed&lt;/code&gt; to strip it down to keys, then through a short Python step that URL-encodes each key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;s5cmd &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s1"&gt;'s3://my-staging-bucket-eu/*'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/^.{19}[[:space:]]+[0-9]+[[:space:]]+//'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
import sys, urllib.parse
for line in sys.stdin:
    k = line.rstrip("\n")
    if k:
        print("my-staging-bucket-eu," + urllib.parse.quote(k, safe=""))
'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; manifest.csv

&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; manifest.csv   &lt;span class="c"&gt;# should match the object count&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp &lt;/span&gt;manifest.csv s3://my-prod-bucket-apac/batch-manifests/manifest.csv
aws s3api head-object &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-prod-bucket-apac &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; batch-manifests/manifest.csv &lt;span class="nt"&gt;--query&lt;/span&gt; ETag &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;safe=''&lt;/code&gt;. Encode everything, slashes included, and Batch decodes it correctly. Keys with spaces, plus signs, or non-ASCII characters are where hand-built manifests usually break. The file came to about 1.23 GB with one line per object, which matched the object count exactly. It went into the destination-region bucket next to the job.&lt;/p&gt;

&lt;h3&gt;
  
  
  The job
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3control create-job &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST_REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--account-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--operation&lt;/span&gt; &lt;span class="s1"&gt;'{"S3PutObjectCopy":{
      "TargetResource":"arn:aws:s3:::my-prod-bucket-apac",
      "MetadataDirective":"COPY",
      "StorageClass":"STANDARD"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--manifest&lt;/span&gt; &lt;span class="s1"&gt;'{"Spec":{"Format":"S3BatchOperations_CSV_20180820","Fields":["Bucket","Key"]},
     "Location":{"ObjectArn":"arn:aws:s3:::my-prod-bucket-apac/batch-manifests/manifest.csv",
                 "ETag":"&amp;lt;manifest-etag&amp;gt;"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--report&lt;/span&gt; &lt;span class="s1"&gt;'{"Bucket":"arn:aws:s3:::my-prod-bucket-apac","Prefix":"batch-reports",
     "Format":"Report_CSV_20180820","Enabled":true,"ReportScope":"FailedTasksOnly"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--priority&lt;/span&gt; 10 &lt;span class="nt"&gt;--role-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BATCH_ROLE_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--confirmation-required&lt;/span&gt; &lt;span class="nt"&gt;--client-request-token&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;uuidgen&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;&lt;code&gt;MetadataDirective: COPY&lt;/code&gt; keeps content types and user metadata. &lt;code&gt;FailedTasksOnly&lt;/code&gt; keeps the report small. If it's empty, that's your proof.&lt;/p&gt;

&lt;p&gt;The job went through the lifecycle you'd hope for. &lt;strong&gt;Preparing&lt;/strong&gt; read every manifest row (about 12 million) with 0 errors. It then sat &lt;strong&gt;Suspended&lt;/strong&gt;, waiting for my confirmation. I read the summary, then released it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3control update-job-status &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST_REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--account-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--job-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$JOB_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--requested-job-status&lt;/span&gt; Ready
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Active&lt;/strong&gt;, then &lt;strong&gt;Complete&lt;/strong&gt;: every object succeeded, 0 failed. Always keep &lt;code&gt;--confirmation-required&lt;/code&gt; on. Validating the manifest before twelve million PUTs is cheap insurance.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mistake I'm a bit embarrassed by
&lt;/h3&gt;

&lt;p&gt;Before I settled on Batch, I briefly ran an rclone S3-to-S3 copy on the EC2 box and thought it was a backbone transfer. It isn't. rclone always streams the bytes through the machine it runs on. Server-side copy within a single provider is a narrow case, and it doesn't cover cross-region at this scale with this tooling. If you want the bytes to stay inside AWS, use Batch Operations or replication, not a relay instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What hop 2 cost
&lt;/h2&gt;

&lt;p&gt;Hop 2's bill comes down to three things: the Batch Operations job and per-object fees, about 12 million PUT requests in the destination region, and roughly 2.5 TB of inter-region transfer. All three were small. DigitalOcean egress and the EC2 time for hop 1 come on top. Staging in the EU isn't free, but it's cheap next to an engineer babysitting a latency-bound copy for a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cutover and cleanup
&lt;/h2&gt;

&lt;p&gt;The manifest is a snapshot. Anything written to Spaces after you list it isn't in it. So the cutover went like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Switch application writes to the APAC bucket, or briefly pause uploads.&lt;/li&gt;
&lt;li&gt;Run a final delta sync of the recent prefixes &lt;strong&gt;directly from DO to the APAC bucket&lt;/strong&gt;. It's small enough that latency doesn't matter. Use &lt;code&gt;--checksum&lt;/code&gt;, because this is the last chance to catch an in-place change:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   rclone copy dospaces:my-source-space/&amp;lt;YYYY/MM&amp;gt; s3apac:my-prod-bucket-apac/&amp;lt;YYYY/MM&amp;gt; &lt;span class="nt"&gt;--checksum&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Clean up: terminate the EC2 instance, put a lifecycle expiration rule on the staging bucket, delete the manifest and report prefixes, and remove the Batch role.&lt;/li&gt;
&lt;li&gt;Rotate every credential used during the migration. Spaces keys, any access keys, all of it. Migration credentials are long-lived by accident and broad by design, so don't leave them lying around.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start on a memory-optimised instance&lt;/strong&gt; and work per folder from minute one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor from the first run.&lt;/strong&gt; Six hours of a frozen box is the cost I resent most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;--size-only&lt;/code&gt; and the no-HEAD flags immediately&lt;/strong&gt; for small files into an empty destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip the generated manifest&lt;/strong&gt; for cross-region jobs. Build the CSV and run in the destination region.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remember that rclone is a relay, not a backbone.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two hops, about a day and a half including mistakes, and zero failed objects.&lt;/p&gt;

&lt;p&gt;I lead platform engineering for fintech. Read the full case study &lt;a href="https://nazmul-ahasan.pro.bd/case-studies/object-storage-migration.html" rel="noopener noreferrer"&gt;here&lt;/a&gt; or see how we can work together at &lt;a href="https://nazmul-ahasan.pro.bd/work-with-me/" rel="noopener noreferrer"&gt;Nazmul Ahasan&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>cloud</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>We almost handed out AWS keys to every team. So I built an LLM gateway instead.</title>
      <dc:creator>Nazmul Ahasan</dc:creator>
      <pubDate>Thu, 16 Jul 2026 17:47:00 +0000</pubDate>
      <link>https://dev.to/nrejve/we-almost-handed-out-aws-keys-to-every-team-so-i-built-an-llm-gateway-instead-g7j</link>
      <guid>https://dev.to/nrejve/we-almost-handed-out-aws-keys-to-every-team-so-i-built-an-llm-gateway-instead-g7j</guid>
      <description>&lt;p&gt;A few months ago the ask landed on my desk that a lot of infra people are getting right now: "The XYZ (Non Tech) teams want to build on our LLMs. Can you give them access to Bedrock?"&lt;/p&gt;

&lt;p&gt;Simple enough on paper. AWS Bedrock was already in our account, the models were enabled, IAM can hand out permissions. Ten minutes of work.&lt;/p&gt;

&lt;p&gt;Except it isn't ten minutes of work, and if you treat it like it is, you'll regret it in about three weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two obvious ways to do this
&lt;/h2&gt;

&lt;p&gt;There are basically two, and neither is good once you think past the first user.&lt;/p&gt;

&lt;p&gt;Option one is to hand out IAM credentials, or a role, that can call Bedrock. Fine for one person. Do it a few times, though, and you've got AWS keys living in &lt;code&gt;.env&lt;/code&gt; files, a CI variable here, someone's Postman collection there, maybe a notebook somewhere you'll never hear about. Rotating any of it means finding every place it landed. And IAM permissions tend to be broader than you meant them to be a policy that lets someone call Bedrock usually lets them reach a few other things too, because nobody scopes these tightly on day one and then day one becomes a year ago.&lt;/p&gt;

&lt;p&gt;Option two is one shared key behind an internal endpoint, and everyone points at that. Quicker to stand up. But now every request looks the same, so you can't tell whose code is burning what. A single bad loop can drain the month's budget before lunch and you won't know which team shipped it. And you can't cut off one person without rotating the key for all of them.&lt;/p&gt;

&lt;p&gt;Both fall apart the moment more than two or three people are involved. And the part that stung most: when finance eventually asks why the AI line jumped, you've got nothing to show them.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is a solved problem, mostly
&lt;/h2&gt;

&lt;p&gt;There's a whole category of tools for this now people call them AI gateways, or LLM gateways. LiteLLM, Portkey, Helicone, Cloudflare's AI Gateway, and a pile of others. They all sit between your apps and the model provider and give you back the two things you want: control over who can call what, and a record of what everyone spent. Same idea as an ordinary API gateway, pointed at tokens.&lt;/p&gt;

&lt;p&gt;I spent a while looking at the hosted ones, and for most teams I'd tell you to pick one and move on. We didn't. The reason is unglamorous. We're a fintech company, and routing prompts and completions, some of which touch data I don't want leaving our network, through somebody else's SaaS just to get a usage graph was a trade I wasn't going to make. I wanted the whole thing inside our own perimeter, talking straight to Bedrock, with the logs in a database we own. So I built a small version.&lt;/p&gt;

&lt;h2&gt;
  
  
  One rule: nobody gets an AWS credential
&lt;/h2&gt;

&lt;p&gt;The whole design comes down to one decision, and it's a blunt one. Nobody gets AWS credentials. Not developers, not services, nothing except the gateway itself.&lt;/p&gt;

&lt;p&gt;What people get instead is a key the gateway invents a prefixed token, &lt;code&gt;llmkey_9f3c…&lt;/code&gt; or whatever, that AWS has never heard of. You drop it in your app, point the client at the gateway's URL instead of the Bedrock endpoint, and you're done. The only place real AWS credentials exist is the gateway process, in environment variables, and they never get copied out.&lt;/p&gt;

&lt;p&gt;Everything else is downstream of that. Once a request shows up carrying a key you handed out, you know who's calling. And once you know who's calling, you can start refusing things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can lock down, per key
&lt;/h2&gt;

&lt;p&gt;A few things, and they're set per key.&lt;/p&gt;

&lt;p&gt;You can pin the models a key is allowed to touch. Most internal tools don't need the top-tier model a ticket summarizer is perfectly happy on something small and fast, and there's no reason to let that key reach for a model that costs several times more, whether by accident or because somebody got curious one afternoon.&lt;/p&gt;

&lt;p&gt;You can hand each key a token budget and count against it on every call. Nothing fancy, just a number and a running total. When a key's creeping toward its limit I can see it on the dashboard before it's a problem, and when it hits the ceiling it stops. That one thing removes the runaway-script scenario, which is the one that keeps you up at night.&lt;/p&gt;

&lt;p&gt;And keys carry names and owners. So when you're staring at usage you're looking at "the internal analytics job" or an actual person, not a random string you have to go trace back at 11pm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request path
&lt;/h2&gt;

&lt;p&gt;Here's what a call does. It's not exciting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request comes in with a gateway key.&lt;/li&gt;
&lt;li&gt;Look it up. Active? Allowed to use the model it's asking for? Any budget left?&lt;/li&gt;
&lt;li&gt;If any of those is a no, reject it right there, before we've spent a cent on AWS.&lt;/li&gt;
&lt;li&gt;If it all checks out, forward to Bedrock with the real credentials.&lt;/li&gt;
&lt;li&gt;When the response comes back, pull the token counts out of it, write a row to the usage log, and bump the key's running total.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is the reason all the reporting works later. Every call becomes a row: input tokens, output tokens, latency, model, cost. Every screen in the app is just a query over that one table.&lt;/p&gt;

&lt;p&gt;Stripped down, the check in the middle is about this simple:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gateway_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&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="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gateway_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Unauthorized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid or disabled key&lt;/span&gt;&lt;span class="sh"&gt;"&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;model_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_models&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Forbidden&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key not permitted to use &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&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;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens_used&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;QuotaExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Token budget exhausted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# nothing hits AWS until we get here
&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;bedrock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;  &lt;span class="c1"&gt;# input / output token counts
&lt;/span&gt;    &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;log_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;increment_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Working out the cost
&lt;/h2&gt;

&lt;p&gt;Cost is the part people assume will be fiddly. It isn't, as long as you don't bake the prices into the code. Bedrock charges a per-1K rate for input and a different per-1K rate for output, the rates differ by model, and they change over time. So they live in settings, and the cost function just reads them:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pricing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# per-1K rates, editable in settings
&lt;/span&gt;    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;  &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_price&lt;/span&gt;
          &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because that runs and gets stored on every request, nobody has to reconstruct spending afterward. I can pull it apart by model, by key, over whatever window. The first time I did that and found one key sitting on most of the month's cost, it turned into a five-minute chat with the team that owned it instead of a week of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dashboards, and who's allowed to touch them
&lt;/h2&gt;

&lt;p&gt;On top of that log table is the part everyone opens first: usage over time, which models are getting hammered, cost per period, the biggest keys ranked by spend. If you've seen any observability dashboard it looks like that, except the axes are tokens and money.&lt;/p&gt;

&lt;p&gt;One thing mattered more than I expected. The people who need to look at this aren't always the people who should be able to change it. Someone on finance, or a team lead, wants to watch the numbers. They've got no reason to be creating keys or editing the price table. So there are roles: full admins who can do everything, and read-only accounts that can look and nothing else. Small feature. Saved an awkward conversation more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stuff I got wrong
&lt;/h2&gt;

&lt;p&gt;A few things I didn't see coming, or saw coming and ignored:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming ruins the tidy version&lt;/strong&gt; Stream a completion and there's no neat usage object waiting up front. You either count tokens as they arrive or grab the metadata Bedrock sends at the very end. I retrofitted this and it was more painful than just doing it right would have been.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single lifetime budget per key seems fine, and isn't&lt;/strong&gt; It holds up until someone legitimately needs more, at which point you're editing limits by hand every few days and wishing you'd built monthly resets or a top-up path at the start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The log table grows faster than you'd guess&lt;/strong&gt; One row per request is nothing right up until it's millions of rows. Have a rough plan for archiving or rolling up old data before you're doing it in a hurry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep cost at full precision&lt;/strong&gt; Per-request cost is a sliver of a cent. Round on the way in and your monthly totals come out wrong by an amount that's just small enough that someone will eventually notice and stop trusting the whole thing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are hard. They're the sort of thing you only really learn by getting them a little wrong first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth building?
&lt;/h2&gt;

&lt;p&gt;For us, yeah. Not because it's clever, it isn't, it's a reverse proxy with a database bolted to it. It's worth it because of what changed day to day. Someone wants Bedrock access, I generate a key, done, no IAM ticket. No real credential has left the gateway even once. A key misbehaves, I revoke that one key and everyone else carries on.&lt;/p&gt;

&lt;p&gt;And when AI spend comes up, which it does constantly now, I open a page instead of a spreadsheet.&lt;/p&gt;

&lt;p&gt;The last one is the part I'd push hardest on. Every company I talk to is having some flavor of the same conversation right now: who's using these models, on what, at what cost, and whether any of it is governed. You can answer that with a policy doc that's stale the week you write it, or you can let your infrastructure answer it by itself. I know which of those stays current.&lt;/p&gt;

&lt;p&gt;If you've just caught the "get everyone onto our LLMs" request yourself, that's the one bit of advice I'd hand you. Don't pass out keys. Stand something small up in front of Bedrock first. It's a weekend of work and it saves you from a mess that's a real pain to clean up later.&lt;/p&gt;

&lt;p&gt;I lead platform engineering for fintech. Read more on &lt;a href="https://nazmul-ahasan.pro.bd/" rel="noopener noreferrer"&gt;my site&lt;/a&gt; or see how we can work together at &lt;a href="https://nazmul-ahasan.pro.bd/work-with-me/" rel="noopener noreferrer"&gt;Nazmul Ahasan&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>llm</category>
      <category>security</category>
    </item>
    <item>
      <title>AWS Cloud Cost: Why Most Companies Overspend — and What to Do About It</title>
      <dc:creator>Nazmul Ahasan</dc:creator>
      <pubDate>Thu, 05 Mar 2026 17:22:11 +0000</pubDate>
      <link>https://dev.to/nrejve/aws-cloud-cost-why-most-companies-overspend-and-what-to-do-about-it-1na7</link>
      <guid>https://dev.to/nrejve/aws-cloud-cost-why-most-companies-overspend-and-what-to-do-about-it-1na7</guid>
      <description>&lt;p&gt;Companies move to AWS expecting three things: scalability, flexibility, and lower costs. The first two usually hold up. The third? That's where I've seen things go sideways — repeatedly.&lt;/p&gt;

&lt;p&gt;I've spent a good chunk of my career working across cloud environments, helping teams untangle billing nightmares and architect systems that don't hemorrhage money. And the uncomfortable truth I keep running into is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AWS isn't expensive. Running AWS without paying attention is.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Let me walk through what I actually see happening out there, and what I think more teams should be doing differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The promises vs. the reality
&lt;/h2&gt;

&lt;p&gt;The pitch is always the same. Pay only for what you use. No upfront hardware investment. Scaling that just works.&lt;/p&gt;

&lt;p&gt;All technically true. But there's a massive asterisk nobody talks about during the sales cycle — the cloud rewards discipline and absolutely punishes negligence. On-prem, your spending was capped by physical hardware. You bought a rack, you used a rack. With AWS, there's no ceiling, and that elasticity can work against you if nobody's watching.&lt;/p&gt;

&lt;p&gt;I had a client a couple of years ago who migrated about 200 workloads into EC2 over six months. Nine months into AWS, their monthly cloud spend was about 65% higher than their old on-prem costs, and climbing. Not because AWS was more expensive — because they'd replicated every bad habit from their data center and added new ones on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the money actually goes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The lift-and-shift trap
&lt;/h3&gt;

&lt;p&gt;This is the most common one. A team takes their on-prem VMs — say, a 16-vCPU server running an internal app — and stands up an identical EC2 instance. No benchmarking, no utilization checks, nothing. Just &lt;em&gt;"make it the same size so nothing breaks."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The problem is that most of those servers were oversized to begin with. I've pulled CloudWatch metrics on freshly migrated instances and found utilization hovering around 12–18%. That's a machine doing almost nothing, billed around the clock. Multiply that across a fleet and you've got a serious problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dev and staging environments that never sleep
&lt;/h3&gt;

&lt;p&gt;This one drives me a little crazy because it's so fixable.&lt;/p&gt;

&lt;p&gt;Production needs to run 24/7 — obviously. But I routinely find dev, staging, QA, and sandbox environments humming along at 2 AM on a Sunday. Nobody's using them. Nobody even remembers they're on. But they're racking up charges because no single person is responsible for shutting them down.&lt;/p&gt;

&lt;p&gt;I worked with one mid-sized engineering org — maybe 8 teams — where non-production environments accounted for nearly &lt;strong&gt;40%&lt;/strong&gt; of their monthly compute bill. On infrastructure that was idle roughly 65% of the time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The on-demand default
&lt;/h3&gt;

&lt;p&gt;AWS gives you several ways to pay less: Savings Plans, Reserved Instances, Spot. But a surprising number of companies run everything on-demand because it's the path of least resistance. Nobody has to forecast. Nobody has to commit. Engineering doesn't have to talk to finance.&lt;/p&gt;

&lt;p&gt;The tradeoff is you're paying full sticker price for workloads that have been running predictably for months or even years. I've seen teams save 30–40% just by committing to a one-year Savings Plan on compute they were clearly going to keep running anyway. It's not a risky bet — it's common sense that nobody gets around to acting on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage: the quiet budget killer
&lt;/h3&gt;

&lt;p&gt;Storage costs don't spike. They creep. That's what makes them dangerous.&lt;/p&gt;

&lt;p&gt;S3 buckets with no lifecycle policies, so data just accumulates forever. EBS snapshots from instances that were terminated a year ago. Detached volumes sitting there, still on the meter. Log files nobody's looked at since 2022, stored in standard-tier S3 instead of Glacier or just deleted outright.&lt;/p&gt;

&lt;p&gt;Individually, none of these line items look alarming. But I've audited accounts where storage waste was quietly eating a double-digit percentage of the monthly bill. It adds up faster than people expect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes cost sprawl
&lt;/h3&gt;

&lt;p&gt;EKS is a whole separate conversation, honestly.&lt;/p&gt;

&lt;p&gt;The pattern I see most often: developers set CPU and memory requests conservatively high because they'd rather have headroom than get paged at 3 AM. Fair enough — I get it. But the cluster autoscaler provisions nodes based on those &lt;em&gt;requests&lt;/em&gt;, not actual usage. So you end up with a bunch of nodes running at 25% utilization, fully billed, because the resource requests told Kubernetes it needed all that capacity.&lt;/p&gt;

&lt;p&gt;Tuning pod requests and limits, setting up HPA properly, right-sizing node groups — this stuff isn't glamorous, but it can cut EKS costs dramatically. I've seen 30–50% reductions from a focused two-week tuning effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;Here's the thing — it's almost never a skills problem. It's an incentives problem.&lt;/p&gt;

&lt;p&gt;Engineers are measured on uptime and reliability, not cost. If my bonus depended on keeping an SLA, I'd overprovision too. That's rational behavior given the incentives. The issue is that nobody's creating a counterbalancing reason to optimize spend.&lt;/p&gt;

&lt;p&gt;Finance teams, meanwhile, are used to predictable costs — hardware depreciation, annual license renewals. AWS billing is dynamic, granular, and frankly kind of confusing if you're not technical. So finance sees the bill, maybe flags it when it spikes, but doesn't have the context to ask the right questions.&lt;/p&gt;

&lt;p&gt;What bridges that gap is a FinOps practice — finance, engineering, and leadership sharing ownership of cloud spend. But most companies I've worked with either don't have one or have a version that's really just one person generating Cost Explorer reports that nobody reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works
&lt;/h2&gt;

&lt;p&gt;No magic bullets here, but these consistently move the needle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bake cost into architecture decisions
&lt;/h3&gt;

&lt;p&gt;Not as an afterthought. Before you deploy, estimate your utilization, pick the right instance family, define your scaling thresholds, and figure out your pricing model. If your architecture review only covers security and availability, you're leaving money on the table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enforce tagging — I mean it
&lt;/h3&gt;

&lt;p&gt;If you can't attribute a cost to a team, a project, and an environment, you can't control it. I'm a big fan of making deployment pipelines reject resources that don't carry mandatory tags. Sounds heavy-handed, but it works. Once teams see their name next to a dollar amount, behavior changes fast.&lt;/p&gt;

&lt;p&gt;A basic setup might look like:&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;# Example: enforcing tags in a CloudFormation resource&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;MyInstance&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;AWS::EC2::Instance&lt;/span&gt;
    &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;InstanceType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;t3.medium&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="na"&gt;Key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Project&lt;/span&gt;
          &lt;span class="na"&gt;Value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;ProjectName&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;Environment&lt;/span&gt;
          &lt;span class="na"&gt;Value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;EnvType&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;Owner&lt;/span&gt;
          &lt;span class="na"&gt;Value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;TeamOwner&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;CostCenter&lt;/span&gt;
          &lt;span class="na"&gt;Value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;CostCenterCode&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Automate environment scheduling
&lt;/h3&gt;

&lt;p&gt;Probably the single highest-ROI change most companies can make. A Lambda function that shuts down dev and staging at 7 PM and spins them back up at 7 AM — that's a 50% compute reduction on those environments right there.&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="c1"&gt;# Simplified Lambda to stop instances by tag
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ec2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ec2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&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;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe_instances&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Filters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&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;Name&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;tag:Environment&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;Values&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&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;dev&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;staging&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&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;Name&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;instance-state-name&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;Values&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&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;running&lt;/span&gt;&lt;span class="sh"&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="n"&gt;instance_ids&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="n"&gt;reservation&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Reservations&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;instance&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reservation&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Instances&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;instance_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;InstanceId&lt;/span&gt;&lt;span class="sh"&gt;'&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;instance_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop_instances&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InstanceIds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;instance_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stopped &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; instances&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can get fancier with CI/CD-triggered provisioning, but even a simple scheduler pays for itself immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stop defaulting to on-demand
&lt;/h3&gt;

&lt;p&gt;Review your usage quarterly. Anything that's been running steadily for 3+ months is a candidate for Savings Plans or RIs. Spot is great for fault-tolerant batch jobs and test workloads. This isn't complicated — it just requires someone to actually own the decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rightsize continuously
&lt;/h3&gt;

&lt;p&gt;Not once. Not annually. Set a recurring calendar reminder — monthly or quarterly — to review EC2, RDS, and EKS utilization. Workloads change. Traffic patterns shift. An instance that was right-sized six months ago might be oversized now.&lt;/p&gt;

&lt;p&gt;AWS Compute Optimizer can point you in the right direction, but someone still has to act on the recommendations. Tooling without follow-through is just dashboard decoration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tie cost to business outcomes
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;"Our cloud spend is huge"&lt;/em&gt; is not a useful data point on its own.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Our cost per active user dropped 20% this quarter"&lt;/em&gt; or &lt;em&gt;"cost per transaction is flat while traffic doubled"&lt;/em&gt; — that's something you can reason about, benchmark, and improve. When cost is framed as a business metric rather than an infrastructure line item, the conversations get a lot more productive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does your team fall?
&lt;/h2&gt;

&lt;p&gt;I think about cloud cost management as a rough progression:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud chaos&lt;/strong&gt; — No tagging, no budgets, no ownership. The bill is a surprise every month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reactive control&lt;/strong&gt; — Someone set up budget alerts. There's a cleanup sprint every quarter when the CFO starts asking questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proactive optimization&lt;/strong&gt; — Automated scheduling, a real Savings Plans strategy, regular utilization reviews built into team rhythms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost-aware culture&lt;/strong&gt; — Teams discuss spend in sprint reviews. Architects weigh pricing alongside performance. Engineering and finance share a vocabulary.&lt;/p&gt;

&lt;p&gt;Most companies I encounter are somewhere between the first two. And honestly, getting from chaos to proactive is where the biggest savings happen — usually without a massive organizational overhaul.&lt;/p&gt;

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

&lt;p&gt;AWS is a phenomenal platform. But the flexibility that makes it powerful is the same flexibility that makes it expensive if you're not intentional.&lt;/p&gt;

&lt;p&gt;The companies I've seen manage this well all share a few traits: they treat cost as a design constraint rather than an afterthought, they create accountability through tagging and visibility, and they build lightweight processes that keep optimization from being a heroic one-off effort.&lt;/p&gt;

&lt;p&gt;None of this is rocket science. Most of it isn't even hard. It just requires someone to care enough to make it a priority — and to keep caring about it month after month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's your experience?&lt;/strong&gt; Have you found specific tools or practices that helped rein in cloud spend? I'd love to hear what's worked (or spectacularly hasn't) for your team.&lt;/p&gt;

&lt;p&gt;I lead platform engineering for fintech. Read the full case study &lt;a href="https://nazmul-ahasan.pro.bd/case-studies/cloud-cost-optimisation.html" rel="noopener noreferrer"&gt;here&lt;/a&gt; or see how we can work together at &lt;a href="https://nazmul-ahasan.pro.bd/work-with-me/" rel="noopener noreferrer"&gt;Nazmul Ahasan&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>finops</category>
      <category>aws</category>
      <category>cloud</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
