<?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: Ali Shaikh</title>
    <description>The latest articles on DEV Community by Ali Shaikh (@alishaikh).</description>
    <link>https://dev.to/alishaikh</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%2F101994%2F889627d8-471f-4871-95f6-55b2baab77cb.png</url>
      <title>DEV Community: Ali Shaikh</title>
      <link>https://dev.to/alishaikh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alishaikh"/>
    <language>en</language>
    <item>
      <title>Meet EkkoJS: The JavaScript Runtime That Doesn't Apologise for Starting Fresh</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Wed, 17 Jun 2026 06:29:01 +0000</pubDate>
      <link>https://dev.to/alishaikh/meet-ekkojs-the-javascript-runtime-that-doesnt-apologise-for-starting-fresh-3p8d</link>
      <guid>https://dev.to/alishaikh/meet-ekkojs-the-javascript-runtime-that-doesnt-apologise-for-starting-fresh-3p8d</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F556ou9x3ijq7awdyjd0c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F556ou9x3ijq7awdyjd0c.png" alt="Meet EkkoJS: The JavaScript Runtime That Doesn't Apologise for Starting Fresh" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's a moment in every developer's day where they type &lt;code&gt;npm install&lt;/code&gt; and watch hundreds of packages materialise from the internet. You didn't ask for most of them. A few are transitive dependencies you'll never audit. One or two probably have CVEs filed against them right now. And somehow, this became normal.&lt;/p&gt;

&lt;p&gt;EkkoJS is a bet that it doesn't have to be.&lt;/p&gt;

&lt;p&gt;Built by the team at &lt;a href="https://amplanetwork.com/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Ampla Network&lt;/a&gt; and open-sourced under MIT, EkkoJS is a JavaScript and TypeScript runtime that trades backward compatibility for a cleaner slate. No CommonJS. No &lt;code&gt;node_modules&lt;/code&gt;. No URL imports. Just ES modules, TypeScript that runs without a build step, security baked in at the permission level, and a standard library big enough that you won't spend the first hour of a new project hunting for packages.&lt;/p&gt;

&lt;p&gt;It's v0.8.3 and labelled a Technology Preview as of June 2026. That means it's not ready for production systems yet. But it's already interesting enough to warrant a proper look.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's actually running your code
&lt;/h2&gt;

&lt;p&gt;The architecture is the first thing that sets EkkoJS apart. Instead of sitting on top of a single runtime engine, it layers three technologies together.&lt;/p&gt;

&lt;p&gt;At the outermost layer is a &lt;strong&gt;Rust host&lt;/strong&gt; running on the Tokio async loop. Rust handles the CLI, the process lifecycle, the permission enforcement, and the FFI bridge. It's fast and has no garbage collector, which keeps latency predictable. Inside that, &lt;strong&gt;V8&lt;/strong&gt; executes your JavaScript (and your TypeScript, after SWC transpiles it in-process). Below V8, native operations (file I/O, cryptography, HTTP) cross the FFI boundary into a &lt;strong&gt;.NET 10 AOT-compiled layer&lt;/strong&gt;. Ahead-of-time compilation means that layer is native machine code, not JIT-compiled at startup.&lt;/p&gt;

&lt;p&gt;The result: your script starts quickly, and the path from your TypeScript source to native system calls is about as short as it can get without writing Rust directly.&lt;/p&gt;

&lt;p&gt;Here's the thing: you don't feel any of this complexity as a user. You just point &lt;code&gt;ekko&lt;/code&gt; at a TypeScript file and it runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The philosophy behind it
&lt;/h2&gt;

&lt;p&gt;Francois, the creator of EkkoJS, has been writing JavaScript since 2001. In his own words from the EkkoJS site: "I never felt fully comfortable with the direction JavaScript runtimes took. I kept dreaming of something different."&lt;/p&gt;

&lt;p&gt;That frustration shapes every design decision in the runtime. Three stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ESM only, local only.&lt;/strong&gt; Every import in an EkkoJS program resolves to either a local file or a declared package. URL imports (the kind you might have seen in Deno or CDN-style scripts) are a compile-time error. This isn't just aesthetic; it closes a whole category of supply chain risk. An attacker can't slip a malicious CDN URL into your dependency graph if the runtime refuses to fetch code from the internet at import time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permissions as a first-class concern.&lt;/strong&gt; Programs start with zero access to the file system, network, or environment variables. You grant what you need at the command line. Running a server that reads one database file looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ekko run &lt;span class="nt"&gt;--allow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;net,fs:./app.db server.ts

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the server tries to read &lt;code&gt;/etc/passwd&lt;/code&gt; or make a request to an unexpected host, it throws. The attack surface shrinks to exactly what you declared.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript without the ceremony.&lt;/strong&gt; No &lt;code&gt;tsconfig.json&lt;/code&gt;, no separate compile step, no output directory to manage. SWC transpiles inline, the V8 isolate picks it up, and your &lt;code&gt;.ts&lt;/code&gt; file just runs. For small scripts and prototypes this alone saves a disproportionate amount of setup friction.&lt;/p&gt;




&lt;h2&gt;
  
  
  Batteries genuinely included
&lt;/h2&gt;

&lt;p&gt;Over thirty modules live under the &lt;code&gt;ekko:&lt;/code&gt; namespace. HTTP server and client. A SQL database with an ORM that supports PostgreSQL, MySQL, MSSQL, and MongoDB. Authentication with RBAC, JWT, OAuth, and TOTP. GraphQL. WebSockets. Cron and job queues. Image processing. Compression. A full-stack React framework called Rune. Even desktop and TUI app toolkits.&lt;/p&gt;

&lt;p&gt;The HTTP server, for instance, is backed by ASP.NET Kestrel (that .NET AOT layer doing work) and comes with Express-style routing, rate limiting, CORS, and security headers built in:&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;createServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;helmet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rateLimit&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;ekko:web&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;port&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="na"&gt;compression&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="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cors&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;helmet&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/users/:id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Express install. No Helmet install. No cors package. All of it ships with the runtime.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does it perform?
&lt;/h2&gt;

&lt;p&gt;Honestly, it's competitive but not dominant. And the team is transparent about that. In their own benchmark table (run on an AMD EPYC 7451, 48 threads):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;EkkoJS finishes &lt;strong&gt;second overall&lt;/strong&gt; across 35 benchmarks, behind Bun but ahead of Node.js and Deno&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;wins the concurrency and threading category outright&lt;/strong&gt; , with thread spawn overhead of 265/s versus Node.js at 23/s and Bun at 103/s&lt;/li&gt;
&lt;li&gt;8-way parallel scaling hits 638 ops/s against Node.js at 532 and Deno at 202&lt;/li&gt;
&lt;li&gt;File read performance beats Node.js for binary reads, and Deno across the board&lt;/li&gt;
&lt;li&gt;Bun leads raw single-threaded throughput, especially on maths and regex&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest note at the bottom of the benchmarks page: "EkkoJS is a Technology Preview. We do not pretend to compete yet with Node.js, Bun, or Deno, which are production-grade, stable, and have been battle-tested for years. The comparison is a compass for our own progress."&lt;/p&gt;

&lt;p&gt;That kind of candour is rare and worth noting.&lt;/p&gt;




&lt;h2&gt;
  
  
  A real example: talking to S3 via LocalStack
&lt;/h2&gt;

&lt;p&gt;Here's where things get practical. Let's build a small script that uses EkkoJS to interact with an S3 bucket through &lt;a href="https://localstack.cloud/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack&lt;/a&gt;, the local AWS emulator. This demo creates a bucket, uploads a JSON document, then retrieves and logs it.&lt;/p&gt;

&lt;p&gt;EkkoJS's &lt;code&gt;ekko:crypto&lt;/code&gt; module provides HMAC-SHA256, which is all you need for &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;AWS Signature Version 4&lt;/a&gt;. No AWS SDK. No &lt;code&gt;node_modules&lt;/code&gt;. Just built-in modules and a handful of lines of TypeScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt; Docker running with LocalStack (&lt;code&gt;docker run -p 4566:4566 localstack/localstack&lt;/code&gt;), and EkkoJS installed. If LocalStack is new to you, I covered it in depth across a &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;9-part build series&lt;/a&gt;. Part 1 starts with S3 and presigned URLs, which maps nicely to what we're doing here.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;s3-demo.ts&lt;/code&gt;
&lt;/h3&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;fetch&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;ekko:web&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;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hash&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;ekko:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// LocalStack defaults. Swap these for real AWS credentials and endpoint in prod.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REGION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:4566&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;ACCESS_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test&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;SECRET_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test&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;BUCKET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ekko-demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ─── AWS Signature V4 helpers ─────────────────────────────────────────────────&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;toHex&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;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hmacSha256&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="nb"&gt;Uint8Array&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signedHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;extraHeaders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&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="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&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;Date&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;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// YYYYMMDD&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;datetime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;:-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&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;Z&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;payloadHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&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;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&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="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-amz-date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-amz-content-sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payloadHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;extraHeaders&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;signedHeaderNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canonicalHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;canonicalRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;canonicalHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;signedHeaderNames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;payloadHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;credentialScope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;REGION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/aws4_request`&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;stringToSign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AWS4-HMAC-SHA256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;credentialScope&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;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonicalRequest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;signingKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hmacSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`AWS4&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&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;k2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hmacSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;REGION&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;k3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hmacSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;hmacSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aws4_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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toHex&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;hmacSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signingKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stringToSign&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="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;`AWS4-HMAC-SHA256 Credential=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;credentialScope&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`SignedHeaders=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;signedHeaderNames&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`Signature=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ─── S3 operations ────────────────────────────────────────────────────────────&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;createBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucket&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signedHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Create bucket: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;putObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;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="nx"&gt;body&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signedHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Upload "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;": &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signedHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ─── Run ──────────────────────────────────────────────────────────────────────&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createBucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BUCKET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EkkoJS&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.8.3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hello&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world&lt;/span&gt;&lt;span class="dl"&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;putObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retrieved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Retrieved:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ekko run &lt;span class="nt"&gt;--allow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;crypto,net s3-demo.ts

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;--allow=crypto,net&lt;/code&gt; is EkkoJS's permission model at work: &lt;code&gt;crypto&lt;/code&gt; gates the cryptography module, and &lt;code&gt;net&lt;/code&gt; grants outbound network access. What's worth noting is how you discover these flags. The runtime tells you exactly what's missing. Forget &lt;code&gt;--allow=crypto&lt;/code&gt; and you get &lt;code&gt;PermissionError: crypto access denied. Run with --allow=crypto&lt;/code&gt;. Add it and forget &lt;code&gt;--allow=net&lt;/code&gt; and you get &lt;code&gt;PermissionError: net access denied for 'http://localhost:4566/...'&lt;/code&gt;. Each error names the flag and the resource it blocked. You build up the permission set incrementally, with no guessing. The script simply cannot reach anything outside what you declared.&lt;/p&gt;

&lt;p&gt;The output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create bucket: 200
Upload "hello.json": 200
Retrieved: {"runtime":"EkkoJS","version":"0.8.3","hello":"world"}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvoqx1hdiaytdvbvd6cdi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvoqx1hdiaytdvbvd6cdi.png" alt="Meet EkkoJS: The JavaScript Runtime That Doesn't Apologise for Starting Fresh" width="800" height="308"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Ekko Terminal Commands&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;No packages installed. No &lt;code&gt;package.json&lt;/code&gt;. No build output folder. Just a TypeScript file and a runtime.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsu0gfisgfyiezdnhw2fc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsu0gfisgfyiezdnhw2fc.png" alt="Meet EkkoJS: The JavaScript Runtime That Doesn't Apologise for Starting Fresh" width="800" height="486"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;S3 Bucket view in Cloud Sprocket&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The wider ecosystem
&lt;/h2&gt;

&lt;p&gt;EkkoJS doesn't stand alone. Three companion projects round it out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rune&lt;/strong&gt; (&lt;code&gt;rune.ekkojs.com&lt;/code&gt;) is the full-stack React framework built into the runtime. Server rendering by default, client-side navigation after the first load, and Mimir for state that survives a full browser refresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bifrost&lt;/strong&gt; (&lt;code&gt;bifrost.ekkojs.com&lt;/code&gt;) is the package registry. Because EkkoJS refuses URL imports, it needs its own distribution channel. Bifrost is that channel. Think npm, but scoped to EkkoJS-compatible ESM packages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asgard&lt;/strong&gt; (&lt;code&gt;asgard.ekkojs.com&lt;/code&gt;) is the official component suite for Rune — 30+ accessible, themeable UI components that are server-rendered out of the box. Add it to any EkkoJS project with &lt;code&gt;ekko add @ekko/asgard&lt;/code&gt;, wrap your app in a &lt;code&gt;ThemeProvider&lt;/code&gt;, and you have a full component library without touching npm.&lt;/p&gt;




&lt;h2&gt;
  
  
  Should you use it now?
&lt;/h2&gt;

&lt;p&gt;Probably not in production. The team is explicit about that: v0.8.x is a Technology Preview, and breaking changes are expected before the first production release later in 2026.&lt;/p&gt;

&lt;p&gt;But for experimenting? For internal tooling where you control the environment? For understanding where JavaScript runtimes might go? Yes, absolutely. EkkoJS has ideas worth spending time with.&lt;/p&gt;

&lt;p&gt;The permission model is something I wish Node.js had shipped with. The ESM-only stance closes supply chain vectors that the rest of the ecosystem still works around. The .NET AOT layer for native operations is an unusual and clever choice. And the honest benchmark page, including the numbers EkkoJS doesn't win, is a refreshing signal about the team behind it.&lt;/p&gt;

&lt;p&gt;It's early. The echo is just starting to resonate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;EkkoJS is open source under the MIT licence. Find the runtime at&lt;/em&gt; &lt;a href="https://ekkojs.com/?ref=alishaikh.me" rel="noopener noreferrer"&gt;&lt;em&gt;ekkojs.com&lt;/em&gt;&lt;/a&gt; &lt;em&gt;and the source at&lt;/em&gt; &lt;a href="https://github.com/e-mc2-dev/ekkojs?ref=alishaikh.me" rel="noopener noreferrer"&gt;&lt;em&gt;github.com/e-mc2-dev/ekkojs&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ekkojs</category>
      <category>typescript</category>
      <category>backenddev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 5 - SQS, SNS, and a Dead-Letter Queue)</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Tue, 26 May 2026 19:41:38 +0000</pubDate>
      <link>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-5-sqs-sns-and-a-dead-letter-28gc</link>
      <guid>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-5-sqs-sns-and-a-dead-letter-28gc</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F365nbwniyj43jget6ovp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F365nbwniyj43jget6ovp.png" alt="Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 5 - SQS, SNS, and a Dead-Letter Queue)"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every real app eventually needs background jobs: sending welcome emails, processing notifications, retrying webhooks. We'll build the canonical AWS pattern on LocalStack: an SNS topic for fan-out, an SQS queue for the job, a dead-letter queue for poison messages, and a redrive policy that ties them together.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What you'll need:&lt;/strong&gt; &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Part 0 setup&lt;/a&gt;, Python 3.x with &lt;code&gt;boto3&lt;/code&gt;, and 30 minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;The three components work together like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;producer (CLI/app)
        │
        ▼
   SNS topic ─── fan-out ───┬───▶ SQS welcome-emails queue
                            │ │
                            │ ▼
                            │ consumer (poll → process → delete)
                            │ │
                            │ │ fails 3 times
                            │ ▼
                            │ SQS welcome-emails-dlq
                            │
                            └───▶ (other subscribers - analytics, audit, etc.)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shape that maps onto pretty much every real app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SNS topic&lt;/strong&gt; for fan-out - many subscribers can listen to the same event without the producer caring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQS queue&lt;/strong&gt; for the job - durable, at-least-once delivery with retries and a DLQ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead-letter queue&lt;/strong&gt; for poison messages - anything that fails repeatedly lands here for inspection rather than blocking the queue forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a real app, the producer might be your signup API publishing a &lt;code&gt;user.created&lt;/code&gt; event, or an image-processing pipeline publishing &lt;code&gt;photo.ready&lt;/code&gt;. The consumer might send a welcome email, create a CRM contact, fire a webhook to another system, or queue a follow-up job for indexing and search. We'll build the polling worker because it's the easiest way to watch the full retry and dead-letter flow without extra moving parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SNS &lt;em&gt;and&lt;/em&gt; SQS, not just one?
&lt;/h2&gt;

&lt;p&gt;Quick answer if you've not used either at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SQS alone&lt;/strong&gt; works fine for one producer talking to one consumer. The moment you want a second consumer (analytics, audit log, notifications system) listening to the same events, you'd have to fan out manually or put a copy on each queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SNS alone&lt;/strong&gt; fans out messages to subscribers, but it doesn't give you queue buffering or consumer-side retry behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SNS → SQS → consumer&lt;/strong&gt; is the combination. SNS handles fan-out, SQS gives you durability and retries, the DLQ catches what slips through.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's three pieces of infrastructure and most beginners skip straight to "I'll just call my consumer directly". Six months later, that's usually the choice they wish they hadn't made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Project folder
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/localstack-series
&lt;span class="nb"&gt;mkdir &lt;/span&gt;part5-queues
&lt;span class="nb"&gt;cd &lt;/span&gt;part5-queues

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Command style in this part
&lt;/h2&gt;

&lt;p&gt;This part uses &lt;code&gt;awslocal&lt;/code&gt; in the main examples because it's shorter. If you prefer the plain AWS CLI, use the same command with &lt;code&gt;aws --endpoint-url=http://localhost:4566 ...&lt;/code&gt; instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# awslocal&lt;/span&gt;
awslocal sqs list-queues

&lt;span class="c"&gt;# aws cli&lt;/span&gt;
aws &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:4566 sqs list-queues

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your LocalStack box lives on another IP, swap &lt;code&gt;localhost&lt;/code&gt; for that host in the &lt;code&gt;--endpoint-url&lt;/code&gt; value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create the dead-letter queue first
&lt;/h2&gt;

&lt;p&gt;You need the DLQ's ARN before you can create the main queue (the redrive policy references it).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal sqs create-queue &lt;span class="nt"&gt;--queue-name&lt;/span&gt; welcome-emails-dlq

&lt;span class="nv"&gt;DLQ_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sqs get-queue-url &lt;span class="nt"&gt;--queue-name&lt;/span&gt; welcome-emails-dlq &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; QueueUrl &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;DLQ_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sqs get-queue-attributes &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="nv"&gt;$DLQ_URL&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attribute-names&lt;/span&gt; QueueArn &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Attributes.QueueArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$DLQ_ARN&lt;/span&gt;
&lt;span class="c"&gt;# arn:aws:sqs:us-east-1:000000000000:welcome-emails-dlq&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Create the main queue with a redrive policy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal sqs create-queue &lt;span class="nt"&gt;--queue-name&lt;/span&gt; welcome-emails &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attributes&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;RedrivePolicy&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;{&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;deadLetterTargetArn&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="nv"&gt;$DLQ_ARN&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;maxReceiveCount&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;3&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;VisibilityTimeout&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;5&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;

&lt;span class="nv"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sqs get-queue-url &lt;span class="nt"&gt;--queue-name&lt;/span&gt; welcome-emails &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; QueueUrl &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;QUEUE_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sqs get-queue-attributes &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="nv"&gt;$QUEUE_URL&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attribute-names&lt;/span&gt; QueueArn &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Attributes.QueueArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two attributes worth understanding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maxReceiveCount: 3&lt;/code&gt;&lt;/strong&gt; - after three receives without a successful delete, the message gets moved to the DLQ. The retry budget. Three is a sane default; raise it if your downstream is flaky.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VisibilityTimeout: 5&lt;/code&gt;&lt;/strong&gt; seconds - when a consumer receives a message, it becomes invisible to other consumers for this long. If the consumer doesn't delete the message in that window, it becomes eligible to be received again. We're setting it short for testing; in production it should be comfortably longer than your longest expected processing time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The escaping on that JSON-inside-JSON &lt;code&gt;--attributes&lt;/code&gt; parameter is annoying - it's the AWS CLI's way of life. A cleaner alternative is &lt;code&gt;--attributes file://attributes.json&lt;/code&gt; with a properly-quoted file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Create the SNS topic and subscribe the queue
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOPIC_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sns create-topic &lt;span class="nt"&gt;--name&lt;/span&gt; user-events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'TopicArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;SUB_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sns subscribe &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--topic-arn&lt;/span&gt; &lt;span class="nv"&gt;$TOPIC_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol&lt;/span&gt; sqs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--notification-endpoint&lt;/span&gt; &lt;span class="nv"&gt;$QUEUE_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'SubscriptionArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set raw message delivery so SQS receives just the message body, not the full SNS envelope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal sns set-subscription-attributes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subscription-arn&lt;/span&gt; &lt;span class="nv"&gt;$SUB_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attribute-name&lt;/span&gt; RawMessageDelivery &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attribute-value&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;RawMessageDelivery=true&lt;/code&gt;, the consumer would have to parse SNS's JSON wrapper (&lt;code&gt;{ "Type": "Notification", "MessageId": "...", "Message": "&amp;lt;your actual message&amp;gt;" }&lt;/code&gt;) on every receive. With it on, the consumer just gets your message body. Almost always what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: The consumer
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;consumer.py&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;The key behaviour to notice before you read the code: &lt;strong&gt;&lt;code&gt;sqs.delete_message(...)&lt;/code&gt; is only called on success&lt;/strong&gt;. If the consumer raises, no delete happens, the visibility timeout expires, the message becomes eligible to be received again, and the receive count ticks up. After three receives without a successful delete, SQS moves it to the DLQ.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS_ENDPOINT_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:4566&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;QUEUE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;sqs&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;sqs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message_body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message_body&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;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fail@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&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;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Simulated downstream failure&lt;/span&gt;&lt;span class="sh"&gt;"&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; ✓ Sent welcome email to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (user_id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&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;def&lt;/span&gt; &lt;span class="nf"&gt;poll_once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;QueueUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;MaxNumberOfMessages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;max_messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;WaitTimeSeconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;wait_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Messages&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="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;Polled - &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;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; message(s)&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;msg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&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; ✗ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; - leaving message in queue for retry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QueueUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceiptHandle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReceiptHandle&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; __main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;poll_once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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="k"&gt;if&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WaitTimeSeconds&lt;/code&gt; enables long polling - the consumer waits up to N seconds for a message rather than returning empty immediately. Long polling drops API costs and improves latency. Use it.&lt;/p&gt;

&lt;p&gt;If your LocalStack box lives on another IP, export &lt;code&gt;AWS_ENDPOINT_URL=http://&amp;lt;that-ip&amp;gt;:4566&lt;/code&gt; before running the consumer. The default &lt;code&gt;localhost:4566&lt;/code&gt; is correct only when the worker and LocalStack are on the same machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Test the happy path and the poison path together
&lt;/h2&gt;

&lt;p&gt;Publish three messages - two clean, one deliberately failing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal sns publish &lt;span class="nt"&gt;--topic-arn&lt;/span&gt; &lt;span class="nv"&gt;$TOPIC_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s1"&gt;'{"user_id":"u-1","email":"alice@example.com"}'&lt;/span&gt;

awslocal sns publish &lt;span class="nt"&gt;--topic-arn&lt;/span&gt; &lt;span class="nv"&gt;$TOPIC_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s1"&gt;'{"user_id":"u-2","email":"fail@example.com"}'&lt;/span&gt;

awslocal sns publish &lt;span class="nt"&gt;--topic-arn&lt;/span&gt; &lt;span class="nv"&gt;$TOPIC_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s1"&gt;'{"user_id":"u-3","email":"bob@example.com"}'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the consumer four times, with a 6-second pause between each (long enough for the visibility timeout to release the poison message back into the queue):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_URL&lt;/span&gt; python3 consumer.py
&lt;span class="c"&gt;# Pass 1&lt;/span&gt;
Polled - 3 message&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt;
  ✓ Sent welcome email to alice@example.com &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;u-1&lt;span class="o"&gt;)&lt;/span&gt;
  ✗ Simulated downstream failure - leaving message &lt;span class="k"&gt;in &lt;/span&gt;queue &lt;span class="k"&gt;for &lt;/span&gt;retry
  ✓ Sent welcome email to bob@example.com &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;u-3&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;sleep &lt;/span&gt;6 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_URL&lt;/span&gt; python3 consumer.py
&lt;span class="c"&gt;# Pass 2&lt;/span&gt;
Polled - 1 message&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt;
  ✗ Simulated downstream failure - leaving message &lt;span class="k"&gt;in &lt;/span&gt;queue &lt;span class="k"&gt;for &lt;/span&gt;retry

&lt;span class="nb"&gt;sleep &lt;/span&gt;6 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_URL&lt;/span&gt; python3 consumer.py
&lt;span class="c"&gt;# Pass 3&lt;/span&gt;
Polled - 1 message&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt;
  ✗ Simulated downstream failure - leaving message &lt;span class="k"&gt;in &lt;/span&gt;queue &lt;span class="k"&gt;for &lt;/span&gt;retry

&lt;span class="nb"&gt;sleep &lt;/span&gt;6 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_URL&lt;/span&gt; python3 consumer.py
&lt;span class="c"&gt;# Pass 4&lt;/span&gt;
Polled - 0 message&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The poison message has been moved to the DLQ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal sqs receive-message &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="nv"&gt;$DLQ_URL&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-number-of-messages&lt;/span&gt; 5 &lt;span class="nt"&gt;--wait-time-seconds&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Messages[*].Body'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="c"&gt;# {"user_id":"u-2","email":"fail@example.com"}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. The two well-formed messages drained on the first pass. The poison message reached &lt;code&gt;maxReceiveCount=3&lt;/code&gt; without a successful delete, disappeared from the main queue after the third failed receive, and showed up in the DLQ by the time the fourth poll ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do with messages in the DLQ
&lt;/h2&gt;

&lt;p&gt;Don't just leave them there forever. The DLQ is an inbox, not a graveyard. Two patterns worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inspect and replay.&lt;/strong&gt; Pull the messages, fix whatever broke (a bug in the consumer, a third-party outage), then either re-publish them to the topic or move them back to the main queue with &lt;code&gt;aws sqs start-message-move-task&lt;/code&gt;. That's the normal replay API on AWS. On the 2026-05-26 homelab LocalStack rerun, the command completed but did not actually move the message, so re-publishing to the topic is the reliable local fallback there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarm on depth.&lt;/strong&gt; A non-zero DLQ depth means &lt;em&gt;someone needs to look at this&lt;/em&gt;. Wire a CloudWatch alarm on &lt;code&gt;ApproximateNumberOfMessagesVisible&lt;/code&gt; and route it to PagerDuty / Opsgenie / your phone. We'll touch alarms in a later article.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wiring a Lambda consumer instead (the production pattern)
&lt;/h2&gt;

&lt;p&gt;What we built above polls in a loop, which is perfect for learning and fine for a small service. In production, most teams use a Lambda triggered by SQS via an "event source mapping". Same redrive behaviour, no Python worker to keep alive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# (Build a Lambda using the deployment flow from [Part 3](https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-3-lambda-s3-thumbnailer-pipeline/) or [Part 4](https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-4-api-gateway-lambda-and-jwt-auth/), then:)&lt;/span&gt;
awslocal lambda create-event-source-mapping &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; welcome-mailer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--event-source-arn&lt;/span&gt; &lt;span class="nv"&gt;$QUEUE_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--batch-size&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--maximum-batching-window-in-seconds&lt;/span&gt; 5

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Lambda polls SQS, invokes your function with batches of up to 10 messages, and the same queue visibility timeout and DLQ policy still apply. Failures bubble up the same way.&lt;/p&gt;

&lt;p&gt;The polling consumer is the right thing to build first because every step is observable. The Lambda version is the right thing to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Message lands in the queue but the consumer never sees it.&lt;/strong&gt; You forgot &lt;code&gt;RawMessageDelivery=true&lt;/code&gt; on the subscription, and your &lt;code&gt;json.loads(message_body)&lt;/code&gt; is choking on the SNS envelope. Either set the flag or &lt;code&gt;json.loads(json.loads(body)["Message"])&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poison message never goes to the DLQ.&lt;/strong&gt; &lt;code&gt;maxReceiveCount&lt;/code&gt; is too high, or the consumer is silently swallowing the exception and deleting the message. Print failures and don't catch broadly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messages that should be dead-lettered come back forever.&lt;/strong&gt; The visibility timeout is shorter than your processing time. The consumer is still working when SQS thinks it's hung, so the message comes back. Make &lt;code&gt;VisibilityTimeout&lt;/code&gt; comfortably longer than your worst-case job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;InvalidParameterValueException&lt;/code&gt; on &lt;code&gt;create-queue&lt;/code&gt;.&lt;/strong&gt; The redrive policy JSON is malformed (the escaping on the CLI is fiddly). Use a &lt;code&gt;--attributes file://attrs.json&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue policy permission errors when SNS tries to deliver.&lt;/strong&gt; LocalStack's Hobby tier doesn't enforce IAM strictly, so this is rare on LocalStack but bites in real AWS - set the queue policy to allow &lt;code&gt;sns.amazonaws.com&lt;/code&gt; to send.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cleanup commands worth knowing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Drain all messages&lt;/span&gt;
awslocal sqs purge-queue &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="nv"&gt;$QUEUE_URL&lt;/span&gt;
awslocal sqs purge-queue &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="nv"&gt;$DLQ_URL&lt;/span&gt;

&lt;span class="c"&gt;# Remove the queues and topic&lt;/span&gt;
awslocal sqs delete-queue &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="nv"&gt;$QUEUE_URL&lt;/span&gt;
awslocal sqs delete-queue &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="nv"&gt;$DLQ_URL&lt;/span&gt;
awslocal sns delete-topic &lt;span class="nt"&gt;--topic-arn&lt;/span&gt; &lt;span class="nv"&gt;$TOPIC_ARN&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're going to Part 6, keep the topic. Step Functions can publish to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Save this as a checkpoint
&lt;/h2&gt;

&lt;p&gt;The full queue topology, DLQ, main queue with redrive policy, topic, and the raw-delivery subscription, re-creates itself on every container start.&lt;/p&gt;

&lt;p&gt;Save as &lt;code&gt;init/ready.d/05-part5-queues.sh&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;The names in this script are fixed on purpose. That keeps the bootstrap predictable across restarts and avoids creating a fresh queue/topic pair every time the container comes back up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Part 5 checkpoint - SQS welcome-emails + DLQ + SNS user-events topic&lt;/span&gt;
awslocal sqs create-queue &lt;span class="nt"&gt;--queue-name&lt;/span&gt; welcome-emails-dlq 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;&lt;span class="nv"&gt;DLQ_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sqs get-queue-url &lt;span class="nt"&gt;--queue-name&lt;/span&gt; welcome-emails-dlq &lt;span class="nt"&gt;--query&lt;/span&gt; QueueUrl &lt;span class="nt"&gt;--output&lt;/span&gt; text 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DLQ_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sqs get-queue-attributes &lt;span class="nt"&gt;--queue-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DLQ_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attribute-names&lt;/span&gt; QueueArn &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Attributes.QueueArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;

awslocal sqs create-queue &lt;span class="nt"&gt;--queue-name&lt;/span&gt; welcome-emails &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attributes&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;RedrivePolicy&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;{&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;deadLetterTargetArn&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="nv"&gt;$DLQ_ARN&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;maxReceiveCount&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;3&lt;/span&gt;&lt;span class="se"&gt;\\\"&lt;/span&gt;&lt;span class="s2"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;VisibilityTimeout&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;5&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;awslocal sns create-topic &lt;span class="nt"&gt;--name&lt;/span&gt; user-events 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;&lt;span class="nv"&gt;QUEUE_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:sqs:us-east-1:000000000000:welcome-emails"&lt;/span&gt;
&lt;span class="nv"&gt;TOPIC_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:sns:us-east-1:000000000000:user-events"&lt;/span&gt;

&lt;span class="c"&gt;# Avoid creating duplicate subscriptions on each restart&lt;/span&gt;
&lt;span class="nv"&gt;EXISTING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sns list-subscriptions-by-topic &lt;span class="nt"&gt;--topic-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOPIC_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;--query&lt;/span&gt; &lt;span class="s2"&gt;"Subscriptions[?Endpoint=='&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_ARN&lt;/span&gt;&lt;span class="s2"&gt;'].SubscriptionArn"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXISTING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXISTING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"None"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;SUB_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal sns subscribe &lt;span class="nt"&gt;--topic-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOPIC_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--protocol&lt;/span&gt; sqs &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--notification-endpoint&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'SubscriptionArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  awslocal sns set-subscription-attributes &lt;span class="nt"&gt;--subscription-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SUB_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;--attribute-name&lt;/span&gt; RawMessageDelivery &lt;span class="nt"&gt;--attribute-value&lt;/span&gt; &lt;span class="nb"&gt;true &lt;/span&gt;2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[bootstrap] part 5 - queues + topic + subscription ready"&lt;/span&gt;


&lt;span class="nb"&gt;chmod&lt;/span&gt; +x init/ready.d/05-part5-queues.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Jumping in at Part 5 from scratch?&lt;/strong&gt; You only need &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Part 0 setup&lt;/a&gt; for the queue topology itself. If you want to test the "producer from the shorten Lambda" story later, keep Part 4 around too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'll wire up next
&lt;/h2&gt;

&lt;p&gt;You've got fan-out, queueing, retries, and dead-lettering - the building blocks of every real async pipeline. &lt;strong&gt;The next part&lt;/strong&gt; graduates from "send one message" to orchestration: an EventBridge rule starts a Step Functions state machine that fans across multiple Lambdas (resize → tag → notify → archive). The kind of workflow that's too gnarly to express in plain queue messaging and is exactly what Step Functions exists for.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The full series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Part 0&lt;/a&gt; - Start here: series intro and installing LocalStack&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; - S3 locally: buckets, presigned URLs, and a tiny photo uploader&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-2-dynamodb-url-shortener-data-layer/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; - DynamoDB locally: building a URL shortener data layer&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-3-lambda-s3-thumbnailer-pipeline/" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt; - Lambda + S3 events: an image thumbnailer pipeline&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-4-api-gateway-lambda-and-jwt-auth/" rel="noopener noreferrer"&gt;Part 4&lt;/a&gt; - API Gateway + Lambda + JWT auth: a real HTTP API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5&lt;/strong&gt; - SQS + SNS: a background job queue with a dead-letter queue &lt;em&gt;(this article)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 6 - EventBridge + Step Functions: orchestrating a photo-processing workflow &lt;em&gt;(next)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 7 - Secrets Manager + KMS: handling secrets and encryption locally&lt;/li&gt;
&lt;li&gt;Part 8 - Terraform (tflocal) + GitHub Actions: integration tests against LocalStack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;AWS SQS dead-letter queues&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/sns/latest/dg/sns-large-payload-raw-message-delivery.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;SNS raw message delivery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/aws/services/sqs/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack SQS docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Related on alishaikh.me&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/localstack-run-aws-services-locally-for-free/" rel="noopener noreferrer"&gt;LocalStack: Run AWS Services Locally for Free&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>sns</category>
      <category>sqs</category>
      <category>dlq</category>
      <category>aws</category>
    </item>
    <item>
      <title>Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 4 - API Gateway, Lambda, and JWT Auth)</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Sun, 17 May 2026 13:55:09 +0000</pubDate>
      <link>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-4-api-gateway-lambda-and-jwt-2igd</link>
      <guid>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-4-api-gateway-lambda-and-jwt-2igd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc4mj03ti312xu9378ide.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc4mj03ti312xu9378ide.png" alt="Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 4 - API Gateway, Lambda, and JWT Auth)" width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The URL shortener from&lt;/strong&gt; &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-2-dynamodb-url-shortener-data-layer/" rel="noopener noreferrer"&gt;&lt;strong&gt;Part 2 - DynamoDB URL Shortener Data Layer&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;needs an HTTP front door. We'll put API Gateway in front of it, back it with two Lambdas, protect &lt;code&gt;POST /shorten&lt;/code&gt; with a third Lambda authoriser, and pass the JWT subject all the way through to DynamoDB. Commands and outputs below were verified against a real LocalStack instance.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What you'll need:&lt;/strong&gt; &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Part 0 setup&lt;/a&gt; and &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-2-dynamodb-url-shortener-data-layer/" rel="noopener noreferrer"&gt;Part 2 finished&lt;/a&gt; (we'll reuse the &lt;code&gt;shortlinks&lt;/code&gt; table), Python 3.12, Docker, &lt;code&gt;curl&lt;/code&gt;, and 60 minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                       ┌─────────────────────┐
client (curl/browser) ─▶ POST /dev/shorten │
                       │ with JWT in header │
                       └──────────┬──────────┘
                                  │
                       ┌──────────▼──────────┐
                       │ authorizer Lambda │
                       │ (validates JWT) │
                       └──────────┬──────────┘
                                  │ isAuthorized: true
                       ┌──────────▼──────────┐ ┌────────────────┐
                       │ shorten Lambda │───▶│ DynamoDB │
                       └─────────────────────┘ │ shortlinks │
                                                  │ │
client ──────────────▶ GET /dev/r/{code} ────▶│ redirect │
                                                  │ Lambda │
                                                  └────────────────┘
                              302 redirect ◀──────────┘

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three pieces of code. One HTTP API gluing them together. By the end of this article you can &lt;code&gt;curl -X POST&lt;/code&gt; a shortener running on LocalStack from your terminal - same shape as a real production API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HTTP API rather than REST API
&lt;/h2&gt;

&lt;p&gt;API Gateway has two flavours: REST API (v1) and HTTP API (v2). Both work on LocalStack. Use HTTP API unless you have a specific reason not to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP API has lower latency, much simpler config, and ~70% lower cost in real AWS.&lt;/li&gt;
&lt;li&gt;REST API is older and has more features (request validators, SDK generation, usage plans). If you don't need those, skip it.&lt;/li&gt;
&lt;li&gt;Lambda authorisers in HTTP API have a "simple response" mode (&lt;code&gt;{ "isAuthorized": true }&lt;/code&gt;) that REST API doesn't. Cleaner to write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything in this article uses HTTP API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Project layout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/localstack-series
&lt;span class="nb"&gt;mkdir &lt;/span&gt;part4-api
&lt;span class="nb"&gt;cd &lt;/span&gt;part4-api
&lt;span class="nb"&gt;mkdir &lt;/span&gt;shorten redirect authorizer

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three folders, one Lambda each. Plus a token-signing helper at the project root.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The shorten Lambda
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;shorten/handler.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;botocore.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ClientError&lt;/span&gt;

&lt;span class="n"&gt;ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS_ENDPOINT_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost.localstack.cloud:4566&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TABLE&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;shortlinks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;ddb&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;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;random_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ascii_lowercase&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;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="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&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;error&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;invalid JSON body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;long_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;long_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;random_code&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;long_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&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;error&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;long_url is required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requestContext&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorizer&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lambda&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="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;userId&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;anonymous&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;TableName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&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;code&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;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;long_url&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;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner_id&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;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;click_count&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;N&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;0&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="n"&gt;ConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attribute_not_exists(code)&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="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ClientError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&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;Error&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;Code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ConditionalCheckFailedException&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;409&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;error&lt;/span&gt;&lt;span class="sh"&gt;"&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;code &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; already taken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&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;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;long_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_resp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&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;Content-Type&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;application/json&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;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&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 line worth noticing: &lt;code&gt;event["requestContext"]["authorizer"]["lambda"]["userId"]&lt;/code&gt;. That's where the authoriser's &lt;code&gt;context&lt;/code&gt; payload lands. Whatever the authoriser puts in &lt;code&gt;context&lt;/code&gt; shows up here, available to your handler. We'll use this to record which user owns each shortlink.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The redirect Lambda
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;redirect/handler.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;botocore.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ClientError&lt;/span&gt;

&lt;span class="n"&gt;ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS_ENDPOINT_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost.localstack.cloud:4566&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TABLE&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;shortlinks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;ddb&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;dynamodb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pathParameters&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;code&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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;missing code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})}&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;TableName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TABLE&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&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;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
            &lt;span class="n"&gt;UpdateExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET click_count = if_not_exists(click_count, :zero) + :one&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attribute_exists(code)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ExpressionAttributeValues&lt;/span&gt;&lt;span class="o"&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;:one&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;N&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;1&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;:zero&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;N&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;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
            &lt;span class="n"&gt;ReturnValues&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL_NEW&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="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ClientError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&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;Error&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;Code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ConditionalCheckFailedException&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&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;Content-Type&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;application/json&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;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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;not found&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="k"&gt;raise&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&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;Location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Attributes&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;long_url&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;S&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;body&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="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;statusCode: 302&lt;/code&gt; plus a &lt;code&gt;Location&lt;/code&gt; header is all it takes for an API Gateway response to be a redirect. The browser follows it transparently. The &lt;code&gt;ConditionExpression="attribute_exists(code)"&lt;/code&gt; matters too: &lt;code&gt;UpdateItem&lt;/code&gt; can create an item if you leave it unguarded, so this keeps a missing short code from turning into a ghost row with a counter but no URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The JWT authoriser Lambda
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;authorizer/handler.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;JWT_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_SECRET&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;dev-secret-change-me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_extract_token&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JWT_SECRET&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;token&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&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;payload&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isAuthorized&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isAuthorized&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&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;userId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&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;unknown&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_extract_token&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;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bearer &lt;/span&gt;&lt;span class="sh"&gt;"&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;header_b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload_b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig_b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;

    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_sign&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;header_b64&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;payload_b64&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="n"&gt;secret&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;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig_b64&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_b64url_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload_b64&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_b64url_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_b64url_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;def&lt;/span&gt; &lt;span class="nf"&gt;_b64url_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&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;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&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;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is HS256 JWT verification in pure stdlib - no &lt;code&gt;pyjwt&lt;/code&gt; dependency, no zip bundling, the whole authoriser is one file. The shape of the response is what HTTP API expects: &lt;code&gt;{ "isAuthorized": true|false, "context": {...} }&lt;/code&gt;. The &lt;code&gt;context&lt;/code&gt; object becomes available to downstream Lambdas via &lt;code&gt;event.requestContext.authorizer.lambda&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One small event-shape detail: HTTP API payload format &lt;code&gt;2.0&lt;/code&gt; lowercases header names in the Lambda event. Checking both &lt;code&gt;authorization&lt;/code&gt; and &lt;code&gt;Authorization&lt;/code&gt; is harmlessly defensive here, but &lt;code&gt;authorization&lt;/code&gt; is the key you should expect to see.&lt;/p&gt;

&lt;p&gt;A note on production: HS256 with a shared secret is fine for learning and internal services. Public APIs typically use RS256 with a JWKS endpoint so you can rotate keys without redeploying. The verification flow looks similar, but you would switch from HMAC to the &lt;code&gt;cryptography&lt;/code&gt; library's asymmetric signature verification methods.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: A token-signing helper
&lt;/h2&gt;

&lt;p&gt;So we can produce tokens for testing. Create &lt;code&gt;sign-token.py&lt;/code&gt; at the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;import &lt;span class="nb"&gt;base64&lt;/span&gt;, hashlib, hmac, json, sys, &lt;span class="nb"&gt;time

&lt;/span&gt;def b64url&lt;span class="o"&gt;(&lt;/span&gt;b&lt;span class="o"&gt;)&lt;/span&gt;:
    &lt;span class="k"&gt;return &lt;/span&gt;base64.urlsafe_b64encode&lt;span class="o"&gt;(&lt;/span&gt;b&lt;span class="o"&gt;)&lt;/span&gt;.decode&lt;span class="o"&gt;()&lt;/span&gt;.rstrip&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"="&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

def sign&lt;span class="o"&gt;(&lt;/span&gt;payload, secret&lt;span class="o"&gt;)&lt;/span&gt;:
    header &lt;span class="o"&gt;=&lt;/span&gt; b64url&lt;span class="o"&gt;(&lt;/span&gt;json.dumps&lt;span class="o"&gt;({&lt;/span&gt;&lt;span class="s2"&gt;"alg"&lt;/span&gt;: &lt;span class="s2"&gt;"HS256"&lt;/span&gt;, &lt;span class="s2"&gt;"typ"&lt;/span&gt;: &lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;, &lt;span class="nv"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;","&lt;/span&gt;, &lt;span class="s2"&gt;":"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;.encode&lt;span class="o"&gt;())&lt;/span&gt;
    body &lt;span class="o"&gt;=&lt;/span&gt; b64url&lt;span class="o"&gt;(&lt;/span&gt;json.dumps&lt;span class="o"&gt;(&lt;/span&gt;payload, &lt;span class="nv"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;","&lt;/span&gt;, &lt;span class="s2"&gt;":"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;.encode&lt;span class="o"&gt;())&lt;/span&gt;
    sig &lt;span class="o"&gt;=&lt;/span&gt; hmac.new&lt;span class="o"&gt;(&lt;/span&gt;secret.encode&lt;span class="o"&gt;()&lt;/span&gt;, f&lt;span class="s2"&gt;"{header}.{body}"&lt;/span&gt;.encode&lt;span class="o"&gt;()&lt;/span&gt;, hashlib.sha256&lt;span class="o"&gt;)&lt;/span&gt;.digest&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;f&lt;span class="s2"&gt;"{header}.{body}.{b64url(sig)}"&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;__name__ &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;" __main__"&lt;/span&gt;:
    user &lt;span class="o"&gt;=&lt;/span&gt; sys.argv[1] &lt;span class="k"&gt;if &lt;/span&gt;len&lt;span class="o"&gt;(&lt;/span&gt;sys.argv&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 1 &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;"user-1"&lt;/span&gt;
    secret &lt;span class="o"&gt;=&lt;/span&gt; sys.argv[2] &lt;span class="k"&gt;if &lt;/span&gt;len&lt;span class="o"&gt;(&lt;/span&gt;sys.argv&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 2 &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;"dev-secret-change-me"&lt;/span&gt;
    print&lt;span class="o"&gt;(&lt;/span&gt;sign&lt;span class="o"&gt;({&lt;/span&gt;&lt;span class="s2"&gt;"sub"&lt;/span&gt;: user, &lt;span class="s2"&gt;"exp"&lt;/span&gt;: int&lt;span class="o"&gt;(&lt;/span&gt;time.time&lt;span class="o"&gt;())&lt;/span&gt; + 3600&lt;span class="o"&gt;}&lt;/span&gt;, secret&lt;span class="o"&gt;))&lt;/span&gt;


&lt;span class="nv"&gt;$ &lt;/span&gt;python3 sign-token.py user-1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEi...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a valid JWT for &lt;code&gt;user-1&lt;/code&gt;, expiring in an hour, signed with the same secret the authoriser will check against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Bundle and deploy the Lambdas
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;fn &lt;span class="k"&gt;in &lt;/span&gt;shorten redirect authorizer&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$fn&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; zip &lt;span class="nt"&gt;-q&lt;/span&gt; ../&lt;span class="nv"&gt;$fn&lt;/span&gt;.zip handler.py &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ..
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;fn &lt;span class="k"&gt;in &lt;/span&gt;shorten redirect&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;awslocal lambda create-function &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--function-name&lt;/span&gt; &lt;span class="nv"&gt;$fn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--runtime&lt;/span&gt; python3.12 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--role&lt;/span&gt; arn:aws:iam::000000000000:role/lambda-role &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--handler&lt;/span&gt; handler.handler &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--zip-file&lt;/span&gt; fileb://&lt;span class="nv"&gt;$fn&lt;/span&gt;.zip &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--timeout&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--environment&lt;/span&gt; &lt;span class="s1"&gt;'Variables={TABLE=shortlinks}'&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;awslocal lambda create-function &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; authorizer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--runtime&lt;/span&gt; python3.12 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; arn:aws:iam::000000000000:role/lambda-role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--handler&lt;/span&gt; handler.handler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zip-file&lt;/span&gt; fileb://authorizer.zip &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; &lt;span class="s1"&gt;'Variables={JWT_SECRET=dev-secret-change-me}'&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;fn &lt;span class="k"&gt;in &lt;/span&gt;shorten redirect authorizer&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;awslocal lambda &lt;span class="nb"&gt;wait &lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nt"&gt;-active-v2&lt;/span&gt; &lt;span class="nt"&gt;--function-name&lt;/span&gt; &lt;span class="nv"&gt;$fn&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait until all three are &lt;code&gt;ACTIVE&lt;/code&gt; before going on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Create the HTTP API
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;API_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal apigatewayv2 create-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; shortener &lt;span class="nt"&gt;--protocol-type&lt;/span&gt; HTTP &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'ApiId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"API_ID=&lt;/span&gt;&lt;span class="nv"&gt;$API_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 8: Lambda integrations
&lt;/h2&gt;

&lt;p&gt;Each route needs a Lambda integration that connects API Gateway to the function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SHORTEN_INT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal apigatewayv2 create-integration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-id&lt;/span&gt; &lt;span class="nv"&gt;$API_ID&lt;/span&gt; &lt;span class="nt"&gt;--integration-type&lt;/span&gt; AWS_PROXY &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integration-uri&lt;/span&gt; arn:aws:lambda:us-east-1:000000000000:function:shorten &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--payload-format-version&lt;/span&gt; 2.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'IntegrationId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;REDIRECT_INT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal apigatewayv2 create-integration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-id&lt;/span&gt; &lt;span class="nv"&gt;$API_ID&lt;/span&gt; &lt;span class="nt"&gt;--integration-type&lt;/span&gt; AWS_PROXY &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--integration-uri&lt;/span&gt; arn:aws:lambda:us-east-1:000000000000:function:redirect &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--payload-format-version&lt;/span&gt; 2.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'IntegrationId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AWS_PROXY&lt;/code&gt; is the integration type that ships the raw HTTP request to the Lambda and expects an HTTP response back. &lt;code&gt;--payload-format-version 2.0&lt;/code&gt; is what HTTP API uses; REST API still uses 1.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: The Lambda authoriser
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;AUTH_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal apigatewayv2 create-authorizer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-id&lt;/span&gt; &lt;span class="nv"&gt;$API_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; jwt-auth &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--authorizer-type&lt;/span&gt; REQUEST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--identity-source&lt;/span&gt; &lt;span class="s1"&gt;'$request.header.Authorization'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--authorizer-uri&lt;/span&gt; arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:authorizer/invocations &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--authorizer-payload-format-version&lt;/span&gt; 2.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-simple-responses&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'AuthorizerId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three flags worth understanding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--identity-source '$request.header.Authorization'&lt;/code&gt; tells API Gateway which header carries the credential. If that identity source is missing on a request, API Gateway returns &lt;code&gt;401 Unauthorized&lt;/code&gt; instead of invoking the authoriser Lambda.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--authorizer-payload-format-version 2.0&lt;/code&gt;&lt;/strong&gt; - must match the integration version.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--enable-simple-responses&lt;/code&gt; switches to the &lt;code&gt;{ isAuthorized: true|false }&lt;/code&gt; shape rather than the full IAM policy response. Cleaner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 10: Routes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Auth-protected&lt;/span&gt;
awslocal apigatewayv2 create-route &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-id&lt;/span&gt; &lt;span class="nv"&gt;$API_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--route-key&lt;/span&gt; &lt;span class="s1"&gt;'POST /shorten'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target&lt;/span&gt; integrations/&lt;span class="nv"&gt;$SHORTEN_INT&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--authorization-type&lt;/span&gt; CUSTOM &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--authorizer-id&lt;/span&gt; &lt;span class="nv"&gt;$AUTH_ID&lt;/span&gt;

&lt;span class="c"&gt;# Public&lt;/span&gt;
awslocal apigatewayv2 create-route &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-id&lt;/span&gt; &lt;span class="nv"&gt;$API_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--route-key&lt;/span&gt; &lt;span class="s1"&gt;'GET /r/{code}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target&lt;/span&gt; integrations/&lt;span class="nv"&gt;$REDIRECT_INT&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{code}&lt;/code&gt; is a path parameter. It arrives at the redirect Lambda as &lt;code&gt;event.pathParameters.code&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 11: Stage with auto-deploy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal apigatewayv2 create-stage &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--api-id&lt;/span&gt; &lt;span class="nv"&gt;$API_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--stage-name&lt;/span&gt; dev &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--auto-deploy&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--auto-deploy&lt;/code&gt; means any subsequent route changes go live without an explicit deploy. Convenient for development.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 12: Permissions for API Gateway to invoke the Lambdas
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;fn &lt;span class="k"&gt;in &lt;/span&gt;shorten redirect&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;awslocal lambda add-permission &lt;span class="nt"&gt;--function-name&lt;/span&gt; &lt;span class="nv"&gt;$fn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--statement-id&lt;/span&gt; apigw-invoke &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--action&lt;/span&gt; lambda:InvokeFunction &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--principal&lt;/span&gt; apigateway.amazonaws.com &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--source-arn&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:execute-api:us-east-1:000000000000:&lt;/span&gt;&lt;span class="nv"&gt;$API_ID&lt;/span&gt;&lt;span class="s2"&gt;/*"&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;awslocal lambda add-permission &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; authorizer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--statement-id&lt;/span&gt; apigw-authorizer-invoke &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; lambda:InvokeFunction &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--principal&lt;/span&gt; apigateway.amazonaws.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-arn&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:execute-api:us-east-1:000000000000:&lt;/span&gt;&lt;span class="nv"&gt;$API_ID&lt;/span&gt;&lt;span class="s2"&gt;/authorizers/&lt;/span&gt;&lt;span class="nv"&gt;$AUTH_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without these, API Gateway can't invoke your Lambdas and you get a generic 500 with no useful error. The route handlers can use the broad &lt;code&gt;api-id/*&lt;/code&gt; form for a learning setup; the authoriser permission above uses the narrower authorizer ARN shape AWS documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 13: Test it
&lt;/h2&gt;

&lt;p&gt;The API endpoint URL is &lt;code&gt;http://&amp;lt;API_ID&amp;gt;.execute-api.localhost.localstack.cloud:4566/dev&lt;/code&gt; when LocalStack is running on the same machine as your client. If your LocalStack box lives on another IP, send the request to that IP and set the &lt;code&gt;Host&lt;/code&gt; header to &lt;code&gt;&amp;lt;API_ID&amp;gt;.execute-api.localhost.localstack.cloud:4566&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Generate a token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 sign-token.py user-1&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="nv"&gt;$API_ID&lt;/span&gt;&lt;span class="s2"&gt;.execute-api.localhost.localstack.cloud:4566/dev"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test the four scenarios:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. POST /shorten without token (expect 401)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/shorten"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"long_url":"https://anthropic.com"}'&lt;/span&gt;
&lt;span class="c"&gt;# HTTP/1.1 401 Unauthorized&lt;/span&gt;
&lt;span class="c"&gt;# {"message":"Unauthorized"}&lt;/span&gt;

&lt;span class="c"&gt;# 2. POST /shorten with valid JWT (expect 201)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/shorten"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"long_url":"https://anthropic.com","code":"anth"}'&lt;/span&gt;
&lt;span class="c"&gt;# HTTP/1.1 201 Created&lt;/span&gt;
&lt;span class="c"&gt;# {"code": "anth", "long_url": "https://anthropic.com", "owner_id": "user-1"}&lt;/span&gt;

&lt;span class="c"&gt;# 3. GET /r/anth (expect 302 redirect)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; - &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/r/anth"&lt;/span&gt;
&lt;span class="c"&gt;# HTTP/1.1 302 FOUND&lt;/span&gt;
&lt;span class="c"&gt;# Location: https://anthropic.com&lt;/span&gt;

&lt;span class="c"&gt;# 4. GET /r/missing (expect 404)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/r/missing"&lt;/span&gt;
&lt;span class="c"&gt;# HTTP/1.1 404 Not Found&lt;/span&gt;
&lt;span class="c"&gt;# {"error": "not found"}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting bit: &lt;code&gt;owner_id: "user-1"&lt;/code&gt; came from the JWT subject claim, flowing through the authoriser context into the shorten Lambda and onto the DynamoDB row. Auth context moved all the way through the request, with no app code involved beyond reading &lt;code&gt;event.requestContext.authorizer.lambda.userId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Hit the redirect a few more times and check the click counter:&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;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/r/anth"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/r/anth"&lt;/span&gt;
awslocal dynamodb get-item &lt;span class="nt"&gt;--table-name&lt;/span&gt; shortlinks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="s1"&gt;'{"code":{"S":"anth"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Item.click_count.N'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="c"&gt;# 3&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atomic increment from Part 2, wired through HTTP API and a Lambda. No race conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every request returns 500 with no useful error.&lt;/strong&gt; Check that you ran &lt;code&gt;add-permission&lt;/code&gt; for each Lambda after creating the API. API Gateway can call IAM-restricted Lambdas only with those statements. On real AWS, the authoriser permission should use the &lt;code&gt;api-id/authorizers/authorizer-id&lt;/code&gt; source ARN shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The very first request after wiring everything returns 500, then the retry works.&lt;/strong&gt; Fresh API Gateway resources on LocalStack can take a second to settle after route, stage, and permission creation. Give it a moment and try once more before changing code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;401 on every request, even with a valid token.&lt;/strong&gt; Confirm the authoriser's &lt;code&gt;JWT_SECRET&lt;/code&gt; matches what &lt;code&gt;sign-token.py&lt;/code&gt; is using. Print both and eyeball them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Could not connect to localstack:4566&lt;/code&gt; in Lambda logs.&lt;/strong&gt; Same hostname gotcha as Part 3. This usually means your code or env var points at &lt;code&gt;localhost:4566&lt;/code&gt; from inside the Lambda container. Use &lt;code&gt;localhost.localstack.cloud:4566&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;403 with "Forbidden" body on the authoriser path.&lt;/strong&gt; The &lt;code&gt;identity-source&lt;/code&gt; doesn't match the header your client is sending, or the route is wired to the wrong authoriser. Clients can send either header casing, but the HTTP API event passed to Lambda lowercases header names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS errors when calling from a browser.&lt;/strong&gt; HTTP API doesn't enable CORS by default. Add &lt;code&gt;--cors-configuration '{"AllowOrigins":["*"],"AllowMethods":["GET","POST"],"AllowHeaders":["*"]}'&lt;/code&gt; to your &lt;code&gt;create-api&lt;/code&gt; call (or update the API after the fact).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  About JWT in production
&lt;/h2&gt;

&lt;p&gt;What we built is a working educational example. To take this to production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Swap HS256 for RS256.&lt;/strong&gt; Public APIs sign with a private key and verify with a public key fetched from a JWKS endpoint. That way you can rotate signing keys without distributing a new shared secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, &lt;code&gt;nbf&lt;/code&gt;&lt;/strong&gt; on top of &lt;code&gt;exp&lt;/code&gt;. Issuer, audience, "not before" - all standard JWT claims worth checking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a real IdP&lt;/strong&gt; like Cognito, Auth0, Clerk, or a self-hosted Keycloak. They handle key rotation, user management, MFA, and password resets so you don't have to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache authoriser results.&lt;/strong&gt; API Gateway can cache an authoriser response for up to an hour, which slashes Lambda invocations on a busy endpoint. Set &lt;code&gt;--authorizer-result-ttl-in-seconds 300&lt;/code&gt; on the &lt;code&gt;create-authorizer&lt;/code&gt; call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cognito is a tempting target here, and yes, LocalStack supports it on the Pro tier. We're staying on the simpler stack in this series, but it'll be a future quick-win article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleanup commands worth knowing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Tear down the API (removes routes, integrations, authorisers, stage)&lt;/span&gt;
awslocal apigatewayv2 delete-api &lt;span class="nt"&gt;--api-id&lt;/span&gt; &lt;span class="nv"&gt;$API_ID&lt;/span&gt;

&lt;span class="c"&gt;# Remove the Lambdas&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;fn &lt;span class="k"&gt;in &lt;/span&gt;shorten redirect authorizer&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;awslocal lambda delete-function &lt;span class="nt"&gt;--function-name&lt;/span&gt; &lt;span class="nv"&gt;$fn&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't actually delete &lt;code&gt;shortlinks&lt;/code&gt; from DynamoDB if you're going on to Part 5 - we'll keep it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Save this as a checkpoint
&lt;/h2&gt;

&lt;p&gt;This part is almost entirely Lambdas and API Gateway resources, both of which depend on &lt;code&gt;.zip&lt;/code&gt; files we just built. Trying to bootstrap the API automatically from an init hook ends up more complex than just walking through the article body again - the Lambda zips need to live somewhere the container can read them, the integrations reference Lambda ARNs, and so on.&lt;/p&gt;

&lt;p&gt;So Part 4's checkpoint is intentionally a no-op. It exists for completeness and to remind you that this part is rebuilt manually from the article steps above.&lt;/p&gt;

&lt;p&gt;Save as &lt;code&gt;init/ready.d/04-part4-api.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Part 4 checkpoint - no automated bootstrap.&lt;/span&gt;
&lt;span class="c"&gt;# The HTTP API, three Lambdas, and authoriser are deployed by the steps in&lt;/span&gt;
&lt;span class="c"&gt;# Part 4's body. Re-run the build + create-function + create-api flow manually&lt;/span&gt;
&lt;span class="c"&gt;# after a restart.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[bootstrap] part 4 - manual deployment required (see article)"&lt;/span&gt;


&lt;span class="nb"&gt;chmod&lt;/span&gt; +x init/ready.d/04-part4-api.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Jumping in at Part 4 from scratch?&lt;/strong&gt; Make sure &lt;code&gt;02-part2-dynamodb.sh&lt;/code&gt; is in your &lt;code&gt;init/ready.d/&lt;/code&gt; first. Part 4 reads from and updates the &lt;code&gt;shortlinks&lt;/code&gt; table from Part 2. Then walk through Steps 2-13 above.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'll wire up next
&lt;/h2&gt;

&lt;p&gt;You've got an authenticated HTTP API in front of a real data layer. &lt;strong&gt;The next part&lt;/strong&gt; builds the asynchronous side: an SQS queue with a dead-letter queue, fed by an SNS topic, consumed by a Lambda. The use case is "send a welcome email" / "notify a user that their photo has been processed" - the kind of background job that has no business living on the request path.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The full series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Part 0&lt;/a&gt; - Start here: series intro and installing LocalStack&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; - S3 locally: buckets, presigned URLs, and a tiny photo uploader&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-2-dynamodb-url-shortener-data-layer/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; - DynamoDB locally: building a URL shortener data layer&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-3-lambda-s3-thumbnailer-pipeline/" rel="noopener noreferrer"&gt;Part 3&lt;/a&gt; - Lambda + S3 events: an image thumbnailer pipeline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4&lt;/strong&gt; - API Gateway + Lambda + JWT auth: a real HTTP API &lt;em&gt;(this article)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 5 - SQS + SNS: a background job queue with a dead-letter queue &lt;em&gt;(next)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 6 - EventBridge + Step Functions: orchestrating a photo-processing workflow&lt;/li&gt;
&lt;li&gt;Part 7 - Secrets Manager + KMS: handling secrets and encryption locally&lt;/li&gt;
&lt;li&gt;Part 8 - Terraform (tflocal) + GitHub Actions: integration tests against LocalStack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;API Gateway HTTP API Lambda authorizers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jwt.io/?ref=alishaikh.me" rel="noopener noreferrer"&gt;JWT.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/aws/services/apigateway/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack API Gateway docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Related on alishaikh.me&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/localstack-run-aws-services-locally-for-free/" rel="noopener noreferrer"&gt;LocalStack: Run AWS Services Locally for Free&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>lambda</category>
      <category>localstack</category>
    </item>
    <item>
      <title>Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 3 - Lambda S3 Thumbnailer Pipeline)</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Sat, 16 May 2026 15:13:31 +0000</pubDate>
      <link>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-3-lambda-s3-thumbnailer-pipeline-36hp</link>
      <guid>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-3-lambda-s3-thumbnailer-pipeline-36hp</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff61492jq8hcgzbmk84ne.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff61492jq8hcgzbmk84ne.png" alt="Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 3 - Lambda S3 Thumbnailer Pipeline)" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you've already worked through&lt;/strong&gt; &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-2-dynamodb-url-shortener-data-layer/" rel="noopener noreferrer"&gt;&lt;strong&gt;Part 2 - DynamoDB URL Shortener Data Layer&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;, this is where the series turns event-driven. Upload a photo to S3, a Lambda fires automatically, and a thumbnail appears in another bucket. By the end you'll have a working pipeline you can adapt for any "react to upload" use case - virus scanning, OCR, watermarking, you name it.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What you'll need:&lt;/strong&gt; &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Part 0 setup&lt;/a&gt;, &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;Part 1 finished&lt;/a&gt; (we'll reuse the &lt;code&gt;photos&lt;/code&gt; bucket convention), Python 3.12, Docker on the machine running LocalStack (because LocalStack executes Lambda invocations through Docker containers), and 45 minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upload to s3://photos/foo.jpg
        │
        ▼
S3 ObjectCreated event
        │
        ▼
   Lambda fires
        │
        ├── reads s3://photos/foo.jpg
        ├── resizes to 256×N (aspect preserved)
        └── writes s3://thumbnails/thumb-foo.jpg

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same trigger pattern AWS customers use for image-heavy SaaS (think Imgur, photo sharing apps) and for "do something on upload" pipelines (virus scanning, transcoding, redaction). The event pattern carries across directly to AWS, but a production version usually adds stricter IAM, idempotency, input validation, retries, object filtering, and monitoring around the same basic Lambda shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is genuinely useful for learning
&lt;/h2&gt;

&lt;p&gt;LocalStack's Lambda runtime spawns a sibling Docker container per invocation, runs your code in it, then tears it down. Same as real AWS conceptually. Same event payload shape. A familiar CloudWatch Logs workflow too, via LocalStack's emulation.&lt;/p&gt;

&lt;p&gt;The two things that differ from real AWS - and are worth knowing about up front - are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;You need Docker on the host running LocalStack.&lt;/strong&gt; Lambda needs the Docker daemon socket. Part 0's compose file mounts &lt;code&gt;/var/run/docker.sock&lt;/code&gt; for exactly this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From inside a Lambda, &lt;code&gt;localhost:4566&lt;/code&gt; doesn't reach LocalStack.&lt;/strong&gt; Lambdas run in a sibling container, so &lt;code&gt;localhost&lt;/code&gt; is the Lambda itself. Use &lt;code&gt;localhost.localstack.cloud:4566&lt;/code&gt; instead. LocalStack documents that this resolves to the LocalStack container for LocalStack-managed compute environments such as Lambda. This is the gotcha that costs everyone an hour the first time.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: Make the buckets
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/localstack-series
&lt;span class="nb"&gt;mkdir &lt;/span&gt;part3-lambda
&lt;span class="nb"&gt;cd &lt;/span&gt;part3-lambda

awslocal s3 mb s3://photos &lt;span class="c"&gt;# if you didn't keep it from Part 1&lt;/span&gt;
awslocal s3 mb s3://thumbnails

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two buckets: source uploads land in &lt;code&gt;photos&lt;/code&gt;, thumbnails go into &lt;code&gt;thumbnails&lt;/code&gt;. Splitting them is the standard pattern - it stops the Lambda triggering recursively on its own output, and it lets you set different lifecycle rules per bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Write the Lambda
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;thumbnailer.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;

&lt;span class="n"&gt;ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS_ENDPOINT_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost.localstack.cloud:4566&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;THUMBNAIL_BUCKET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;THUMBNAIL_BUCKET&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;thumbnails&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;THUMBNAIL_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;s3&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;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-east-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;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;results&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;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&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="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&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;bucket&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;name&lt;/span&gt;&lt;span class="sh"&gt;"&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;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unquote_plus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&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;object&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;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

        &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;THUMBNAIL_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPEG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;upper&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;fmt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPEG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGBA&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;P&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seek&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="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;THUMBNAIL_BUCKET&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thumb-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&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="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ContentType&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;image/jpeg&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="n"&gt;results&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thumb&lt;/span&gt;&lt;span class="sh"&gt;"&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;THUMBNAIL_BUCKET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/thumb-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thumbnails&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;results&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 line-by-line notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;urllib.parse.unquote_plus&lt;/code&gt; - S3 event keys are URL-encoded (a file called &lt;code&gt;my photo.jpg&lt;/code&gt; arrives as &lt;code&gt;my+photo.jpg&lt;/code&gt;). Decoding is the kind of thing every "first Lambda" gets bitten by.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Image.thumbnail(size)&lt;/code&gt; - Pillow's thumbnail resizes in place and preserves aspect ratio. For a 1200×800 source with &lt;code&gt;(256, 256)&lt;/code&gt; you get 256×171 out, not 256×256. That's almost always what you want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The RGBA/P→RGB conversion&lt;/strong&gt; - JPEG can't store transparency. Without this, PNG-with-alpha sources crash on save. Quietly converting is the simplest fix.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;endpoint_url&lt;/code&gt; uses &lt;code&gt;localhost.localstack.cloud:4566&lt;/code&gt; by default, with &lt;code&gt;AWS_ENDPOINT_URL&lt;/code&gt; as the override. For LocalStack-managed Lambda containers, that hostname resolves back to the LocalStack instance. Recent AWS SDK endpoint settings can also honour &lt;code&gt;AWS_ENDPOINT_URL&lt;/code&gt; directly, but keeping the fallback explicit here makes the LocalStack hostname obvious in the sample. The default is enough for the standard LocalStack-on-Docker setup; only set the env var in Step 4 if you need to override it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Bundle Pillow for the Lambda runtime
&lt;/h2&gt;

&lt;p&gt;This is the macOS-developer gotcha. This walkthrough targets the LocalStack Python 3.12 Lambda environment we verified, which expects Linux-compatible native wheels. Pillow ships native binaries, so &lt;code&gt;pip&lt;/code&gt; will install macOS-arm64 wheels on your laptop unless you tell it otherwise, and those won't work in the Lambda container.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;pip install --platform manylinux2014_x86_64&lt;/code&gt;:&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;mkdir &lt;/span&gt;package
pip &lt;span class="nb"&gt;install &lt;/span&gt;Pillow &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; manylinux2014_x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target&lt;/span&gt; ./package &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--only-binary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;:all: &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--python-version&lt;/span&gt; 3.12

&lt;span class="nb"&gt;cp &lt;/span&gt;thumbnailer.py package/

&lt;span class="nb"&gt;cd &lt;/span&gt;package &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; zip &lt;span class="nt"&gt;-qr&lt;/span&gt; ../thumbnailer.zip &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ..

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resulting zip is about 8MB - well under Lambda's 50MB direct upload limit. For larger deps you'd push to S3 first or use a Lambda layer; we'll do layers in a future article.&lt;/p&gt;

&lt;p&gt;If you're already on Linux x86_64, drop the &lt;code&gt;--platform&lt;/code&gt; flag and just &lt;code&gt;pip install Pillow --target ./package&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Deploy the Lambda
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal lambda create-function &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; thumbnailer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--runtime&lt;/span&gt; python3.12 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; arn:aws:iam::000000000000:role/lambda-role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--handler&lt;/span&gt; thumbnailer.handler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zip-file&lt;/span&gt; fileb://thumbnailer.zip &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt; 30 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory-size&lt;/span&gt; 512 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; &lt;span class="s1"&gt;'Variables={THUMBNAIL_BUCKET=thumbnails}'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three knobs worth noticing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--timeout 30&lt;/code&gt;&lt;/strong&gt; - default is 3 seconds, which is enough for the smoke test but won't survive a 4MP photo on a cold start. 30 seconds is comfortable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--memory-size 512&lt;/code&gt;&lt;/strong&gt; - Pillow's image decoding allocates buffers proportional to image dimensions. 512MB is a safe minimum.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--role&lt;/code&gt; - LocalStack doesn't validate IAM by default on the Hobby tier, so any well-formed role ARN works. In real AWS you'd point at a role with &lt;code&gt;s3:GetObject&lt;/code&gt; and &lt;code&gt;s3:PutObject&lt;/code&gt; policies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wait for it to be ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal lambda &lt;span class="nb"&gt;wait &lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nt"&gt;-active-v2&lt;/span&gt; &lt;span class="nt"&gt;--function-name&lt;/span&gt; thumbnailer

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Wire the S3 trigger
&lt;/h2&gt;

&lt;p&gt;Two pieces: a permission on the Lambda saying "S3 is allowed to invoke me", and a notification on the bucket saying "send ObjectCreated events here".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Lambda permission for S3 to invoke&lt;/span&gt;
awslocal lambda add-permission &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; thumbnailer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--statement-id&lt;/span&gt; s3-invoke &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; lambda:InvokeFunction &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--principal&lt;/span&gt; s3.amazonaws.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-arn&lt;/span&gt; arn:aws:s3:::photos

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the bucket notification config - save this as &lt;code&gt;notification.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"LambdaFunctionConfigurations"&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;"Id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"thumb-on-upload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"LambdaFunctionArn"&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:lambda:us-east-1:000000000000:function:thumbnailer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Events"&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:ObjectCreated:*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal s3api put-bucket-notification-configuration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; photos &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--notification-configuration&lt;/span&gt; file://notification.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Test it end to end
&lt;/h2&gt;

&lt;p&gt;Generate a test image (or use any JPEG you have lying around):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
from PIL import Image
img = Image.new(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, (1200, 800), color=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;steelblue&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)
img.save(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;test.jpg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;JPEG&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, quality=85)
print(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Created 1200x800 test.jpg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)
&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal s3 &lt;span class="nb"&gt;cp &lt;/span&gt;test.jpg s3://photos/test.jpg
&lt;span class="nb"&gt;sleep &lt;/span&gt;5 &lt;span class="c"&gt;# demo pause; S3 notifications usually arrive in seconds but can sometimes take longer&lt;/span&gt;
awslocal s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://thumbnails/

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-05-12 00:16:20 1332 thumb-test.jpg

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull it back and check the dimensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal s3 &lt;span class="nb"&gt;cp &lt;/span&gt;s3://thumbnails/thumb-test.jpg ./result.jpg
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"from PIL import Image; print(Image.open('result.jpg').size)"&lt;/span&gt;


&lt;span class="o"&gt;(&lt;/span&gt;256, 171&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 1200×800 source thumbnailed to 256×171 (aspect preserved). On a homelab box this usually completes in a couple of seconds, but S3 event notifications are asynchronous and designed for at-least-once delivery, so treat the demo &lt;code&gt;sleep 5&lt;/code&gt; as a convenience rather than a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Reading the logs when something goes wrong
&lt;/h2&gt;

&lt;p&gt;This is the bit that saves your evening. Every Lambda invocation produces logs you can inspect through LocalStack's CloudWatch Logs emulation, which is close enough to the AWS workflow to make local debugging feel familiar.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# What log groups exist?&lt;/span&gt;
awslocal logs describe-log-groups &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'logGroups[*].logGroupName'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text

&lt;span class="c"&gt;# Latest log stream for our Lambda&lt;/span&gt;
&lt;span class="nv"&gt;LATEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;awslocal logs describe-log-streams &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; /aws/lambda/thumbnailer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--order-by&lt;/span&gt; LastEventTime &lt;span class="nt"&gt;--descending&lt;/span&gt; &lt;span class="nt"&gt;--max-items&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'logStreams[0].logStreamName'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Read the events&lt;/span&gt;
awslocal logs get-log-events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; /aws/lambda/thumbnailer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-stream-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LATEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'events[*].message'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see the &lt;code&gt;START&lt;/code&gt;, &lt;code&gt;END&lt;/code&gt;, and &lt;code&gt;REPORT&lt;/code&gt; lines you're used to from real Lambda, plus any &lt;code&gt;print()&lt;/code&gt; or &lt;code&gt;logging&lt;/code&gt; calls from your code.&lt;/p&gt;

&lt;p&gt;The first time I ran this, the Lambda fired but bombed with &lt;code&gt;EndpointConnectionError: Could not connect to "http://localstack:4566/photos/test.jpg"&lt;/code&gt; - that's the hostname gotcha I mentioned at the top. The fix was the &lt;code&gt;localhost.localstack.cloud:4566&lt;/code&gt; line in the Python source. The logs showed it instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The networking gotcha, in detail
&lt;/h2&gt;

&lt;p&gt;When LocalStack invokes a Lambda, it spawns a fresh Docker container on the Docker host. From inside that container:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;localhost&lt;/code&gt; is the Lambda container itself - useless for talking back to LocalStack.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;localstack&lt;/code&gt; would work &lt;em&gt;if&lt;/em&gt; the LocalStack container is named that and on a Docker network the Lambda joins. Often it isn't.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;localhost.localstack.cloud&lt;/code&gt; is the default hostname to use for LocalStack-managed Lambda containers, because &lt;code&gt;localhost&lt;/code&gt; inside the function points back at the function container itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're running LocalStack on a remote homelab box and developing on your laptop, the Lambda environment variable for &lt;code&gt;AWS_ENDPOINT_URL&lt;/code&gt; should still be &lt;code&gt;http://localhost.localstack.cloud:4566&lt;/code&gt; - it's resolved at Lambda runtime, inside the homelab's Docker network, not from your laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lambda fires but produces no thumbnail.&lt;/strong&gt; Check the CloudWatch logs first. In LocalStack it's usually the hostname issue or a Pillow image-format issue. In real AWS, also check the execution role for missing &lt;code&gt;s3:GetObject&lt;/code&gt; or &lt;code&gt;s3:PutObject&lt;/code&gt; permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Unable to import module 'thumbnailer': No module named 'PIL'&lt;/code&gt;.&lt;/strong&gt; The Pillow native binaries didn't make it into the zip, or you skipped the &lt;code&gt;--platform manylinux2014_x86_64&lt;/code&gt; flag and bundled the Mac wheels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda invocations hang for 30+ seconds and time out.&lt;/strong&gt; Docker socket mount missing on the host running LocalStack. Add &lt;code&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/code&gt; to the compose file and restart.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InvalidArgument: ... Configuration is ambiguously defined&lt;/code&gt;. You're applying a second notification config without clearing the first. Use &lt;code&gt;--notification-configuration '{}'&lt;/code&gt; to wipe it, or fix the JSON to merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The thumbnail is bigger than the source.&lt;/strong&gt; Pillow's &lt;code&gt;thumbnail()&lt;/code&gt; only shrinks. If your source is 200×100, the "thumbnail" stays 200×100. That's correct behaviour.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cleanup commands worth knowing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Remove the Lambda&lt;/span&gt;
awslocal lambda delete-function &lt;span class="nt"&gt;--function-name&lt;/span&gt; thumbnailer

&lt;span class="c"&gt;# Remove the bucket notification (the Lambda link)&lt;/span&gt;
awslocal s3api put-bucket-notification-configuration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; photos &lt;span class="nt"&gt;--notification-configuration&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;

&lt;span class="c"&gt;# Wipe the thumbnails bucket&lt;/span&gt;
awslocal s3 &lt;span class="nb"&gt;rm &lt;/span&gt;s3://thumbnails &lt;span class="nt"&gt;--recursive&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't actually delete the Lambda or the buckets if you're carrying on to Part 4 - we'll layer API Gateway on top of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  Save this as a checkpoint
&lt;/h2&gt;

&lt;p&gt;The supporting resources for the thumbnailer pipeline (the &lt;code&gt;thumbnails&lt;/code&gt; bucket) can sit in your init hooks folder. The Lambda itself is built from a &lt;code&gt;.zip&lt;/code&gt; of your code, which is awkward to redeploy from a one-shot shell script - so we'll skip the Lambda in the checkpoint and rely on the manual deploy steps above.&lt;/p&gt;

&lt;p&gt;Save as &lt;code&gt;init/ready.d/03-part3-lambda.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Part 3 checkpoint - supporting resources for the thumbnailer pipeline&lt;/span&gt;
&lt;span class="c"&gt;# (Lambda deployment + S3 trigger wiring is article-driven; redeploy via the&lt;/span&gt;
&lt;span class="c"&gt;# steps in the article body. This script just ensures the buckets exist.)&lt;/span&gt;
awslocal s3 mb s3://thumbnails 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
echo&lt;/span&gt; &lt;span class="s2"&gt;"[bootstrap] part 3 - thumbnails bucket ready (deploy Lambda manually)"&lt;/span&gt;


&lt;span class="nb"&gt;chmod&lt;/span&gt; +x init/ready.d/03-part3-lambda.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Jumping in at Part 3 from scratch?&lt;/strong&gt; You'll need scripts &lt;code&gt;01-part1-s3.sh&lt;/code&gt; and &lt;code&gt;02-part2-dynamodb.sh&lt;/code&gt; from the previous articles in your &lt;code&gt;init/ready.d/&lt;/code&gt; too. Then follow the Lambda build + trigger wiring above to get the dynamic part going.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'll wire up next
&lt;/h2&gt;

&lt;p&gt;You've got an event-driven pipeline running locally. &lt;strong&gt;The next part&lt;/strong&gt; puts a real HTTP API in front of the URL shortener from Part 2 - API Gateway with a Lambda backend, plus a JWT authoriser so only signed requests get through. Same Lambda mechanics as today, with HTTP routing and auth in front.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The full series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Part 0&lt;/a&gt; - Start here: series intro and installing LocalStack&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; - S3 locally: buckets, presigned URLs, and a tiny photo uploader&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-2-dynamodb-url-shortener-data-layer/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; - DynamoDB locally: building a URL shortener data layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3&lt;/strong&gt; - Lambda + S3 events: an image thumbnailer pipeline &lt;em&gt;(this article)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 4 - API Gateway + Lambda + JWT auth: a real HTTP API &lt;em&gt;(next)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 5 - SQS + SNS: a background job queue with a dead-letter queue&lt;/li&gt;
&lt;li&gt;Part 6 - EventBridge + Step Functions: orchestrating a photo-processing workflow&lt;/li&gt;
&lt;li&gt;Part 7 - Secrets Manager + KMS: handling secrets and encryption locally&lt;/li&gt;
&lt;li&gt;Part 8 - Terraform (tflocal) + GitHub Actions: integration tests against LocalStack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/aws/capabilities/networking/accessing-endpoint-url/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack endpoint URL docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/NotificationHowTo.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;AWS S3 event notifications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pillow.readthedocs.io/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Pillow documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Related on alishaikh.me&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 0 - Setup)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 1 - S3 Photo Uploader with Presigned URLs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-2-dynamodb-url-shortener-data-layer/" rel="noopener noreferrer"&gt;Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 2 - DynamoDB URL Shortener Data Layer)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/localstack-run-aws-services-locally-for-free/" rel="noopener noreferrer"&gt;LocalStack: Run AWS Services Locally for Free&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>lambda</category>
      <category>s3</category>
    </item>
    <item>
      <title>Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 2 - DynamoDB URL Shortener Data Layer)</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Mon, 11 May 2026 17:35:09 +0000</pubDate>
      <link>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-2-dynamodb-url-shortener-data-2dj3</link>
      <guid>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-2-dynamodb-url-shortener-data-2dj3</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8z4erpavz778ffmliyrb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8z4erpavz778ffmliyrb.png" alt="Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 2 - DynamoDB URL Shortener Data Layer)" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you've already worked through&lt;/strong&gt; &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;&lt;strong&gt;Part 1 - S3 Photo Uploader with Presigned URLs&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;, this is where the app gets a data layer. By the end of this article you'll have a DynamoDB table on LocalStack, a small Node CLI that handles shortening, resolving, and atomic click counting, plus a fixture loader you can reuse later. Part 3 detours into Lambda + S3 events, and Part 4 will wrap this shortener in an HTTP API.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DynamoDB fits this job
&lt;/h2&gt;

&lt;p&gt;A URL shortener is one of those workloads DynamoDB fits neatly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The hot path is simple: look up &lt;code&gt;code → long_url&lt;/code&gt;, then bump a click counter. That's still a tiny, key-based access pattern - exactly the sort of thing DynamoDB is good at.&lt;/li&gt;
&lt;li&gt;Click counting wants atomic increments. DynamoDB supports &lt;code&gt;SET attr = attr + :n&lt;/code&gt; natively - no read-modify-write race conditions.&lt;/li&gt;
&lt;li&gt;Writes are point inserts with a uniqueness check. DynamoDB's &lt;code&gt;ConditionExpression&lt;/code&gt; makes "fail if this code already exists" a one-line addition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, Bitly, TinyURL, and plenty of internal "go links" tools use this same shape: short code in, long URL out, plus a counter on the side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Create the shortlinks table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/localstack-series
&lt;span class="nb"&gt;mkdir &lt;/span&gt;part2-dynamodb
&lt;span class="nb"&gt;cd &lt;/span&gt;part2-dynamodb

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal dynamodb create-table &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; shortlinks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attribute-definitions&lt;/span&gt; &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;code,AttributeType&lt;span class="o"&gt;=&lt;/span&gt;S &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key-schema&lt;/span&gt; &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;code,KeyType&lt;span class="o"&gt;=&lt;/span&gt;HASH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--billing-mode&lt;/span&gt; PAY_PER_REQUEST

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth understanding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AttributeName=code,KeyType=HASH&lt;/code&gt; - &lt;code&gt;code&lt;/code&gt; is the partition key. The CLI still uses the legacy &lt;code&gt;HASH&lt;/code&gt; / &lt;code&gt;RANGE&lt;/code&gt; terminology. For a URL shortener it's a 6-character random string like &lt;code&gt;abc123&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PAY_PER_REQUEST&lt;/code&gt; - billing mode. The other option is &lt;code&gt;PROVISIONED&lt;/code&gt; where you pre-allocate read/write capacity. &lt;code&gt;PAY_PER_REQUEST&lt;/code&gt; (also called "on-demand") is what you want for unpredictable workloads, and what most new tables use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why is &lt;code&gt;long_url&lt;/code&gt; not in &lt;code&gt;--attribute-definitions&lt;/code&gt;?&lt;/strong&gt; DynamoDB only requires you to declare attributes used in keys (primary or secondary indexes). Everything else is freeform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Confirm it's there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal dynamodb describe-table &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; shortlinks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Table.{Name:TableName,Status:TableStatus,Keys:KeySchema}'&lt;/span&gt;


&lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"Name"&lt;/span&gt;: &lt;span class="s2"&gt;"shortlinks"&lt;/span&gt;,
    &lt;span class="s2"&gt;"Status"&lt;/span&gt;: &lt;span class="s2"&gt;"ACTIVE"&lt;/span&gt;,
    &lt;span class="s2"&gt;"Keys"&lt;/span&gt;: &lt;span class="o"&gt;[&lt;/span&gt;
        &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"AttributeName"&lt;/span&gt;: &lt;span class="s2"&gt;"code"&lt;/span&gt;, &lt;span class="s2"&gt;"KeyType"&lt;/span&gt;: &lt;span class="s2"&gt;"HASH"&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Set up the Node project
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;"type": "module"&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"part2-dynamodb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependencies"&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;"@aws-sdk/client-dynamodb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^3.600.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@aws-sdk/lib-dynamodb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^3.600.0"&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;A note on the two packages: &lt;code&gt;@aws-sdk/client-dynamodb&lt;/code&gt; is the low-level client that speaks the raw DynamoDB JSON wire format (where strings are wrapped as &lt;code&gt;{ S: "..." }&lt;/code&gt; and numbers as &lt;code&gt;{ N: "..." }&lt;/code&gt;). &lt;code&gt;@aws-sdk/lib-dynamodb&lt;/code&gt; is a small wrapper that lets you work with plain JavaScript objects. Use the wrapper for application code; you almost never want the raw form.&lt;/p&gt;

&lt;p&gt;The exact minor version will move over time. Any current AWS SDK v3 release is fine here, the pattern is the thing that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Write the shortlinks module
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;shortlinks.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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DynamoDBClient&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;@aws-sdk/client-dynamodb&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;DynamoDBDocumentClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;PutCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ScanCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;UpdateCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;DeleteCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;BatchWriteCommand&lt;/span&gt;&lt;span class="p"&gt;,&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;@aws-sdk/lib-dynamodb&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;setTimeout&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;sleep&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;node:timers/promises&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;client&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;DynamoDBClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:4566&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&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;ddb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DynamoDBDocumentClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&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;TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shortlinks&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;randomCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&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;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;abcdefghijklmnopqrstuvwxyz0123456789&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&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;Now the operations, one at a time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shorten - write a new entry, fail if the code is taken
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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;shorten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;longUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomCode&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;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PutCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;longUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;click_count&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="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;ConditionExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attribute_not_exists(code)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;code&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 line that's worth understanding: &lt;code&gt;ConditionExpression: 'attribute_not_exists(code)'&lt;/code&gt; makes the write conditional on the code not already being in the table. Two requests racing to claim the same code - only one wins. The other gets &lt;code&gt;ConditionalCheckFailedException&lt;/code&gt;, which the caller can catch and retry with a new code.&lt;/p&gt;

&lt;p&gt;This is genuinely how production URL shorteners avoid double-claiming. In a real app, that failure path usually triggers "generate a new code and try again" rather than surfacing the exception directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resolve - increment the counter and return the updated item
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UpdateCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TABLE&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;ConditionExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attribute_exists(code)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;UpdateExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SET click_count = if_not_exists(click_count, :zero) + :one&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;ExpressionAttributeValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:zero&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:one&lt;/span&gt;&lt;span class="dl"&gt;'&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="na"&gt;ReturnValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ALL_NEW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Attributes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ConditionalCheckFailedException&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This version is closer to what you'd actually ship:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;attribute_exists(code)&lt;/code&gt;&lt;/strong&gt; stops DynamoDB from creating a brand-new row when the short code doesn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;if_not_exists(click_count, :zero) + :one&lt;/code&gt; keeps the counter safe even if an older row is missing &lt;code&gt;click_count&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReturnValues: 'ALL_NEW'&lt;/code&gt;&lt;/strong&gt; gives you the updated item back in the same call, so the read path stays compact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key point is the atomic increment. Even with lots of concurrent resolves, every click is counted exactly once. No read-modify-write loop, no transaction needed. The &lt;code&gt;attribute_exists(code)&lt;/code&gt; guard matters because &lt;code&gt;UpdateItem&lt;/code&gt; can create an item if you let it.&lt;/p&gt;

&lt;h3&gt;
  
  
  List, remove
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&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;out&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;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ScanCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;limit&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="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Items&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localeCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DeleteCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TABLE&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&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;&lt;code&gt;ScanCommand&lt;/code&gt; is fine for tens or hundreds of items but reads every item in the table. It also does not return a meaningful order, so the small in-memory sort is the only reason the CLI output looks stable here. We'll talk about the bigger tradeoffs in a moment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixture loading with BatchWrite
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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;loadFixtures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// BatchWrite is capped at 25 items per request.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&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;PutRequest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
          &lt;span class="na"&gt;click_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;click_count&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;

    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&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;ddb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BatchWriteCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;RequestItems&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;TABLE&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;pending&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;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UnprocessedItems&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="nx"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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 25-per-request cap is a real DynamoDB limit, not a LocalStack one. The other detail that matters is &lt;code&gt;UnprocessedItems&lt;/code&gt;: DynamoDB can accept part of a batch and ask you to retry the rest. That's why the loop keeps retrying until the chunk drains. The fixed &lt;code&gt;200ms&lt;/code&gt; pause is perfectly fine for a local fixture script like this; for production retry loops, exponential backoff is the usual guidance. One more wrinkle: &lt;code&gt;BatchWrite&lt;/code&gt; only supports puts and deletes, not conditional writes or partial updates, which is why fixtures use it but the live shorten/resolve path doesn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI wrapper at the bottom
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;`file://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&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="s2"&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="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shorten&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Shortened to: &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;shorten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;args&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resolve&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;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;list&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;click_count&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;click_count&lt;/span&gt; &lt;span class="p"&gt;})));&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;remove&lt;/span&gt;&lt;span class="dl"&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;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Removed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;args&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fixtures&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;await&lt;/span&gt; &lt;span class="nf"&gt;loadFixtures&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://github.com&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;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;np&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://npmjs.com&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;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mdn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://developer.mozilla.org&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;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aws&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;long_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://docs.aws.amazon.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Loaded 4 fixtures&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Usage: node shortlinks.js &amp;lt;shorten|resolve|list|remove|fixtures&amp;gt; [args...]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Try it out
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node shortlinks.js fixtures
&lt;span class="go"&gt;Loaded 4 fixtures

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node shortlinks.js list
&lt;span class="go"&gt;┌─────────┬───────┬─────────────────────────────────┬─────────────┐
│ (index) │ code │ long_url │ click_count │
├─────────┼───────┼─────────────────────────────────┼─────────────┤
│ 0 │ 'aws' │ 'https://docs.aws.amazon.com' │ 0 │
│ 1 │ 'gh' │ 'https://github.com' │ 0 │
│ 2 │ 'mdn' │ 'https://developer.mozilla.org' │ 0 │
│ 3 │ 'np' │ 'https://npmjs.com' │ 0 │
└─────────┴───────┴─────────────────────────────────┴─────────────┘

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node shortlinks.js resolve gh
&lt;span class="go"&gt;{
  "click_count": 1,
  "created_at": "2026-05-11T17:15:33.184Z",
  "code": "gh",
  "long_url": "https://github.com"
}

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node shortlinks.js resolve gh
&lt;span class="go"&gt;{
  "click_count": 2,
  "created_at": "2026-05-11T17:15:33.184Z",
  "code": "gh",
  "long_url": "https://github.com"
}

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node shortlinks.js shorten &lt;span class="s2"&gt;"https://anthropic.com"&lt;/span&gt; claude
&lt;span class="go"&gt;Shortened to: claude

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node shortlinks.js shorten &lt;span class="s2"&gt;"https://example.com"&lt;/span&gt; claude
&lt;span class="go"&gt;... ConditionalCheckFailedException

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The atomic counter ticked from 1 to 2 across two separate processes. The second &lt;code&gt;shorten&lt;/code&gt; for &lt;code&gt;claude&lt;/code&gt; failed cleanly because the code was already taken. In a production shortener, that's where you'd generate another code and retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scan vs Query, before this grows up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Scan&lt;/code&gt; walks the table rather than doing a key-targeted lookup - fine for tens of items, painful at thousands, ruinous at millions. The two patterns you'll actually want in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Query&lt;/strong&gt; - point reads by partition key, plus sorted reads when you have a sort key. The thing DynamoDB is fastest at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global Secondary Index (GSI)&lt;/strong&gt; - a second key on a different attribute. For instance, if you wanted to look up shortlinks by &lt;code&gt;long_url&lt;/code&gt; ("has anyone shortened this before?"), you'd add a GSI with &lt;code&gt;long_url&lt;/code&gt; as its partition key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For Part 4 of this series we won't need a GSI - the API only ever looks up by code. If you later want a "my shortlinks" listing per user, add a GSI on &lt;code&gt;owner_id&lt;/code&gt; and &lt;code&gt;Query&lt;/code&gt; it. We'll wire that up properly when we hit the auth article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Single-table design - when you'd actually need it
&lt;/h2&gt;

&lt;p&gt;You'll see "single-table design" mentioned in DynamoDB tutorials a lot. The idea is simple enough: instead of one table per entity (users, posts, comments), you put everything in one table with composed keys (&lt;code&gt;USER#123&lt;/code&gt;, &lt;code&gt;POST#456&lt;/code&gt;, &lt;code&gt;COMMENT#789#REPLY#1&lt;/code&gt;). It's powerful and saves money at scale.&lt;/p&gt;

&lt;p&gt;For a URL shortener with one entity, it's overkill. The moment you start adding owners, tags, click events, or shared lists, the single-table approach starts paying off. We're keeping it simple here. If this app grows up in your own homelab, that's the point where you'd revisit the table design. Alex DeBrie's &lt;a href="https://www.dynamodbbook.com/?ref=alishaikh.me" rel="noopener noreferrer"&gt;DynamoDB Book&lt;/a&gt; is the canonical reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ResourceNotFoundException&lt;/code&gt;&lt;/strong&gt; when running the script. Forgot to create the table, or you typed the table name wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ConditionalCheckFailedException&lt;/code&gt; on &lt;code&gt;resolve&lt;/code&gt; for a code you expected to exist.&lt;/strong&gt; The item is missing, or you wrote it into a different table or endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty &lt;code&gt;Items&lt;/code&gt; array on Scan even after writes succeeded.&lt;/strong&gt; You're scanning a different table, or pointing at a different LocalStack instance. Run &lt;code&gt;awslocal dynamodb list-tables&lt;/code&gt; to confirm.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UnrecognizedClientException&lt;/code&gt; when using the SDK. You forgot to set &lt;code&gt;endpoint&lt;/code&gt; and &lt;code&gt;credentials&lt;/code&gt; on the client - it's trying to hit real AWS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cleanup commands worth knowing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Drop the table entirely&lt;/span&gt;
awslocal dynamodb delete-table &lt;span class="nt"&gt;--table-name&lt;/span&gt; shortlinks

&lt;span class="c"&gt;# Recreate it when you want a clean slate again&lt;/span&gt;
awslocal dynamodb create-table &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; shortlinks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attribute-definitions&lt;/span&gt; &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;code,AttributeType&lt;span class="o"&gt;=&lt;/span&gt;S &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key-schema&lt;/span&gt; &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;code,KeyType&lt;span class="o"&gt;=&lt;/span&gt;HASH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--billing-mode&lt;/span&gt; PAY_PER_REQUEST

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop-and-recreate is what you'll usually reach for during development. Fewer moving parts, less shell glue, less chance of deleting the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Save this as a checkpoint
&lt;/h2&gt;

&lt;p&gt;Add this to your &lt;code&gt;init/ready.d/&lt;/code&gt; folder so the table comes back automatically next time you &lt;code&gt;docker compose up&lt;/code&gt;. (Pattern set up in Part 0.)&lt;/p&gt;

&lt;p&gt;Save as &lt;code&gt;init/ready.d/02-part2-dynamodb.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Part 2 checkpoint - DynamoDB shortlinks table&lt;/span&gt;
awslocal dynamodb create-table &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; shortlinks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attribute-definitions&lt;/span&gt; &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;code,AttributeType&lt;span class="o"&gt;=&lt;/span&gt;S &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key-schema&lt;/span&gt; &lt;span class="nv"&gt;AttributeName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;code,KeyType&lt;span class="o"&gt;=&lt;/span&gt;HASH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--billing-mode&lt;/span&gt; PAY_PER_REQUEST 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;awslocal dynamodb &lt;span class="nb"&gt;wait &lt;/span&gt;table-exists &lt;span class="nt"&gt;--table-name&lt;/span&gt; shortlinks 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
echo&lt;/span&gt; &lt;span class="s2"&gt;"[bootstrap] part 2 - shortlinks table ready"&lt;/span&gt;


&lt;span class="nb"&gt;chmod&lt;/span&gt; +x init/ready.d/02-part2-dynamodb.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Jumping in at Part 2 from scratch?&lt;/strong&gt; Drop in &lt;code&gt;01-part1-s3.sh&lt;/code&gt; from Part 1 alongside this one. Strictly speaking, Part 2 doesn't depend on Part 1 yet. Still, keeping the scripts in numeric order now makes the later parts much less messy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'll wire up next
&lt;/h2&gt;

&lt;p&gt;You've got a working data layer with atomic counters and collision-safe writes. &lt;strong&gt;The next part&lt;/strong&gt; brings Lambda into the mix: a Python function that listens for S3 upload events from Part 1's photo bucket and writes a thumbnail to a second bucket. Then Part 4 will wrap our shortener (this article's table) and the photo flow (Part 1 + 3) into a real HTTP API with JWT auth.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The full series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Part 0&lt;/a&gt; - Start here: series intro and installing LocalStack&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; - S3 locally: buckets, presigned URLs, and a tiny photo uploader&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2&lt;/strong&gt; - DynamoDB locally: building a URL shortener data layer &lt;em&gt;(this article)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 3 - Lambda + S3 events: an image thumbnailer pipeline &lt;em&gt;(next)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 4 - API Gateway + Lambda + JWT auth: a real HTTP API&lt;/li&gt;
&lt;li&gt;Part 5 - SQS + SNS: a background job queue with a dead-letter queue&lt;/li&gt;
&lt;li&gt;Part 6 - EventBridge + Step Functions: orchestrating a photo-processing workflow&lt;/li&gt;
&lt;li&gt;Part 7 - Secrets Manager + KMS: handling secrets and encryption locally&lt;/li&gt;
&lt;li&gt;Part 8 - Terraform (tflocal) + GitHub Actions: integration tests against LocalStack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;DynamoDB UpdateItem documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-lib-dynamodb/?ref=alishaikh.me" rel="noopener noreferrer"&gt;@aws-sdk/lib-dynamodb (Document Client)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.dynamodbbook.com/?ref=alishaikh.me" rel="noopener noreferrer"&gt;The DynamoDB Book - Alex DeBrie&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Related on alishaikh.me&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 0 - Setup)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-urls/" rel="noopener noreferrer"&gt;Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 1 - S3 Photo Uploader with Presigned URLs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/localstack-run-aws-services-locally-for-free/" rel="noopener noreferrer"&gt;LocalStack: Run AWS Services Locally for Free&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>homelab</category>
      <category>localstack</category>
    </item>
    <item>
      <title>Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 1 - S3 Photo Uploader with Presigned URLs)</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Sat, 09 May 2026 19:50:51 +0000</pubDate>
      <link>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-21il</link>
      <guid>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-1-s3-photo-uploader-with-presigned-21il</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx5gaivfgnih71zy4ppq9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx5gaivfgnih71zy4ppq9.png" alt="Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 1 - S3 Photo Uploader with Presigned URLs)" width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you've already worked through&lt;/strong&gt; &lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;&lt;strong&gt;Part 0 - Setup&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;, this is where the series starts feeling real. By the end of this article you'll have a working photo uploader where the browser sends files straight to an S3 bucket through a&lt;/strong&gt; &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;&lt;strong&gt;presigned URL&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;. Same production pattern, just running on LocalStack on your laptop.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser ─┐
          │ 1. GET /sign?key=&amp;lt;filename&amp;gt;
          ▼
        Backend (Node) ── 2. presigned URL ──▶ Browser
                                                  │
                                                  │ 3. PUT file directly to S3
                                                  ▼
                                              LocalStack S3
                                              (bucket: photos)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A two-component app. The Node backend issues short-lived presigned URLs. The browser uses those URLs to upload files straight to S3 - the backend never sees the file bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this pattern actually matters
&lt;/h2&gt;

&lt;p&gt;If you're new to presigned URLs, this is the part where it clicks. Most beginner tutorials show you how to upload a file by streaming it through your backend: browser → backend → S3. That works for tiny files. It falls apart fast at scale because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your backend handles every byte. A 50MB photo upload ties up a server worker for several seconds.&lt;/li&gt;
&lt;li&gt;Bandwidth is doubled (in to your backend, out to S3).&lt;/li&gt;
&lt;li&gt;Memory pressure climbs the moment you have a few concurrent uploads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Presigned URLs flip this. Your backend signs a short-lived URL that authorises a single upload, then hands the URL to the browser. The browser uploads directly to S3. The backend is back to handling JSON-sized requests in milliseconds.&lt;/p&gt;

&lt;p&gt;You'll see this pattern all over modern SaaS. Worth learning properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Make a project folder for Part 1
&lt;/h2&gt;

&lt;p&gt;We'll keep each part's code in its own subfolder under the series root from Part 0:&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;cd&lt;/span&gt; ~/projects/localstack-series
&lt;span class="nb"&gt;mkdir &lt;/span&gt;part1-s3-uploader
&lt;span class="nb"&gt;cd &lt;/span&gt;part1-s3-uploader

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LocalStack should already be running. Quick check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps
awslocal s3 &lt;span class="nb"&gt;ls&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see your &lt;code&gt;hello-localstack&lt;/code&gt; bucket from Part 0, you're good to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create the photos bucket
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal s3 mb s3://photos

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the storage half done. Next we need to tell the bucket it's allowed to accept uploads from a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Configure CORS on the bucket
&lt;/h2&gt;

&lt;p&gt;The browser will be making a cross-origin PUT from &lt;code&gt;http://localhost:3000&lt;/code&gt; (our backend) to &lt;code&gt;http://localhost:4566&lt;/code&gt; (LocalStack S3). Without &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;CORS&lt;/a&gt; configured, the browser will refuse the upload.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;cors.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"CORSRules"&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;"AllowedHeaders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"AllowedMethods"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUT"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"AllowedOrigins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ExposeHeaders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ETag"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal s3api put-bucket-cors &lt;span class="nt"&gt;--bucket&lt;/span&gt; photos &lt;span class="nt"&gt;--cors-configuration&lt;/span&gt; file://cors.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AllowedOrigins: ["*"]&lt;/code&gt; is fine for local dev. In production you'd lock this down to your real frontend origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The backend - issuing presigned URLs
&lt;/h2&gt;

&lt;p&gt;Set up the Node project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;package.json&lt;/code&gt;, set the type to &lt;code&gt;module&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"part1-s3-uploader"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependencies"&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;"@aws-sdk/client-s3"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^3.600.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@aws-sdk/s3-request-presigner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^3.600.0"&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;Create &lt;code&gt;server.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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PutObjectCommand&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;@aws-sdk/client-s3&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;getSignedUrl&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;@aws-sdk/s3-request-presigner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&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;node:http&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;readFileSync&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;node:fs&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;s3&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;S3Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:4566&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;forcePathStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test&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;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/sign&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;url&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;key&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;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/octet-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;key is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="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;cmd&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;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;photos&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;ContentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;type&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;signedUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expiresIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signedUrl&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="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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Listening on http://localhost:3000&lt;/span&gt;&lt;span class="dl"&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 things worth understanding line by line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;endpoint: 'http://localhost:4566'&lt;/code&gt;&lt;/strong&gt; - points the SDK at LocalStack instead of real AWS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;forcePathStyle: true&lt;/code&gt;&lt;/strong&gt; - the small but important LocalStack gotcha. As the &lt;a href="https://docs.localstack.cloud/aws/services/s3/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack S3 docs&lt;/a&gt; explain, path-style requests are the safe default for local S3 work. Without this, the SDK builds URLs like &lt;code&gt;https://photos.s3.amazonaws.com/...&lt;/code&gt; which won't resolve locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;credentials: { accessKeyId: 'test', secretAccessKey: 'test' }&lt;/code&gt;&lt;/strong&gt; - LocalStack accepts any credentials by default. The strings can be anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;expiresIn: 60&lt;/code&gt;&lt;/strong&gt; - the presigned URL is valid for 60 seconds. Long enough for a slow connection, short enough that a leaked URL isn't a long-term problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: The frontend - a tiny upload form
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;index.html&lt;/code&gt; next to &lt;code&gt;server.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Photo Uploader (LocalStack)&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;480px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4rem&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.5rem&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#137333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.err&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#c5221f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Photo Uploader&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Pick an image and upload it straight to LocalStack S3.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="na"&gt;accept=&lt;/span&gt;&lt;span class="s"&gt;"image/*"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"upload"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Upload&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&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;$upload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upload&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;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;$upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&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="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;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/octet-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Getting presigned URL...&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;params&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/sign?&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

      &lt;span class="nx"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Uploading to S3...&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;upload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;type&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;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Uploaded as &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;err&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Upload failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small but important details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The browser PUTs the file directly to the URL returned by the backend. Note the request goes to &lt;code&gt;localhost:4566&lt;/code&gt;, not &lt;code&gt;localhost:3000&lt;/code&gt; - the backend is out of the picture once the URL is signed.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Content-Type&lt;/code&gt; header on the PUT must match what the backend signed. If they don't match, S3 returns a 403 with a "signature does not match" error. The code above uses the same &lt;code&gt;type&lt;/code&gt; variable for both.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node server.js

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://localhost:3000&lt;/code&gt; in a browser, pick a photo, and click Upload. You should see "Uploaded as 1714...-..." within a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Verify the file in S3
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://photos/

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your uploaded file should be there with a &lt;code&gt;Date.now()&lt;/code&gt;-prefixed key. To download it back and check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal s3 &lt;span class="nb"&gt;cp &lt;/span&gt;s3://photos/&amp;lt;your-key&amp;gt; ./downloaded.jpg

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or grab a presigned GET URL and view it in a browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal s3 presign s3://photos/&amp;lt;your-key&amp;gt; &lt;span class="nt"&gt;--expires-in&lt;/span&gt; 60

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open the printed URL - your photo loads.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real-world touch: auto-expiring uploads with a lifecycle rule
&lt;/h2&gt;

&lt;p&gt;S3 &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-expire-general-considerations.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;lifecycle rules&lt;/a&gt; let you tell the bucket "delete anything older than N days". Genuinely useful for user uploads where you don't want to store everything forever.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;lifecycle.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Rules"&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;"ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expire-uploads-after-30-days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Enabled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Filter"&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;"Prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Expiration"&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;"Days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal s3api put-bucket-lifecycle-configuration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; photos &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lifecycle-configuration&lt;/span&gt; file://lifecycle.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In real AWS, S3 runs lifecycle expiration asynchronously, not the instant the clock flips. For this article, the useful bit is the bucket configuration itself: you can keep the same rule in local dev and production without changing your app code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CORS errors in the browser console.&lt;/strong&gt; Re-check that you ran the &lt;code&gt;put-bucket-cors&lt;/code&gt; command after creating the bucket. The error message looks like "blocked by CORS policy".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;403 SignatureDoesNotMatch on upload.&lt;/strong&gt; The &lt;code&gt;Content-Type&lt;/code&gt; you sent in the PUT didn't match what the backend signed. Print both, line them up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getaddrinfo ENOTFOUND photos.s3.amazonaws.com&lt;/code&gt;.&lt;/strong&gt; You forgot &lt;code&gt;forcePathStyle: true&lt;/code&gt; on the S3 client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presigned URL works for a few seconds then 403s.&lt;/strong&gt; It's expired - you set &lt;code&gt;expiresIn&lt;/code&gt; too low for a slow upload. Bump it to 300 (five minutes) for testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server can't read &lt;code&gt;index.html&lt;/code&gt;.&lt;/strong&gt; Run &lt;code&gt;node server.js&lt;/code&gt; from the &lt;code&gt;part1-s3-uploader/&lt;/code&gt; folder, not from a parent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cleanup commands worth knowing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Empty the bucket&lt;/span&gt;
awslocal s3 &lt;span class="nb"&gt;rm &lt;/span&gt;s3://photos &lt;span class="nt"&gt;--recursive&lt;/span&gt;

&lt;span class="c"&gt;# Delete the bucket entirely&lt;/span&gt;
awslocal s3 rb s3://photos &lt;span class="nt"&gt;--force&lt;/span&gt;

&lt;span class="c"&gt;# Or just stop the Node server with Ctrl+C - LocalStack keeps running&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't actually delete the bucket if you plan to do Part 3 (the Lambda thumbnailer reads from this same bucket).&lt;/p&gt;

&lt;h2&gt;
  
  
  Save this as a checkpoint
&lt;/h2&gt;

&lt;p&gt;State doesn't survive a &lt;code&gt;docker compose down&lt;/code&gt; on the free Hobby tier, so let's bottle this part up as a script that recreates everything next time LocalStack starts. (Part 0 set up the &lt;code&gt;init/ready.d/&lt;/code&gt; folder and the docker-compose mount; we just drop a new file in it.)&lt;/p&gt;

&lt;p&gt;Save as &lt;code&gt;init/ready.d/01-part1-s3.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Part 1 checkpoint - S3 photo uploader (bucket + CORS + lifecycle)&lt;/span&gt;
awslocal s3 mb s3://photos 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;awslocal s3api put-bucket-cors &lt;span class="nt"&gt;--bucket&lt;/span&gt; photos &lt;span class="nt"&gt;--cors-configuration&lt;/span&gt; &lt;span class="s1"&gt;'{
  "CORSRules":[{"AllowedHeaders":["*"],"AllowedMethods":["GET","PUT"],"AllowedOrigins":["*"],"ExposeHeaders":["ETag"]}]
}'&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;awslocal s3api put-bucket-lifecycle-configuration &lt;span class="nt"&gt;--bucket&lt;/span&gt; photos &lt;span class="nt"&gt;--lifecycle-configuration&lt;/span&gt; &lt;span class="s1"&gt;'{
  "Rules":[{"ID":"expire-uploads-after-30-days","Status":"Enabled","Filter":{"Prefix":""},"Expiration":{"Days":30}}]
}'&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

echo&lt;/span&gt; &lt;span class="s2"&gt;"[bootstrap] part 1 - photos bucket + CORS + lifecycle ready"&lt;/span&gt;


&lt;span class="nb"&gt;chmod&lt;/span&gt; +x init/ready.d/01-part1-s3.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next &lt;code&gt;docker compose up&lt;/code&gt; and your Part 1 setup is back. The &lt;code&gt;2&amp;gt;/dev/null || true&lt;/code&gt; keeps each command idempotent - re-running on an already-set-up bucket is harmless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jumping in at Part 1 from a fresh LocalStack?&lt;/strong&gt; This script is all you need (Part 0 has no resources of its own beyond the smoke-test bucket).&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'll wire up next
&lt;/h2&gt;

&lt;p&gt;You've got a real upload flow running locally - same pattern as production SaaS apps, in 50-odd lines of code. &lt;strong&gt;The next part&lt;/strong&gt; builds the data layer for our URL shortener using DynamoDB: single-table design, fixture loading, and the &lt;code&gt;awslocal dynamodb&lt;/code&gt; commands you'll keep coming back to. Part 4 will wire it all to an HTTP API.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The full series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Part 0&lt;/a&gt; - Start here: series intro and installing LocalStack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 1&lt;/strong&gt; - S3 locally: buckets, presigned URLs, and a tiny photo uploader &lt;em&gt;(this article)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 2 - DynamoDB locally: building a URL shortener data layer &lt;em&gt;(next)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 3 - Lambda + S3 events: an image thumbnailer pipeline&lt;/li&gt;
&lt;li&gt;Part 4 - API Gateway + Lambda + JWT auth: a real HTTP API&lt;/li&gt;
&lt;li&gt;Part 5 - SQS + SNS: a background job queue with a dead-letter queue&lt;/li&gt;
&lt;li&gt;Part 6 - EventBridge + Step Functions: orchestrating a photo-processing workflow&lt;/li&gt;
&lt;li&gt;Part 7 - Secrets Manager + KMS: handling secrets and encryption locally&lt;/li&gt;
&lt;li&gt;Part 8 - Terraform (tflocal) + GitHub Actions: integration tests against LocalStack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;AWS docs - uploading objects with presigned URLs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html?ref=alishaikh.me" rel="noopener noreferrer"&gt;AWS docs - using CORS with S3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/aws/services/s3/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack S3 docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Related Posts&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup/" rel="noopener noreferrer"&gt;Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 0 - Setup)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/localstack-run-aws-services-locally-for-free/" rel="noopener noreferrer"&gt;LocalStack: Run AWS Services Locally for Free&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>cloudnative</category>
      <category>devops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Docker's MCP Catalog and Toolkit: What It Is and Why It Matters</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Tue, 05 May 2026 17:03:02 +0000</pubDate>
      <link>https://dev.to/alishaikh/dockers-mcp-catalog-and-toolkit-what-it-is-and-why-it-matters-fph</link>
      <guid>https://dev.to/alishaikh/dockers-mcp-catalog-and-toolkit-what-it-is-and-why-it-matters-fph</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi0nhste5csv0joukrd9s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi0nhste5csv0joukrd9s.png" alt="Docker's MCP Catalog and Toolkit: What It Is and Why It Matters" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So Docker has shipped its MCP Toolkit. If you've been wiring up Claude or Cursor to talk to outside tools and felt like you were duct-taping JSON config files together at 2 am, you're not the only one. The Model Context Protocol crowd has grown fast, and the plumbing has stayed messy. Docker's answer: package the servers as containers, list them in a catalogue, and put a single gateway in front of the lot.&lt;/p&gt;

&lt;p&gt;Let me explain what's actually new, what it does, and where it fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mess MCP was creating
&lt;/h2&gt;

&lt;p&gt;Model Context Protocol is the open spec that lets a model talk to external tools, files, and APIs in a structured way. It works. But every server tends to have its own install steps, its own runtime, and its own quirks. Want GitHub, Atlassian, and a search tool wired into your editor? You'd be cloning three repos, juggling Node and Python versions, and stuffing API keys into config files that almost certainly end up in someone's screenshare by accident.&lt;/p&gt;

&lt;p&gt;Docker's pitch is straightforward: containers already solved this for web apps a decade ago, so do the same for MCP servers (&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker Docs&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the MCP Toolkit actually is
&lt;/h2&gt;

&lt;p&gt;The MCP Toolkit is a management surface inside Docker Desktop (4.62 and up) that lets you browse, launch, and configure containerised MCP servers, then point your AI client at them (&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/toolkit/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker Docs&lt;/a&gt;). Underneath, every server is a regular container image, so you don't need Node, Python, or some random binary on your host to run them.&lt;/p&gt;

&lt;p&gt;The Toolkit comes with three pieces worth knowing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Catalog&lt;/strong&gt; : a curated list of over 200 verified MCP servers, packaged as signed images with SBOMs, provenance, and security updates (&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/catalog/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker Docs&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Gateway&lt;/strong&gt; : a single endpoint your client connects to, which fronts every server you've enabled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profiles&lt;/strong&gt; : named bundles of servers and their config, so your "data analysis" stack doesn't bleed into your "infra ops" stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll come back to each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Catalog: a registry, but for AI tools
&lt;/h2&gt;

&lt;p&gt;If Docker Hub is where you go for a Postgres image, the MCP Catalog is where you go for a YouTube transcript reader, a Brave Search server, a Notion connector, an Atlassian bridge, and so on. Images live under the &lt;code&gt;mcp/&lt;/code&gt; namespace, are signed by Docker, and ship with a Software Bill of Materials so you can see exactly what's inside (&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/catalog/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker Docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The supply-chain story matters here. MCP servers run with privileges your model gets to use, which means a poisoned image is a really bad day. Signed images, provenance checks, and Scout integration push that risk closer to the floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gateway: one socket to rule them all
&lt;/h2&gt;

&lt;p&gt;Here's where it gets clever. Instead of every client opening a connection to every server, the Gateway sits in the middle and aggregates them (&lt;a href="https://www.docker.com/blog/mcp-toolkit-gateway-explained/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker blog&lt;/a&gt;). Your client (Claude Desktop, Cursor, VS Code, Windsurf, Goose, continue.dev, and OpenAI's Codex CLI) connects once. The Gateway:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;starts and stops the underlying containers,&lt;/li&gt;
&lt;li&gt;proxies trusted remote servers when you'd rather not run them locally,&lt;/li&gt;
&lt;li&gt;handles OAuth flows so you don't paste tokens into config files,&lt;/li&gt;
&lt;li&gt;and mounts secrets only into the container that needs them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can run more than one Gateway, each with a different profile attached. So a "chess tools" Gateway and a "cloud ops" Gateway can sit side by side without stepping on each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Profiles and the March 2026 templates
&lt;/h2&gt;

&lt;p&gt;Profiles are just named collections: pick a few servers, set their config, save the bundle. They're version-controllable, which is the bit teams care about. Compose-first means a profile can move from a laptop into Cloud Run or Azure Container Apps without much rework (&lt;a href="https://www.docker.com/blog/mcp-toolkit-gateway-explained/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker blog&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In March 2026, Docker Desktop 4.67 added Profile Templates: a starter-pack idea applied to MCP, surfaced through the &lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/profiles/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Profiles tab&lt;/a&gt; and the &lt;code&gt;docker mcp profile create&lt;/code&gt; CLI (&lt;a href="https://doolpa.com/news/docker-desktop-4-67-mcp-profile-templates-march-2026?ref=alishaikh.me" rel="noopener noreferrer"&gt;Doolpa&lt;/a&gt; for the release date). Pick a card on the Profiles tab, get a pre-configured bundle for web work (think GitHub plus Playwright), data analysis, or cloud infra, and you're running in seconds rather than fiddling with five separate setup guides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security, but the sort that doesn't make you cry
&lt;/h2&gt;

&lt;p&gt;A few things make the security model nicer than the DIY route:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image signing and provenance&lt;/strong&gt; on everything in the catalogue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth handled in-browser&lt;/strong&gt; , with credentials stored by the Toolkit rather than scattered across YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-container secret mounts&lt;/strong&gt; so your GitHub token isn't visible to the Notion server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoped tokens&lt;/strong&gt; for services like GitHub, so the Toolkit hands the model the narrowest credential it can get away with rather than a blanket personal access token (&lt;a href="https://www.docker.com/blog/mcp-toolkit-gateway-explained/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker blog&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is magic. It's the same supply-chain hygiene Docker already pushed for application images, just pointed at a new audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it from the CLI
&lt;/h2&gt;

&lt;p&gt;If you live in your terminal, the Toolkit ships with a &lt;code&gt;docker mcp&lt;/code&gt; command set so you can list servers, enable them, and start the Gateway without ever opening Desktop's UI (&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/cli/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker Docs&lt;/a&gt;). Handy for scripts and CI, and frankly nicer when you're already in flow.&lt;/p&gt;

&lt;p&gt;A first-time run looks roughly like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Update Docker Desktop to 4.62 or later.&lt;/li&gt;
&lt;li&gt;Open the MCP Toolkit panel, browse the Catalog, click into a server you want.&lt;/li&gt;
&lt;li&gt;Authenticate where needed (OAuth pops a browser window).&lt;/li&gt;
&lt;li&gt;Add the server to a profile.&lt;/li&gt;
&lt;li&gt;Start the Gateway.&lt;/li&gt;
&lt;li&gt;Point Claude, Cursor, or VS Code at it.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;mcp list&lt;/code&gt; from the client and watch the tools appear.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the loop. No virtualenvs, no Node version managers, no "works on my machine" tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into Codex
&lt;/h2&gt;

&lt;p&gt;Codex deserves its own note because Docker has done the integration work properly. OpenAI's Codex CLI and IDE extension share an MCP config at &lt;code&gt;~/.codex/config.toml&lt;/code&gt;, and Docker ships a one-shot helper that fills it in for you (&lt;a href="https://www.docker.com/blog/connect-codex-to-mcp-servers-mcp-toolkit/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker blog&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;docker mcp client configure codex

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that once, and any server you've enabled in your Toolkit profile shows up inside Codex on the next launch. The same trick works for Claude Desktop, Cursor, and the others, but the Codex story is the freshest one and the easiest to recommend if you're already in the OpenAI camp (&lt;a href="https://developers.openai.com/codex/mcp?ref=alishaikh.me" rel="noopener noreferrer"&gt;OpenAI Developers&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Who actually benefits
&lt;/h2&gt;

&lt;p&gt;A few honest answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solo developers&lt;/strong&gt; who've been dabbling with MCP and want to try ten servers without breaking their machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teams&lt;/strong&gt; that need shared, reproducible tool stacks. Compose files give you a real source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security-conscious shops&lt;/strong&gt; that won't run unsigned binaries from random GitHub repos. Now they don't have to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone moving from prototype to production&lt;/strong&gt;. The same profile that runs locally can ship to Cloud Run, Azure, or wherever your container platform lives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're not using MCP yet, the Toolkit lowers the trial cost so much that there's no real reason not to poke at it for an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it goes from here
&lt;/h2&gt;

&lt;p&gt;Docker is treating MCP the way it treated the early days of containers: turn the messy install dance into a one-click experience, build a catalogue, then add governance on top. The Gateway being open source means the community can extend it, which is the bit that'll matter long-term (&lt;a href="https://www.docker.com/blog/mcp-toolkit-gateway-explained/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker blog&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Worth a look if you've spent any time wiring AI tools by hand. You'll get that hour back, plus a few more.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/toolkit/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker MCP Toolkit | Docker Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker MCP Catalog and Toolkit | Docker Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/catalog/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker MCP Catalog | Docker Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/profiles/?ref=alishaikh.me" rel="noopener noreferrer"&gt;MCP Profiles | Docker Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/ai/mcp-catalog-and-toolkit/cli/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Use MCP Toolkit from the CLI | Docker Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.docker.com/blog/introducing-docker-mcp-catalog-and-toolkit/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Introducing Docker MCP Catalog and Toolkit | Docker blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.docker.com/blog/mcp-toolkit-gateway-explained/?ref=alishaikh.me" rel="noopener noreferrer"&gt;AI Guide to the Galaxy: MCP Toolkit and Gateway, Explained | Docker blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.docker.com/blog/connect-codex-to-mcp-servers-mcp-toolkit/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Connect Codex to MCP Servers via Docker MCP Toolkit | Docker blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/codex/mcp?ref=alishaikh.me" rel="noopener noreferrer"&gt;Model Context Protocol - Codex | OpenAI Developers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://doolpa.com/news/docker-desktop-4-67-mcp-profile-templates-march-2026?ref=alishaikh.me" rel="noopener noreferrer"&gt;Docker Desktop 4.67: MCP Profile Templates | Doolpa&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>containers</category>
      <category>docker</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 0 — Setup)</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Sun, 03 May 2026 13:42:56 +0000</pubDate>
      <link>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup-bh1</link>
      <guid>https://dev.to/alishaikh/run-aws-on-your-laptop-a-9-part-localstack-build-series-part-0-setup-bh1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7389221cblf81wmxfcli.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7389221cblf81wmxfcli.png" alt="Run AWS on Your Laptop: A 9-Part LocalStack Build Series (Part 0 — Setup)" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Welcome to a 9-part hands-on series on building real things with LocalStack. We'll build a small but real backend on your laptop — photo uploads, image processing, a URL shortener with JWT auth, async job queues, scheduled workflows, encrypted secrets, the lot — all running locally, all without an AWS bill. New parts land as I finish them. By the end you'll have a working stack provisioned with Terraform and tested in GitHub Actions, plus the kind of feel for AWS that only comes from actually wiring it up rather than reading docs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This first article is the "start here" piece. By the end of the next 30 minutes, you'll have LocalStack running on your laptop, the AWS CLI pointed at the right endpoint, and a smoke-tested S3 bucket — ready for the next part.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What you'll need:&lt;/strong&gt; Docker (Docker Desktop on Mac/Windows, or Docker Engine on Linux), about 30 minutes, and a free LocalStack account.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is LocalStack, in one paragraph
&lt;/h2&gt;

&lt;p&gt;LocalStack is an emulator that runs a fake AWS on your laptop. You point your AWS CLI or SDK at &lt;code&gt;http://localhost:4566&lt;/code&gt; instead of the real AWS endpoints, and the rest of your code stays the same. Most of the AWS services you'd actually use day to day are covered — S3, Lambda, DynamoDB, SQS, SNS, EventBridge, API Gateway, Step Functions, Secrets Manager, KMS, and more. The exact count varies by plan and release; the &lt;a href="https://www.localstack.cloud/pricing?ref=alishaikh.me" rel="noopener noreferrer"&gt;free Hobby plan&lt;/a&gt; advertises 30+ services, paid tiers more. If you want the longer conceptual intro, my older post &lt;a href="https://alishaikh.me/localstack-run-aws-services-locally-for-free/" rel="noopener noreferrer"&gt;&lt;strong&gt;LocalStack: Run AWS Services Locally for Free&lt;/strong&gt;&lt;/a&gt; is the place. It pre-dates the March 2026 packaging change (which we'll handle in a moment) but the model still holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're building over the series
&lt;/h2&gt;

&lt;p&gt;One coherent project, threaded through the next nine weeks. By the end of week nine you'll have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A photo upload flow&lt;/strong&gt; (S3 + presigned URLs) where the browser uploads directly to a bucket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An image processing pipeline&lt;/strong&gt; (Lambda fired by S3 events) that creates thumbnails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A URL shortener API&lt;/strong&gt; (DynamoDB + API Gateway + Lambda + JWT auth) so users can share short links&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A background job queue&lt;/strong&gt; (SQS + SNS + dead-letter queue) for things like welcome emails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scheduled workflow&lt;/strong&gt; (EventBridge + Step Functions) running nightly cleanup and reprocessing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted secrets&lt;/strong&gt; (Secrets Manager + KMS) for API keys and third-party credentials&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole stack provisioned with Terraform&lt;/strong&gt; (via &lt;code&gt;tflocal&lt;/code&gt;) and tested in &lt;strong&gt;GitHub Actions CI&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same architectural patterns most small SaaS apps use. The same patterns you'd use against real AWS — the only difference is the endpoint URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this series is for
&lt;/h2&gt;

&lt;p&gt;You'll get the most out of it if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You write code in Node, Python, Go, or any language with an AWS SDK&lt;/li&gt;
&lt;li&gt;You've used AWS before, even briefly, but don't want to keep paying to learn&lt;/li&gt;
&lt;li&gt;You'd like to write integration tests that actually exercise S3, Lambda, and DynamoDB&lt;/li&gt;
&lt;li&gt;You're a homelabber who wants the AWS patterns running on your own hardware&lt;/li&gt;
&lt;li&gt;You're sizing up AWS for a side project and want to prototype before committing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've never opened the AWS console, &lt;a href="https://alishaikh.me/localstack-run-aws-services-locally-for-free/" rel="noopener noreferrer"&gt;the older intro post&lt;/a&gt; is the friendlier starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Heads up: the 2026 packaging change
&lt;/h2&gt;

&lt;p&gt;Quick footnote because it'll catch you out otherwise. In the &lt;a href="https://blog.localstack.cloud/localstack-for-aws-release-2026-03-0/?ref=alishaikh.me" rel="noopener noreferrer"&gt;March 2026 release&lt;/a&gt; (&lt;code&gt;2026.03.0&lt;/code&gt;), LocalStack consolidated its two Docker images into one and folded the previous free Community Edition into a free &lt;a href="https://www.localstack.cloud/pricing?ref=alishaikh.me" rel="noopener noreferrer"&gt;&lt;strong&gt;Hobby&lt;/strong&gt; plan&lt;/a&gt; for non-commercial use. The image is still free — you just register an account and pass an auth token to the container. Step 1 below walks through it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Grab your auth token
&lt;/h2&gt;

&lt;p&gt;Sign up at &lt;a href="https://app.localstack.cloud/?ref=alishaikh.me" rel="noopener noreferrer"&gt;app.localstack.cloud&lt;/a&gt;, confirm the email, and copy the auth token from your account page. Add it to your shell config so every future shell picks it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.zshrc or ~/.bashrc&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LOCALSTACK_AUTH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ls-...your-token-here...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload your shell (&lt;code&gt;source ~/.zshrc&lt;/code&gt;) before moving on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Set up the project folder
&lt;/h2&gt;

&lt;p&gt;Everything in this series will live under one folder. Create it now:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/projects/localstack-series
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/localstack-series

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We'll add subdirectories per article as the series goes on (&lt;code&gt;part1-s3-uploader/&lt;/code&gt;, &lt;code&gt;part2-dynamodb/&lt;/code&gt;, and so on).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The docker-compose.yml
&lt;/h2&gt;

&lt;p&gt;Drop this in the project root. It's the file you'll keep coming back to:&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;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;localstack&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localstack&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localstack/localstack:latest&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;127.0.0.1:4566:4566"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:4510-4559:4510-4559"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DEBUG=0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PERSISTENCE=1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SERVICES=s3,lambda,dynamodb,sqs,sns,events,iam,sts,apigateway,secretsmanager,kms,stepfunctions,logs&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./init:/etc/localstack/init"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./.localstack:/var/lib/localstack"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few choices worth explaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;localstack/localstack:latest&lt;/code&gt; — the simplest "always current" pin. The image moves with each monthly release; if you'd rather lock to a specific version once you're up and running, swap &lt;code&gt;latest&lt;/code&gt; for the full version tag (&lt;code&gt;2026.04.0&lt;/code&gt; or whatever's current at app.localstack.cloud).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port &lt;code&gt;4566&lt;/code&gt;&lt;/strong&gt; is the &lt;a href="https://docs.localstack.cloud/getting-started/installation/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack Gateway&lt;/a&gt; — every emulated AWS service is reachable through it. The &lt;code&gt;4510-4559&lt;/code&gt; range is the external services port range that some services use for their own endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SERVICES&lt;/code&gt;&lt;/strong&gt; &lt;a href="https://docs.localstack.cloud/references/configuration/?ref=alishaikh.me" rel="noopener noreferrer"&gt;restricts LocalStack to only the listed services&lt;/a&gt; — anything not in the list is disabled. Useful for keeping the memory footprint small and for failing loudly if your code accidentally calls something you didn't intend to test. By default LocalStack &lt;a href="https://docs.localstack.cloud/references/configuration/?ref=alishaikh.me" rel="noopener noreferrer"&gt;lazy-loads services on first use&lt;/a&gt;, so unused ones cost nothing — but disabling them entirely is cleaner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PERSISTENCE=1&lt;/code&gt; plus the &lt;code&gt;.localstack&lt;/code&gt; volume&lt;/strong&gt; is the configuration that saves state between restarts on the paid &lt;a href="https://docs.localstack.cloud/aws/capabilities/state-management/persistence/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Base and Ultimate plans&lt;/a&gt;. On the free Hobby tier the env var is silently unsupported — state wipes on every &lt;code&gt;docker compose down&lt;/code&gt;. I've left the variable in the compose file because it's harmless when ignored and starts working automatically the moment you upgrade. For Hobby readers, plan to recreate resources at the start of each article in the series — none of it is heavy, but it's worth knowing up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Docker socket mount&lt;/strong&gt; is needed for &lt;a href="https://docs.localstack.cloud/aws/services/lambda/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Lambda&lt;/a&gt; — LocalStack runs Lambda functions in sibling Docker containers, so it needs to talk to the host's Docker daemon. Without this mount, Lambda invocations fail with "Docker not available".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add a &lt;code&gt;.gitignore&lt;/code&gt; while you're here (the &lt;code&gt;init/&lt;/code&gt; folder is &lt;em&gt;not&lt;/em&gt; ignored — it'll hold our checkpoint scripts and we want those committed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;.localstack/&lt;/span&gt;
&lt;span class="err"&gt;*.zip&lt;/span&gt;
&lt;span class="err"&gt;node_modules/&lt;/span&gt;
&lt;span class="err"&gt;__pycache__&lt;/span&gt; &lt;span class="err"&gt;/&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Bring it up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; localstack

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're looking for output that ends with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LocalStack version: 2026.04.x
LocalStack build date: 2026-...
Ready.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hit &lt;code&gt;Ctrl+C&lt;/code&gt; to stop following logs — the container keeps running. If "Ready." doesn't appear within 30 seconds, jump to the troubleshooting section a bit further down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Install the AWS CLI and point it at LocalStack
&lt;/h2&gt;

&lt;p&gt;The official &lt;a href="https://aws.amazon.com/cli/?ref=alishaikh.me" rel="noopener noreferrer"&gt;AWS CLI&lt;/a&gt; talks to LocalStack the same way it talks to real AWS — you just point it at LocalStack's endpoint URL.&lt;/p&gt;

&lt;p&gt;Install it if you don't already have it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;awscli &lt;span class="c"&gt;# macOS&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;awscli &lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="c"&gt;# Windows: installer from https://aws.amazon.com/cli/&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure it once for LocalStack. Add these to your shell profile (&lt;code&gt;~/.zshrc&lt;/code&gt;, &lt;code&gt;~/.bashrc&lt;/code&gt;, or PowerShell &lt;code&gt;$PROFILE&lt;/code&gt;):&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;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ENDPOINT_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:4566
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;test
export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;test
export &lt;/span&gt;&lt;span class="nv"&gt;AWS_DEFAULT_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-east-1

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PowerShell equivalent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clear any persistent AWS_PROFILE so the env vars below take precedence&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Remove-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Env:AWS_PROFILE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ErrorAction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SilentlyContinue&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;AWS_ENDPOINT_URL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:4566"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;AWS_DEFAULT_REGION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Remove-Item Env:AWS_PROFILE&lt;/code&gt; line matters on Windows: if you've previously set &lt;code&gt;AWS_PROFILE=production&lt;/code&gt; (or anything else) at the user level, the AWS CLI will keep using &lt;em&gt;that&lt;/em&gt; profile and ignore the env vars we just set. Clearing it first makes the LocalStack config win.&lt;/p&gt;

&lt;p&gt;Reload your shell. Now &lt;code&gt;aws s3 ls&lt;/code&gt; calls LocalStack instead of real AWS — same CLI, different endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Or use a dedicated &lt;code&gt;localstack&lt;/code&gt; AWS profile (cleaner for anyone with day-job AWS)
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.localstack.cloud/aws/integrations/aws-native-tools/aws-cli/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack AWS CLI integration docs&lt;/a&gt; recommend splitting the config across the two AWS files — keys in &lt;code&gt;~/.aws/credentials&lt;/code&gt;, region/output/endpoint in &lt;code&gt;~/.aws/config&lt;/code&gt;:&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="c"&gt;# ~/.aws/credentials
&lt;/span&gt;&lt;span class="nn"&gt;[localstack]&lt;/span&gt;
&lt;span class="py"&gt;aws_access_key_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
&lt;span class="py"&gt;aws_secret_access_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;


&lt;span class="c"&gt;# ~/.aws/config
&lt;/span&gt;&lt;span class="nn"&gt;[profile localstack]&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;us-east-1&lt;/span&gt;
&lt;span class="py"&gt;output&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;json&lt;/span&gt;
&lt;span class="py"&gt;endpoint_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;http://localhost.localstack.cloud:4566&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then either pin per command (&lt;code&gt;aws --profile localstack s3 ls&lt;/code&gt;) or set &lt;code&gt;AWS_PROFILE=localstack&lt;/code&gt; for the project folder. Your real-AWS profiles stay untouched.&lt;/p&gt;

&lt;p&gt;The hostname &lt;code&gt;localhost.localstack.cloud&lt;/code&gt; is the one LocalStack &lt;a href="https://docs.localstack.cloud/aws/integrations/aws-native-tools/aws-cli/?ref=alishaikh.me" rel="noopener noreferrer"&gt;recommends&lt;/a&gt; — it resolves to &lt;code&gt;127.0.0.1&lt;/code&gt; from the host and to the LocalStack container from inside Docker networks, so the same config works everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Optional shortcuts for &lt;code&gt;awslocal&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If you want to type &lt;code&gt;awslocal s3 ls&lt;/code&gt; instead of &lt;code&gt;aws s3 ls&lt;/code&gt;, two options — pick whichever is less hassle for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A — install the Python wrapper:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/localstack/awscli-local?ref=alishaikh.me" rel="noopener noreferrer"&gt;&lt;code&gt;awslocal&lt;/code&gt;&lt;/a&gt; is a small Python package that sets the endpoint URL and placeholder credentials for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;awscli-local &lt;span class="c"&gt;# or: pip install awscli-local&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option B — define a shell function (no install):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Drop into &lt;code&gt;~/.zshrc&lt;/code&gt; or &lt;code&gt;~/.bashrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;awslocal&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;AWS_PROFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;AWS_DEFAULT_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  aws &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:4566 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The empty &lt;code&gt;AWS_PROFILE=&lt;/code&gt; makes sure your day-job profile doesn't leak in. PowerShell equivalent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;awslocal&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="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;AWS_PROFILE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;AWS_DEFAULT_REGION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--endpoint-url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;http://localhost:4566&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This series uses &lt;code&gt;awslocal&lt;/code&gt; in code samples because it's shorter. &lt;strong&gt;Every command works the same way with plain &lt;code&gt;aws&lt;/code&gt;&lt;/strong&gt; once you've exported the env vars above. Use whichever you prefer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Smoke test
&lt;/h2&gt;

&lt;p&gt;Create your first bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3 mb s3://hello-localstack

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;make_bucket: hello-localstack

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm it's there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3 &lt;span class="nb"&gt;ls


&lt;/span&gt;2026-05-03 12:34:56 hello-localstack

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put a file in it:&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"hello from localstack"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; hello.txt
aws s3 &lt;span class="nb"&gt;cp &lt;/span&gt;hello.txt s3://hello-localstack/
aws s3 &lt;span class="nb"&gt;ls &lt;/span&gt;s3://hello-localstack/

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your file shows up, LocalStack is healthy and you're ready for Week 1.&lt;/p&gt;

&lt;p&gt;(If you went with the &lt;code&gt;awslocal&lt;/code&gt; shortcut from Step 5, every &lt;code&gt;aws&lt;/code&gt; here works the same way as &lt;code&gt;awslocal&lt;/code&gt; — interchangeable.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Set up the checkpoint mechanism (init hooks)
&lt;/h2&gt;

&lt;p&gt;State doesn't survive a restart on the free Hobby tier — &lt;a href="https://docs.localstack.cloud/aws/capabilities/state-management/persistence/?ref=alishaikh.me" rel="noopener noreferrer"&gt;persistence is paid-only&lt;/a&gt;. The good news: there's a free workaround that turns out to be even better for a tutorial series. &lt;a href="https://docs.localstack.cloud/references/init-hooks/?ref=alishaikh.me" rel="noopener noreferrer"&gt;&lt;strong&gt;Init hooks&lt;/strong&gt;&lt;/a&gt; let LocalStack run shell scripts every time the container starts. Instead of saving state, we save a script that recreates state in seconds.&lt;/p&gt;

&lt;p&gt;Init hooks are listed as "Included in Plans: Hobby, Base, Ultimate", so they work on the free tier.&lt;/p&gt;

&lt;p&gt;The pattern across this series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each article ends with a small "save this as a checkpoint" script&lt;/li&gt;
&lt;li&gt;Drop the scripts into your &lt;code&gt;init/ready.d/&lt;/code&gt; folder, named with a numeric prefix per article (&lt;code&gt;01-part1-s3.sh&lt;/code&gt;, &lt;code&gt;02-part2-dynamodb.sh&lt;/code&gt;, ...)&lt;/li&gt;
&lt;li&gt;Every &lt;code&gt;docker compose up&lt;/code&gt; re-runs them in order — your resources rebuild themselves&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want to jump in at Part 4 without doing the previous ones?&lt;/strong&gt; Add scripts &lt;code&gt;01&lt;/code&gt; through &lt;code&gt;04&lt;/code&gt; to your &lt;code&gt;init/ready.d/&lt;/code&gt; and you're set up. They're additive&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mount in our compose file (&lt;code&gt;./init:/etc/localstack/init&lt;/code&gt;) is already there. Make the directory:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; init/ready.d

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try it with a tiny example. Save this as &lt;code&gt;init/ready.d/00-part0-smoke.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
aws &lt;span class="nt"&gt;--profile&lt;/span&gt; localstack s3 mb s3://hello-localstack 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
echo&lt;/span&gt; &lt;span class="s2"&gt;"[bootstrap] part 0 — smoke bucket ready"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it executable:&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;chmod&lt;/span&gt; +x init/ready.d/00-part0-smoke.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart LocalStack to pick up the new mount and the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;5

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3 &lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="c"&gt;# 2026-05-03 12:34:56 hello-localstack&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bucket came back automatically. The &lt;code&gt;2&amp;gt;/dev/null || true&lt;/code&gt; makes the command idempotent — if a future run finds the bucket already there, the error is swallowed and the script keeps going.&lt;/p&gt;

&lt;p&gt;That's the whole pattern. Each part of the series will give you one or two more lines to paste into the same folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caveat for Lambda steps:&lt;/strong&gt; Lambda functions need a &lt;code&gt;.zip&lt;/code&gt; of your code, which is awkward to deploy through a one-off shell script. Articles that introduce Lambda (Parts 3 and 4) will walk through deployment manually, and the checkpoint scripts will skip the Lambda parts — only the supporting resources (buckets, tables, queues, IAM roles) get bootstrapped automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;A short list of things that have caught readers before:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Auth token not set" or services refusing to start.&lt;/strong&gt; Re-export &lt;code&gt;LOCALSTACK_AUTH_TOKEN&lt;/code&gt; and run &lt;code&gt;docker compose up -d --force-recreate&lt;/code&gt;. The variable has to be in the shell that runs &lt;code&gt;docker compose&lt;/code&gt;, not just in the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port 4566 already in use.&lt;/strong&gt; Another LocalStack container is still running. Run &lt;code&gt;docker ps -a | grep localstack&lt;/code&gt; and kill the duplicate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aws: Could not connect to the endpoint URL&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;AWS_ENDPOINT_URL&lt;/code&gt; env var isn't set in this shell. Re-source your profile (&lt;code&gt;source ~/.zshrc&lt;/code&gt; / &lt;code&gt;. $PROFILE&lt;/code&gt;) or re-export the four env vars from Step 5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS CLI says "Unable to locate credentials".&lt;/strong&gt; Same root cause — the four env vars from Step 5 aren't in scope. Re-source the profile or set them inline for one command. If you went with the &lt;code&gt;awslocal&lt;/code&gt; shortcut, &lt;code&gt;pipx ensurepath&lt;/code&gt; (then a new shell) usually fixes "command not found" for that route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State doesn't survive a restart.&lt;/strong&gt; Expected on the free Hobby tier (&lt;a href="https://docs.localstack.cloud/aws/capabilities/state-management/persistence/?ref=alishaikh.me" rel="noopener noreferrer"&gt;persistence is paid-only&lt;/a&gt;). Use the init-hooks pattern from Step 7 to recreate resources automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Init script doesn't run.&lt;/strong&gt; Check that the file is executable (&lt;code&gt;chmod +x&lt;/code&gt;) and that &lt;code&gt;init/ready.d/&lt;/code&gt; is mounted at &lt;code&gt;/etc/localstack/init/ready.d&lt;/code&gt; inside the container. &lt;code&gt;docker compose exec localstack ls /etc/localstack/init/ready.d&lt;/code&gt; confirms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda invocations hang.&lt;/strong&gt; The Docker socket mount is missing. Add &lt;code&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/code&gt; and recreate the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow first request after a cold start.&lt;/strong&gt; Normal — LocalStack &lt;a href="https://docs.localstack.cloud/references/configuration/?ref=alishaikh.me" rel="noopener noreferrer"&gt;lazy-loads services on first request&lt;/a&gt; by default. The second call is instant.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cleanup commands worth knowing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stop the container, keep state&lt;/span&gt;
docker compose stop

&lt;span class="c"&gt;# Stop and remove the container, keep state on disk&lt;/span&gt;
docker compose down

&lt;span class="c"&gt;# Stop, remove the container, AND wipe all state&lt;/span&gt;
docker compose down &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; .localstack

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third one is the "I've got it into a weird state, start fresh" command. You'll want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small habit worth picking up
&lt;/h2&gt;

&lt;p&gt;Drop these in your shell config so you never start a session forgetting the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.zshrc or ~/.bashrc&lt;/span&gt;
ls-up&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/localstack-series &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
ls-down&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/localstack-series &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose down&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
ls-logs&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/localstack-series &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; localstack&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;ls-up&lt;/code&gt;, &lt;code&gt;ls-logs&lt;/code&gt;, &lt;code&gt;ls-down&lt;/code&gt; from anywhere on the machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'll wire up next
&lt;/h2&gt;

&lt;p&gt;You've got LocalStack running, the AWS CLI pointed at it, a smoke-tested S3 bucket, and the init-hooks checkpoint pattern in place. &lt;strong&gt;The next part&lt;/strong&gt; uses this setup for something real: a tiny photo uploader where the browser uploads files directly to S3 via a presigned URL — the same pattern Slack, Imgur, and most SaaS apps use for user-generated content. By the end of Part 1, you'll have a working endpoint and a single-page upload form you'll keep around for the rest of the series.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The full series&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 0&lt;/strong&gt; — Start here: series intro and installing LocalStack &lt;em&gt;(this article)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 1 — S3 locally: buckets, presigned URLs, and a tiny photo uploader &lt;em&gt;(next)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Part 2 — DynamoDB locally: building a URL shortener data layer&lt;/li&gt;
&lt;li&gt;Part 3 — Lambda + S3 events: an image thumbnailer pipeline&lt;/li&gt;
&lt;li&gt;Part 4 — API Gateway + Lambda + JWT auth: a real HTTP API&lt;/li&gt;
&lt;li&gt;Part 5 — SQS + SNS: a background job queue with a dead-letter queue&lt;/li&gt;
&lt;li&gt;Part 6 — EventBridge + Step Functions: orchestrating a photo-processing workflow&lt;/li&gt;
&lt;li&gt;Part 7 — Secrets Manager + KMS: handling secrets and encryption locally&lt;/li&gt;
&lt;li&gt;Part 8 — Terraform (tflocal) + GitHub Actions: integration tests against LocalStack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/getting-started/installation/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack getting started — installation and Docker Compose example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.localstack.cloud/pricing?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack pricing and plan comparison&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.localstack.cloud/localstack-for-aws-release-2026-03-0/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack 2026.03.0 release notes — auth token rollout&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.localstack.cloud/localstack-for-aws-release-2026-04-0/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack 2026.04.0 release notes — current version, App Inspector, lstk CLI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/aws/integrations/aws-native-tools/aws-cli/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack AWS CLI integration — recommended profile setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/references/configuration/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack configuration reference (&lt;code&gt;SERVICES&lt;/code&gt;, &lt;code&gt;EAGER_SERVICE_LOADING&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/aws/capabilities/state-management/persistence/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack persistence docs (Base/Ultimate only)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/aws/services/lambda/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack Lambda docs (Docker socket required)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.localstack.cloud/references/init-hooks/?ref=alishaikh.me" rel="noopener noreferrer"&gt;LocalStack init hooks (free on Hobby) — the checkpoint pattern&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/localstack/awscli-local?ref=alishaikh.me" rel="noopener noreferrer"&gt;awscli-local on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>containers</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Fedora 44 Is Out: GNOME 50, GCC 16, Ruby 4.0, and Wine That Just Works</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Wed, 29 Apr 2026 17:45:01 +0000</pubDate>
      <link>https://dev.to/alishaikh/fedora-44-is-out-gnome-50-gcc-16-ruby-40-and-wine-that-just-works-2c9a</link>
      <guid>https://dev.to/alishaikh/fedora-44-is-out-gnome-50-gcc-16-ruby-40-and-wine-that-just-works-2c9a</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fixbxj4061413m6vumxm3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fixbxj4061413m6vumxm3.jpg" alt="Fedora 44 Is Out: GNOME 50, GCC 16, Ruby 4.0, and Wine That Just Works"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fedora 44 dropped on 28 April 2026 after a couple of slip dates, and it's a chunkier release than the version number suggests. Major language toolchain bumps, a kernel that auto-enables NTSYNC for Wine and Steam, GNOME 50, KDE Plasma 6.6, and a switch to DNF5 as the PackageKit backend. Here's what actually matters, what might bite you on upgrade day, and the commands to get from 43 to 44 without surprises.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I've been running it in a Proxmox VM since the beta. The short version: it feels stable, the toolchain refresh is bigger than the headline suggests, and if you write code in Ruby or anything that touches LLVM, you'll want to read the toolchain section before you upgrade your work machine.&lt;/p&gt;

&lt;p&gt;Let's go through what's new.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Headline Versions
&lt;/h2&gt;

&lt;p&gt;Quick rundown of the version bumps you'll notice straight away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linux kernel&lt;/strong&gt; 6.19.14&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GNOME&lt;/strong&gt; 50 (Workstation default)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KDE Plasma&lt;/strong&gt; 6.6 (with a new Plasma Login Manager)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GCC&lt;/strong&gt; 16, with binutils 2.46, glibc 2.43, gdb 16&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLVM&lt;/strong&gt; 22 across all sub-projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go&lt;/strong&gt; 1.26&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ruby&lt;/strong&gt; 4.0 (yes, a major version jump from 3.4)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CMake&lt;/strong&gt; 4.x with &lt;strong&gt;ninja&lt;/strong&gt; as the new default generator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MariaDB&lt;/strong&gt; 11.8 as the default&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A lot of those are "ah, fine" upgrades that just keep you current. Two of them - Ruby and CMake - are big enough that they deserve their own sections later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wine and Steam Got a Quiet Win
&lt;/h2&gt;

&lt;p&gt;This is the change I think most users will actually feel, even if they don't know what NTSYNC is.&lt;/p&gt;

&lt;p&gt;Linux kernel 6.14 introduced NTSYNC, a module that mimics Windows NT thread-synchronisation primitives natively in the kernel. Wine and Proton already had &lt;code&gt;esync&lt;/code&gt; and &lt;code&gt;fsync&lt;/code&gt; to do similar work, but NTSYNC sticks closer to how Windows actually behaves, which means fewer compatibility quirks and, in some cases, better framerates.&lt;/p&gt;

&lt;p&gt;Fedora 44 wires it up so most users never have to think about it. When you install a package that recommends &lt;code&gt;wine-ntsync&lt;/code&gt; (Wine itself, or the Steam package from RPM Fusion - both pull &lt;code&gt;wine-ntsync&lt;/code&gt; in via RPM Recommends), Fedora drops a config into &lt;code&gt;/usr/lib/modules-load.d/ntsync.conf&lt;/code&gt;. After reboot, the module is loaded. No &lt;code&gt;modprobe&lt;/code&gt;, no editing config files, no chasing down a forum post.&lt;/p&gt;

&lt;p&gt;You can check it's loaded with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsmod | &lt;span class="nb"&gt;grep &lt;/span&gt;ntsync

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see output, you're good. If you don't, install Wine (or Steam from RPM Fusion) and reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Wine from the main repo&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;wine

&lt;span class="c"&gt;# Steam usually comes from RPM Fusion (enable it first if you haven't)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;steam

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reboot

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't expect a 50% framerate jump in everything you run. Proton's existing &lt;code&gt;esync&lt;/code&gt;/&lt;code&gt;fsync&lt;/code&gt; paths cover most cases. What you will notice is that games which previously needed manual tweaks to work now run out of the box. That's a real quality-of-life win.&lt;/p&gt;

&lt;h2&gt;
  
  
  GNOME 50: Polish, Not Revolution
&lt;/h2&gt;

&lt;p&gt;GNOME 50 isn't trying to reinvent itself. The big themes are accessibility, colour management, and remote desktop, and most of the visible changes feel like sanding down rough edges rather than throwing furniture around.&lt;/p&gt;

&lt;p&gt;The accessibility work matters more than headlines suggest. Screen-reader behaviour is more consistent across apps, keyboard navigation in Settings has fewer dead ends, and the magnifier handles fractional scaling cleanly. If you support users who rely on these tools, the upgrade is a clean win.&lt;/p&gt;

&lt;p&gt;Colour management gained proper per-monitor profile support, which is going to make photographers and designers very happy. Remote desktop now negotiates pixel formats more intelligently when you're connecting from a different display profile.&lt;/p&gt;

&lt;p&gt;If you're on KDE instead, Plasma 6.6 is the headline. The new Plasma Login Manager replaces SDDM in the default install, and the first-run setup wizard is genuinely shorter than it used to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  For Developers: The Toolchain Bumps
&lt;/h2&gt;

&lt;p&gt;Here's where you need to pay attention if Fedora is your daily driver for work.&lt;/p&gt;

&lt;h3&gt;
  
  
  GCC 16 and LLVM 22
&lt;/h3&gt;

&lt;p&gt;GCC 16 brings the usual mix of new C++26 features, better diagnostics, and improved optimisation passes. LLVM 22 ships across &lt;code&gt;clang&lt;/code&gt;, &lt;code&gt;lld&lt;/code&gt;, &lt;code&gt;lldb&lt;/code&gt;, and the rest of the family. If you build native code, neither of these should break anything, but you'll want to rebuild any pre-compiled artifacts that ship with a Fedora 43 toolchain stamp.&lt;/p&gt;

&lt;p&gt;Quick sanity check after upgrade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcc &lt;span class="nt"&gt;--version&lt;/span&gt;
clang &lt;span class="nt"&gt;--version&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both should report their new major versions. If you've got a project that pins to an exact GCC version in CI, mirror that in your local Containerfile rather than relying on the system compiler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ruby 4.0: The Big Jump
&lt;/h3&gt;

&lt;p&gt;Ruby skipped from 3.4 straight to 4.0 in this cycle. The Fedora project's own change proposal calls it "the superior Ruby development platform" - their words, not mine, but the upgrade itself is the thing to plan for.&lt;/p&gt;

&lt;p&gt;Most Ruby code that worked on 3.4 will work on 4.0, but a few rough edges to watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The soname bumped, so some C-extension gems need a rebuild against the new ABI. &lt;code&gt;bundle install&lt;/code&gt; should handle this for most gems, but native gems with vendored binaries can stumble.&lt;/li&gt;
&lt;li&gt;A handful of long-deprecated stdlib methods are gone. If your project still has Ruby 1.9-era code lurking, run your test suite before you ship.&lt;/li&gt;
&lt;li&gt;Paths have shifted to fit the new version layout, so anything hard-coding &lt;code&gt;/usr/lib64/ruby/3.4&lt;/code&gt; or similar will need updating.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you maintain a Rails app, the safest play is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# In your project directory&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; vendor/bundle
bundle &lt;span class="nb"&gt;install
&lt;/span&gt;bundle &lt;span class="nb"&gt;exec &lt;/span&gt;rspec &lt;span class="c"&gt;# or your test command of choice&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't blindly &lt;code&gt;dnf upgrade&lt;/code&gt; your work machine if you've got a Ruby project with deadlines this week. Spin up a Toolbox container first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;toolbox create &lt;span class="nt"&gt;--release&lt;/span&gt; 44 ruby4-test
toolbox enter ruby4-test
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/my-rails-app
bundle &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you Ruby 4.0 in an isolated environment without touching the rest of your system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Go 1.26
&lt;/h3&gt;

&lt;p&gt;Quieter upgrade. Most Go code is forward-compatible by design. The standard library has the usual round of additions, and the toolchain is faster on cold builds. Nothing here should bite you.&lt;/p&gt;

&lt;h3&gt;
  
  
  CMake 4.x with Ninja by Default
&lt;/h3&gt;

&lt;p&gt;This one is small in code but worth flagging. Fedora 44 ships CMake 4.x, and the default generator is now &lt;code&gt;ninja&lt;/code&gt; instead of &lt;code&gt;make&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your build scripts assume &lt;code&gt;make&lt;/code&gt; is the generator (looking for &lt;code&gt;Makefile&lt;/code&gt;, parsing make output, anything like that), you've got two options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Option 1: explicitly request make&lt;/span&gt;
cmake &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="s2"&gt;"Unix Makefiles"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Option 2: just use ninja and update your scripts&lt;/span&gt;
cmake &lt;span class="nb"&gt;.&lt;/span&gt;
ninja

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ninja is faster, especially on incremental builds, so most projects will benefit from making the switch permanent. But if your CI runs scripts that grep &lt;code&gt;make&lt;/code&gt; output, fix them now rather than at 2am next Tuesday.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNF5 Is the New Backend (For PackageKit Too)
&lt;/h2&gt;

&lt;p&gt;Fedora 41 made DNF5 the default command-line tool. Fedora 44 finishes the job: PackageKit, the abstraction layer that GUI software updaters sit on top of, now uses DNF5 as its backend through &lt;code&gt;libdnf5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For most users this is invisible. GNOME Software still works, KDE Discover still works, the GUI updater on your aunt's laptop still works. What it means for you, if you're a developer or sysadmin, is that the legacy DNF4 code paths are now genuinely on borrowed time. If you've got internal tooling that shells out to &lt;code&gt;dnf&lt;/code&gt; and parses output, this is a good week to make sure you're using the DNF5 syntax.&lt;/p&gt;

&lt;p&gt;A few command differences worth noting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# DNF4-style (still works for backward compatibility, but deprecated)&lt;/span&gt;
dnf list installed

&lt;span class="c"&gt;# DNF5-style (preferred)&lt;/span&gt;
dnf list &lt;span class="nt"&gt;--installed&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most everyday commands (&lt;code&gt;install&lt;/code&gt;, &lt;code&gt;remove&lt;/code&gt;, &lt;code&gt;upgrade&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt;) work identically. The differences are in the more specialised flags - &lt;code&gt;dnf list installed&lt;/code&gt; is the classic example, superseded by &lt;code&gt;dnf list --installed&lt;/code&gt; in DNF5. Old scripts won't break overnight, but if you're writing new tooling, follow the DNF5 conventions. The manual is genuinely well-written - &lt;code&gt;man dnf5&lt;/code&gt; is worth ten minutes if you live in this tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other Things Worth Knowing
&lt;/h2&gt;

&lt;p&gt;A few smaller changes that don't deserve their own section but are worth a paragraph each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MariaDB 11.8&lt;/strong&gt; is the new default. If you upgrade a server, run &lt;code&gt;mariadb-upgrade&lt;/code&gt; afterwards to update the system tables. Don't skip this and then wonder why connections behave oddly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fedora Cloud images&lt;/strong&gt; now use Btrfs subvolumes for &lt;code&gt;/boot&lt;/code&gt;. Mostly invisible, but if you've got automation that assumes &lt;code&gt;/boot&lt;/code&gt; is a separate ext4 partition, test before you redeploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenSSL&lt;/strong&gt; added directory-hash support for &lt;code&gt;ca-certificates&lt;/code&gt;. Faster certificate loading on systems with a lot of trust roots, no behaviour change for normal use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anaconda&lt;/strong&gt; (the installer) now creates network profiles only for devices that are actually configured during install, instead of generating empty profiles for every NIC it sees. Cleaner, less to clean up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;QEMU 32-bit host builds are gone.&lt;/strong&gt; Upstream deprecated them, Fedora followed. If you're running a 32-bit host (rare in 2026), this is your nudge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bootupd phase 1&lt;/strong&gt; begins. Boot loader updates start migrating to a unified mechanism. You won't notice unless something goes wrong, in which case the new tooling makes recovery cleaner.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Upgrade
&lt;/h2&gt;

&lt;p&gt;If you're on Fedora 43, the upgrade path hasn't changed. Two ways to do it.&lt;/p&gt;

&lt;h3&gt;
  
  
  GUI: GNOME Software
&lt;/h3&gt;

&lt;p&gt;Open Software, look for the "Fedora 44 is now available" banner, click the button, reboot when prompted. Easy mode. Works for the vast majority of desktops.&lt;/p&gt;

&lt;h3&gt;
  
  
  Terminal: dnf-plugin-system-upgrade
&lt;/h3&gt;

&lt;p&gt;The reliable, scriptable path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Make sure your current system is fully patched&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf upgrade &lt;span class="nt"&gt;--refresh&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reboot &lt;span class="c"&gt;# only if there are kernel/glibc updates&lt;/span&gt;

&lt;span class="c"&gt;# 2. Install the system-upgrade plugin if you don't have it&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;dnf-plugin-system-upgrade

&lt;span class="c"&gt;# 3. Download the upgrade&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf system-upgrade download &lt;span class="nt"&gt;--releasever&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;44

&lt;span class="c"&gt;# 4. Trigger the upgrade reboot&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf system-upgrade reboot

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 3 takes a while - it's downloading a few GB of packages. Step 4 reboots into a special offline upgrade mode that runs the actual transaction. Coffee break, come back to a Fedora 44 login screen.&lt;/p&gt;

&lt;p&gt;If something goes wrong during step 4, you'll boot back into your old system. That's a feature, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Upgrade?
&lt;/h2&gt;

&lt;p&gt;For most desktops, yes, and there's no reason to wait. The release cycle stability has been good and the headline features (NTSYNC, GNOME 50, KDE 6.6) are user-facing wins.&lt;/p&gt;

&lt;p&gt;For work machines, give it a week. Not because Fedora 44 is risky in itself, but because your toolchain might have rough edges with the new compiler versions. If you run a Ruby shop, run your test suite in a Toolbox container before you commit.&lt;/p&gt;

&lt;p&gt;For servers, give it longer. Fedora is a leading-edge distribution by design. If you need long support windows and conservative changes, this is what CentOS Stream and RHEL are for. Test in staging, run for a fortnight, then upgrade prod.&lt;/p&gt;

&lt;p&gt;For my own laptop, I'm pulling the trigger this evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try This Before You Upgrade
&lt;/h2&gt;

&lt;p&gt;If you want to kick the tyres without committing, the easiest path is a live USB. Download the Workstation ISO from &lt;code&gt;getfedora.org&lt;/code&gt;, write it to a USB stick, boot from it, and have a poke around. Nothing touches your existing install.&lt;/p&gt;

&lt;p&gt;The simplest path is &lt;strong&gt;Fedora Media Writer&lt;/strong&gt; - it handles the download and the writing in one GUI flow, and it works on Linux, macOS, and Windows. If you'd rather use the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Replace /dev/sdX with your USB device - check with lsblk first!&lt;/span&gt;
&lt;span class="c"&gt;# status=progress and oflag=direct are optional; they just give you feedback and force direct I/O&lt;/span&gt;
&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Fedora-Workstation-Live-x86_64-44-1.5.iso &lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/sdX &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4M &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;progress &lt;span class="nv"&gt;oflag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;direct

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can run NTSYNC checks, test GNOME 50, and confirm your hardware works without committing to the upgrade. Five minutes well spent.&lt;/p&gt;

&lt;p&gt;That's Fedora 44. Solid release, decent toolchain refresh, real gains for anyone running Wine or Steam. Worth the upgrade for most users, worth the planning for developers, and worth the staging-environment patience for servers. Same Fedora cadence, same predictable spring release, same weeks of polish ahead.&lt;/p&gt;

</description>
      <category>fedora</category>
      <category>homelab</category>
      <category>linux</category>
      <category>news</category>
    </item>
    <item>
      <title>Secure Self-Hosted OpenClaw AI Assistant: Step-by-Step Proxmox Template Guide with Tailscale Integration</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Sun, 01 Feb 2026 16:15:50 +0000</pubDate>
      <link>https://dev.to/alishaikh/secure-self-hosted-openclaw-ai-assistant-step-by-step-proxmox-template-guide-with-tailscale-ghj</link>
      <guid>https://dev.to/alishaikh/secure-self-hosted-openclaw-ai-assistant-step-by-step-proxmox-template-guide-with-tailscale-ghj</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F16jx2yuvorw6t5cf0mwe.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F16jx2yuvorw6t5cf0mwe.jpg" alt="Secure Self-Hosted OpenClaw AI Assistant: Step-by-Step Proxmox Template Guide with Tailscale Integration" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the last few weeks, you could not escape seeing "Clawd" everywhere - the viral AI assistant that went from zero to 100,000 GitHub stars in just two months. Originally called Clawdbot, now rebranded as OpenClaw, it's an open-source AI that actually does things instead of just chatting. So this weekend, I set it up on my Proxmox homelab.&lt;/p&gt;

&lt;p&gt;Now I have a digital assistant that I can communicate with through WhatsApp and Telegram. I asked it to choose its own name and persona, and now it is called Lyra. I gave it a dedicated email and GitHub account.&lt;/p&gt;

&lt;p&gt;This guide walks through creating a reusable Proxmox template with all dependencies pre-installed. Clone the template, boot it, configure OpenClaw and Tailscale, and you're done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debian 13 VM template with Node.js 24, OpenClaw CLI, and Tailscale pre-installed&lt;/li&gt;
&lt;li&gt;Automatic installation via cloud-init (no manual package installs)&lt;/li&gt;
&lt;li&gt;Secure HTTPS access via Tailscale Serve (no public exposure)&lt;/li&gt;
&lt;li&gt;Progress indicators and status helpers baked in&lt;/li&gt;
&lt;li&gt;~15 minutes from template creation to running AI assistant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/Ali-Shaikh/proxmox-toolbox/blob/main/templates/openclaw/debian-13-openclaw-ready-template.sh?ref=alishaikh.me" rel="noopener noreferrer"&gt;OpenClaw Proxmox Template Script&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What is OpenClaw?
&lt;/h2&gt;

&lt;p&gt;OpenClaw is an autonomous AI assistant that actually executes tasks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Controls browsers (fill forms, scrape data, automate workflows)&lt;/li&gt;
&lt;li&gt;Manages files on your system&lt;/li&gt;
&lt;li&gt;Integrates with WhatsApp, Telegram, Discord&lt;/li&gt;
&lt;li&gt;Runs terminal commands&lt;/li&gt;
&lt;li&gt;All through a web UI or chat interfaces&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Think of it as the plumbing layer that connects AI models (Claude, GPT-4o, Gemini) to real-world actions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catch:&lt;/strong&gt; It's powerful, which means it needs proper isolation. Hence the dedicated VM approach.&lt;/p&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your Browser
    │
    │ HTTPS (Tailscale Serve)
    ▼
OpenClaw VM (Proxmox)
    │
    ├─ Tailscale Serve → http://127.0.0.1:18789
    │
    ├─ OpenClaw Gateway
    │ ├─ Control UI
    │ └─ AI agent + channels
    │
    └─ Internet (outbound only)
        └─ WhatsApp/Telegram/AI APIs

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;Gateway binds to loopback (127.0.0.1) only&lt;/li&gt;
&lt;li&gt;Tailscale Serve proxies HTTPS from your Tailnet&lt;/li&gt;
&lt;li&gt;No public exposure, no firewall rules needed&lt;/li&gt;
&lt;li&gt;Messaging platforms connect over regular internet&lt;/li&gt;
&lt;li&gt;If using Tailscale, the server is only accessible through your Tailscale network, ensuring secure, private access&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;You'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Proxmox VE 8.x (ensure it's updated: &lt;code&gt;apt update &amp;amp;&amp;amp; apt full-upgrade&lt;/code&gt; via SSH or web console)&lt;/li&gt;
&lt;li&gt;At least 4GB RAM and 20GB storage available per VM (adjust based on workload)&lt;/li&gt;
&lt;li&gt;SSH access to Proxmox (or use the web console as a fallback)&lt;/li&gt;
&lt;li&gt;A Tailscale account (free tier works)&lt;/li&gt;
&lt;li&gt;Your SSH public key&lt;/li&gt;
&lt;li&gt;Cloud-init enabled in Proxmox (check under Datacenter &amp;gt; Options; if not, enable it)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Quick Tip:&lt;/strong&gt; If you're new to Tailscale, start with their quickstart guide: &lt;a href="https://tailscale.com/kb/1017/install?ref=alishaikh.me" rel="noopener noreferrer"&gt;Tailscale Docs&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: Create the Template
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Download the Script
&lt;/h3&gt;

&lt;p&gt;SSH into Proxmox and download the script directly to avoid copy-paste errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh root@your-proxmox-host
wget https://raw.githubusercontent.com/Ali-Shaikh/proxmox-toolbox/main/templates/openclaw/debian-13-openclaw-ready-template.sh -O debian-13-openclaw-ready-template.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Configure It
&lt;/h3&gt;

&lt;p&gt;Open the script and modify these values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nano debian-13-openclaw-ready-template.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Change these:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VMID=10010 # Template ID (pick any unused ID; check with `qm list`)
STORAGE="local-lvm" # Your Proxmox storage pool (verify with `pvesm list`)
MEMORY=4096 # 4GB RAM minimum
CORES=2 # CPU cores

CI_USER="debian"
CI_PASSWORD="$(openssl passwd -6 $(pwgen -s 16 1))" # Generate a strong password with pwgen or similar; install pwgen if needed: apt install pwgen

# Replace with YOUR SSH public key
echo "ssh-rsa AAAA... your-email@example.com" &amp;gt; ~/ssh.pub

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Homebrew (Linuxbrew) is installed via cloud-init for certain dependencies; it's not native to Debian but works fine for this setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run It
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chmod +x debian-13-openclaw-ready-template.sh
./debian-13-openclaw-ready-template.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Download Debian 13 cloud image&lt;/li&gt;
&lt;li&gt;Create a VM with UEFI support&lt;/li&gt;
&lt;li&gt;Configure cloud-init to install Node.js, OpenClaw, Tailscale, pnpm, Homebrew&lt;/li&gt;
&lt;li&gt;Convert it to a template&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; ~2-3 minutes&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Deploy a VM
&lt;/h2&gt;

&lt;p&gt;Clone the template (replace IDs if needed; check for conflicts with &lt;code&gt;qm list&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;qm clone 10010 201 --name openclaw-prod --full
qm start 201

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Wait 5-7 minutes.&lt;/strong&gt; Cloud-init is installing everything. You can watch progress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Get the VM's IP (once it boots)
qm guest cmd 201 network-get-interfaces

# SSH in and watch the install
ssh debian@&amp;lt;vm-ip&amp;gt;
sudo tail -f /var/log/openclaw-install.log

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you see "Installation Complete", you're ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify Installation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Quick check
check-install-status

# Or check versions manually
cat /root/install-versions.txt

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see Node.js 24, npm, pnpm, OpenClaw, Tailscale, and Homebrew all installed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: Configure OpenClaw
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Connect to Tailscale
&lt;/h3&gt;

&lt;p&gt;Get an auth key from &lt;a href="https://login.tailscale.com/admin/settings/keys?ref=alishaikh.me" rel="noopener noreferrer"&gt;https://login.tailscale.com/admin/settings/keys&lt;/a&gt; (set to expire in 30-90 days for security).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo tailscale up --authkey=tskey-auth-YOUR_KEY_HERE
tailscale status # Verify connection

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Set Up pnpm (Required for Skills)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pnpm setup
source ~/.bashrc

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Run OpenClaw Onboarding
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openclaw onboard --install-daemon

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical configuration choices:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;Why?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup type&lt;/td&gt;
&lt;td&gt;Local gateway (this machine)&lt;/td&gt;
&lt;td&gt;Keeps everything self-contained.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bind address&lt;/td&gt;
&lt;td&gt;Loopback (127.0.0.1) - &lt;strong&gt;MUST BE LOOPBACK&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Prevents direct exposure to LAN or internet; required for secure Tailscale proxying.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;Token (Recommended)&lt;/td&gt;
&lt;td&gt;Stronger than alternatives for access control.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expose via&lt;/td&gt;
&lt;td&gt;Tailscale Serve&lt;/td&gt;
&lt;td&gt;Secure Tailnet-only access without public exposure.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never select "LAN (0.0.0.0)" for bind address&lt;/li&gt;
&lt;li&gt;Do not select "Funnel" (exposes to public internet)&lt;/li&gt;
&lt;li&gt;If you get a Tailscale binary warning, choose "No" and continue&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Save Your Token
&lt;/h3&gt;

&lt;p&gt;The wizard will display your gateway token. Save it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cat ~/.openclaw/openclaw.json | jq -r '.gateway.auth.token'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output: &lt;code&gt;claw_abc123def456...&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write this down.&lt;/strong&gt; You'll need it to access the UI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify Tailscale Serve
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tailscale serve status

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://openclaw-host.tail-scale.ts.net/ (Tailscale Serve)
|-- / proxy http://127.0.0.1:18789

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If empty, configure manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo tailscale serve --bg --https=443 http://127.0.0.1:18789

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Check Gateway Status
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openclaw status
openclaw health
openclaw gateway logs # Watch for errors

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Part 4: Access the UI
&lt;/h2&gt;

&lt;p&gt;From any device on your Tailscale network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Get all access info in one command
openclaw-access-info

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shows your Tailscale URL and gateway token. Open the URL in your browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt; &lt;code&gt;https://openclaw-host.tail-scale.ts.net/&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Enter your gateway token when prompted. If &lt;code&gt;"allowTailscale": true&lt;/code&gt; is enabled in your config, you'll be auto-authenticated - see Configuration File for trade-offs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Based on common troubleshooting (though not explicitly in OpenClaw docs), when using Tailscale Serve, you may need to add &lt;code&gt;"controlUi": { "allowInsecureAuth": true }&lt;/code&gt; to your config file to avoid "pairing required" WebSocket errors. This skips device pairing for compatibility but reduces security slightly; use only if needed and combine with strong token auth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuration File
&lt;/h2&gt;

&lt;p&gt;Your OpenClaw config lives at &lt;code&gt;~/.openclaw/openclaw.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "gateway": {
    "port": 18789,
    "bind": "127.0.0.1",
    "tailscale": {
      "mode": "serve"
    },
    "auth": {
      "mode": "token",
      "token": "your-gateway-token-here",
      "allowTailscale": false // Set to true for auto-auth via Tailscale identity (convenience trade-off: trust your Tailnet fully or use token-only for stricter security)
    },
    "controlUi": {
      "allowInsecureAuth": true // May be needed for Tailscale Serve to fix WebSocket errors; skips device pairing (use with caution)
    }
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bind: "127.0.0.1"&lt;/code&gt; - Gateway only listens on loopback (required for Tailscale Serve)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tailscale.mode: "serve"&lt;/code&gt; - Tailnet-only access&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;auth.allowTailscale: false&lt;/code&gt; - Prefer token auth; enable &lt;code&gt;true&lt;/code&gt; only if you fully trust your Tailnet (trade-off: easier access vs. potential risk if Tailnet is compromised)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;controlUi.allowInsecureAuth: true&lt;/code&gt; - &lt;strong&gt;May be needed for Tailscale Serve&lt;/strong&gt; - Skips device pairing to prevent "pairing required" WebSocket errors (trade-off: fixes compatibility but weakens pairing-based security; not explicitly required in OpenClaw docs but helps with common issues)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;View your config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cat ~/.openclaw/openclaw.json | jq

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Add Chat Integrations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  WhatsApp
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openclaw channels login
# Scan QR code with WhatsApp &amp;gt; Settings &amp;gt; Linked Devices (note: WhatsApp limits linked devices; check your account limits)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Telegram
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openclaw configure --section channels.telegram
# Add bot token from @BotFather

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Discord
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openclaw configure --section channels.discord
# Add bot token from Discord Developer Portal

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; For LLM integrations (e.g., Claude, GPT-4o keys), add them securely via &lt;code&gt;openclaw configure --section models&lt;/code&gt;. Store keys in environment variables or a secrets manager for best practices.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  Tailscale Serve Not Working
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Check Tailscale status:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tailscale status
sudo systemctl status tailscaled

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Manually configure Serve:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo tailscale serve --bg --https=443 http://127.0.0.1:18789
tailscale serve status

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Ensure HTTPS is enabled for your Tailnet:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Visit &lt;a href="https://login.tailscale.com/admin/dns?ref=alishaikh.me" rel="noopener noreferrer"&gt;https://login.tailscale.com/admin/dns&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Enable HTTPS if prompted&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Can't Access from Remote Device
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Verify both devices are on Tailscale:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tailscale status # Run on both devices

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test connectivity:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ping openclaw-host
curl -k https://openclaw-host.tail-scale.ts.net/health

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  WebSocket Errors: "pairing required" (1008)
&lt;/h3&gt;

&lt;p&gt;Edit your config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nano ~/.openclaw/openclaw.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add under the &lt;code&gt;gateway&lt;/code&gt; section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"controlUi": {
  "allowInsecureAuth": true
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Restart the gateway:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;systemctl --user restart openclaw-gateway

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This skips device pairing for Tailscale compatibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installation Seems Stuck
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Check cloud-init progress
sudo tail -100 /var/log/cloud-init-output.log

# Check OpenClaw install progress
sudo tail -100 /var/log/openclaw-install.log

# Current step
cat /var/run/openclaw-install-progress

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  pnpm Global Bin Error
&lt;/h3&gt;

&lt;p&gt;If you see &lt;code&gt;ERR_PNPM_NO_GLOBAL_BIN_DIR&lt;/code&gt; when installing skills:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pnpm setup
source ~/.bashrc
openclaw onboard --install-daemon # Retry

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Proxmox-Specific Issues
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;VM won't boot: Check UEFI settings in Proxmox VM hardware tab; ensure BIOS is set to OVMF (UEFI).&lt;/li&gt;
&lt;li&gt;No IP assigned: Verify DHCP in your Proxmox network bridge; restart VM if needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reset Everything
&lt;/h3&gt;

&lt;p&gt;Start over if needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openclaw gateway stop
rm -rf ~/.openclaw/
openclaw onboard --install-daemon

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Diagnostic Commands
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openclaw doctor # Comprehensive health check
openclaw status --all # Deep status
openclaw health # Quick health probe
openclaw gateway logs -f # Follow gateway logs

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;OpenClaw can execute commands and access your system. Here's how to secure it:&lt;/p&gt;

&lt;h3&gt;
  
  
  Gateway Security
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Loopback binding only&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ "gateway": { "bind": "127.0.0.1" } }

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Token authentication&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "gateway": {
    "auth": {
      "mode": "token",
      "allowTailscale": false // Token-only is stricter; enable true only if Tailnet is highly trusted
    }
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Rotate tokens regularly (every 90 days)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openclaw configure --section gateway.auth

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Monitor access logs&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openclaw gateway logs | grep -i "error\|unauthorised\|failed"

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Tailscale Security
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Use Serve (Tailnet-only), not Funnel (public).&lt;/li&gt;
&lt;li&gt;Generate auth keys with 30-90 day expiration; rotate them.&lt;/li&gt;
&lt;li&gt;Enable firewall (e.g., UFW: &lt;code&gt;sudo ufw enable&lt;/code&gt; and allow only necessary outbound traffic).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  System Security
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Regular updates:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y
openclaw update

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consider automating via cron: &lt;code&gt;crontab -e&lt;/code&gt; and add &lt;code&gt;0 2 * * * /usr/bin/apt update &amp;amp;&amp;amp; /usr/bin/apt upgrade -y&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Take Proxmox snapshots before changes: &lt;code&gt;qm snapshot 201 pre-config --description 'Before updates'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also, backup config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tar -czf openclaw-backup-$(date +%Y%m%d).tar.gz ~/.openclaw/

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Best Practices:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use token authentication&lt;/li&gt;
&lt;li&gt;Enable Tailscale Serve (not Funnel)&lt;/li&gt;
&lt;li&gt;Regular backups and updates&lt;/li&gt;
&lt;li&gt;Monitor logs for issues&lt;/li&gt;
&lt;li&gt;Rotate credentials periodically&lt;/li&gt;
&lt;li&gt;Never expose gateway on 0.0.0.0&lt;/li&gt;
&lt;li&gt;Never commit tokens to git&lt;/li&gt;
&lt;li&gt;Install monitoring tools like Fail2Ban for SSH: &lt;code&gt;sudo apt install fail2ban&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;For least privilege (e.g., running OpenClaw as non-root user), we'll cover it in a future article when we deep dive into securing it.&lt;/li&gt;
&lt;li&gt;Consider sandboxing with Docker inside the VM for extra isolation.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Testing and Validation
&lt;/h2&gt;

&lt;p&gt;After setup, run a smoke test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Access the UI via Tailscale URL.&lt;/li&gt;
&lt;li&gt;Send a test message via WhatsApp/Telegram: "Hello, what's the weather in Dubai?" (assuming LLM integration).&lt;/li&gt;
&lt;li&gt;Verify browser control: Ask it to open a safe site and scrape non-sensitive data.&lt;/li&gt;
&lt;li&gt;Check logs for errors: &lt;code&gt;openclaw gateway logs&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If issues arise, use the troubleshooting section.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Template Script (Full Code)
&lt;/h2&gt;

&lt;p&gt;Here is the full code for reference (you can copy-paste this into a file if the wget fails, but using the raw URL is recommended):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/bin/bash

# ===== PRODUCTION-READY OPENCLAW PROXMOX TEMPLATE =====
# This script creates a Debian 13 VM template with OpenClaw pre-installed
#
# Author: Ali Shaikh &amp;lt;alishaikh.me&amp;gt;
#
# Features:
# - Cloud-init progress indication
# - Login warnings during initialization
# - Automatic pnpm setup
# - Status check commands
# - Access info helper command
# - Proper error handling
#
# ===== CONFIGURABLE PARAMETERS =====

VMID=10010
VM_NAME="debian-13-openclaw-ready-template"
STORAGE="local-lvm"
MEMORY=4096
SOCKETS=1
CORES=2
DISK_ADDITIONAL_SIZE="+15G"

IMAGE_URL="https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2"
IMAGE_PATH="/var/lib/vz/template/iso/$(basename ${IMAGE_URL})"

CI_USER="debian"
CI_PASSWORD="$(openssl passwd -6 debian)" # For production, use a stronger password

# Replace with your actual SSH public key
echo "ssh-rsa " &amp;gt; ~/ssh.pub

# Download Debian 13 Cloud Image
if [! -f "${IMAGE_PATH}"]; then
  echo "Downloading Debian 13 Cloud Image..."
  wget -P /var/lib/vz/template/iso/ "${IMAGE_URL}"
else
  echo "Image already exists at ${IMAGE_PATH}"
fi

set -x

# Destroy existing VM if it exists
qm destroy $VMID 2&amp;gt;/dev/null || true

# Create VM
qm create ${VMID} \
  --name "${VM_NAME}" \
  --ostype l26 \
  --memory ${MEMORY} \
  --cores ${CORES} \
  --agent 1 \
  --bios ovmf --machine q35 --efidisk0 ${STORAGE}:0,pre-enrolled-keys=0 \
  --cpu host --socket ${SOCKETS} --cores ${CORES} \
  --vga serial0 --serial0 socket \
  --net0 virtio,bridge=vmbr0

# Import disk
qm importdisk ${VMID} "${IMAGE_PATH}" ${STORAGE}
qm set $VMID --scsihw virtio-scsi-pci --virtio0 $STORAGE:vm-${VMID}-disk-1,discard=on
qm resize ${VMID} virtio0 ${DISK_ADDITIONAL_SIZE}
qm set $VMID --boot order=virtio0
qm set $VMID --scsi1 $STORAGE:cloudinit

# Create snippets directory
mkdir -p /var/lib/vz/snippets/

# Create Cloud-Init configuration with proper YAML formatting
cat &amp;lt;&amp;lt; 'CLOUDCONFIG' | tee /var/lib/vz/snippets/debian13_openclaw_ready.yaml
#cloud-config

package_update: true
package_upgrade: true

packages:
  - curl
  - wget
  - git
  - build-essential
  - procps
  - file
  - ca-certificates
  - gnupg
  - qemu-guest-agent
  - htop
  - vim
  - tmux
  - jq
  - cron

write_files:
  # Login status checker - runs on every login to show progress
  - path: /etc/profile.d/cloud-init-status.sh
    permissions: '0755'
    owner: root:root
    content: |
      #!/bin/bash
      # Check cloud-init and installation status on login

      RED='\033[0;31m'
      GREEN='\033[0;32m'
      YELLOW='\033[1;33m'
      BLUE='\033[0;34m'
      NC='\033[0m' # No Color

      # Check if cloud-init is still running
      if cloud-init status 2&amp;gt;/dev/null | grep -q "running"; then
        echo ""
        echo -e "${YELLOW}========================================${NC}"
        echo -e "${YELLOW} SYSTEM INITIALISATION IN PROGRESS${NC}"
        echo -e "${YELLOW}========================================${NC}"
        echo ""
        echo -e "${BLUE}Cloud-init is still running. Please wait...${NC}"
        echo ""
        echo "Monitor progress:"
        echo " sudo tail -f /var/log/cloud-init-output.log"
        echo " sudo tail -f /var/log/openclaw-install.log"
        echo ""
        echo "Check status:"
        echo " cloud-init status"
        echo " cat /var/run/openclaw-install-progress"
        echo ""
        echo -e "${YELLOW}Some commands may not be available yet!${NC}"
        echo ""
      elif [-f /var/run/openclaw-install-progress]; then
        PROGRESS=$(cat /var/run/openclaw-install-progress 2&amp;gt;/dev/null)
        if ["$PROGRESS" != "COMPLETE"]; then
          echo ""
          echo -e "${YELLOW}Installation in progress: $PROGRESS${NC}"
          echo "Run: sudo tail -f /var/log/openclaw-install.log"
          echo ""
        fi
      elif [-f /root/.openclaw-ready]; then
        # Only show ready message once per session
        if [-z "$OPENCLAW_READY_SHOWN"]; then
          echo ""
          echo -e "${GREEN}System is ready! Run: setup-openclaw.sh${NC}"
          echo ""
          export OPENCLAW_READY_SHOWN=1
        fi
      fi

  - path: /usr/local/bin/openclaw-access-info
    permissions: '0755'
    owner: root:root
    content: |
      #!/bin/bash
      echo "==========================================="
      echo "OpenClaw Access Information"
      echo "==========================================="
      echo ""
      echo "Tailscale Serve URL:"
      tailscale serve status 2&amp;gt;/dev/null | grep -o 'https://[^]*' || echo " Not configured - run: openclaw onboard --install-daemon"
      echo ""
      if [-f ~/.openclaw/openclaw.json]; then
        echo "Gateway Token:"
        cat ~/.openclaw/openclaw.json | jq -r '.gateway.auth.token' 2&amp;gt;/dev/null || echo " Not found"
        echo ""
        echo "Allow Tailscale Auth:"
        cat ~/.openclaw/openclaw.json | jq -r '.gateway.auth.allowTailscale' 2&amp;gt;/dev/null || echo " Not configured"
      else
        echo "OpenClaw not configured yet"
        echo "Run: openclaw onboard --install-daemon"
      fi
      echo "==========================================="

  - path: /usr/local/bin/setup-openclaw.sh
    permissions: '0755'
    owner: root:root
    content: |
      #!/bin/bash
      echo "======================================"
      echo "OpenClaw Setup Helper"
      echo "======================================"
      echo ""
      echo "Installed versions:"
      echo " Node.js: $(node --version 2&amp;gt;/dev/null || echo 'Not found')"
      echo " npm: $(npm --version 2&amp;gt;/dev/null || echo 'Not found')"
      echo " pnpm: $(pnpm --version 2&amp;gt;/dev/null || echo 'Not installed')"
      echo " OpenClaw: $(openclaw --version 2&amp;gt;/dev/null || echo 'Not installed - run: npm install -g openclaw@latest')"
      echo " Tailscale: $(tailscale version 2&amp;gt;/dev/null || echo 'Not found')"
      echo " Homebrew: $(brew --version 2&amp;gt;/dev/null | head -n1 || echo 'Not found - source ~/.bashrc first')"
      echo ""
      echo "Quick Start Guide:"
      echo "=================="
      echo ""
      echo "1. Connect to Tailscale:"
      echo " Get auth key: https://login.tailscale.com/admin/settings/keys"
      echo " sudo tailscale up --authkey=tskey-auth-YOUR_KEY"
      echo ""
      echo "2. Configure pnpm (required for skills):"
      echo " pnpm setup &amp;amp;&amp;amp; source ~/.bashrc"
      echo ""
      echo "3. Configure OpenClaw:"
      echo " openclaw onboard --install-daemon"
      echo ""
      echo " Configuration choices:"
      echo " - Setup: Local gateway (this machine)"
      echo " - Bind: Loopback (127.0.0.1) &amp;lt;- REQUIRED"
      echo " - Auth: Token (Recommended)"
      echo " - Tailscale: Serve (for HTTPS)"
      echo ""
      echo "4. Get access info:"
      echo " openclaw-access-info"
      echo ""
      echo "5. Verify:"
      echo " openclaw status"
      echo " openclaw health"
      echo " tailscale serve status"
      echo ""
      echo "Docs: https://docs.openclaw.ai"
      echo ""

  # Progress checker command
  - path: /usr/local/bin/check-install-status
    permissions: '0755'
    owner: root:root
    content: |
      #!/bin/bash
      echo "========================================"
      echo "Installation Status Check"
      echo "========================================"
      echo ""

      # Cloud-init status
      echo "Cloud-init status:"
      cloud-init status 2&amp;gt;/dev/null || echo " Unable to check"
      echo ""

      # Progress file (cleared after reboot, so may not exist)
      if [-f /var/run/openclaw-install-progress]; then
        echo "Current step: $(cat /var/run/openclaw-install-progress)"
      fi

      # Ready marker (use sudo to check /root)
      if sudo test -f /root/.openclaw-ready 2&amp;gt;/dev/null; then
        echo "Status: READY"
      else
        echo "Status: IN PROGRESS (or checking permissions...)"
      fi
      echo ""

      # Installed versions (use sudo to read /root)
      if sudo test -f /root/install-versions.txt 2&amp;gt;/dev/null; then
        echo "Installed versions:"
        sudo cat /root/install-versions.txt | sed 's/^/ /'
      fi
      echo ""

      echo "Logs:"
      echo " sudo tail -f /var/log/openclaw-install.log"

  - path: /root/install-openclaw.sh
    permissions: '0755'
    owner: root:root
    content: |
      #!/bin/bash
      # Don't use set -e - we want to continue even if some commands fail
      exec &amp;gt; /var/log/openclaw-install.log 2&amp;gt;&amp;amp;1

      # Progress update function
      update_progress() {
        echo "$1" &amp;gt; /var/run/openclaw-install-progress
        echo "&amp;gt;&amp;gt;&amp;gt; PROGRESS: $1"
      }

      echo "=== OpenClaw Installation Started at $(date) ==="
      update_progress "Starting installation..."

      # Install Tailscale
      update_progress "Installing Tailscale..."
      curl -fsSL https://tailscale.com/install.sh | sh
      if command -v tailscale &amp;amp;&amp;gt; /dev/null; then
        echo "Tailscale version: $(tailscale version)"
      else
        echo "WARNING: Tailscale installation may have failed"
      fi

      # Install Node.js 24
      update_progress "Installing Node.js 24..."
      curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
      apt-get install -y nodejs
      echo "Node.js version: $(node --version)"
      echo "npm version: $(npm --version)"

      # Install pnpm globally
      update_progress "Installing pnpm..."
      npm install -g pnpm@latest
      echo "pnpm version: $(pnpm --version)"

      # Setup pnpm global bin directory (required for OpenClaw skills)
      pnpm setup
      source /root/.bashrc 2&amp;gt;/dev/null || true
      # Also setup for debian user
      sudo -u debian bash -c 'pnpm setup' 2&amp;gt;/dev/null || true

      # Install OpenClaw via npm
      update_progress "Installing OpenClaw..."
      npm install -g openclaw@latest

      # Verify installation and add to PATH if needed
      if command -v openclaw &amp;amp;&amp;gt; /dev/null; then
        echo "OpenClaw version: $(openclaw --version)"
      else
        echo "OpenClaw not in PATH, checking npm global bin..."
        NPM_BIN=$(npm bin -g)
        if [-f "$NPM_BIN/openclaw"]; then
          echo "Found at $NPM_BIN/openclaw, creating symlink..."
          ln -sf "$NPM_BIN/openclaw" /usr/local/bin/openclaw
          echo "OpenClaw version: $(openclaw --version)"
        else
          echo "ERROR: OpenClaw installation failed"
          echo "NPM global bin: $NPM_BIN"
          ls -la "$NPM_BIN/" 2&amp;gt;/dev/null || echo "Cannot list npm bin directory"
        fi
      fi

      # Install Homebrew for debian user (for plugins)
      update_progress "Installing Homebrew (this may take a while)..."
      # Create the debian user's home if it doesn't exist
      mkdir -p /home/debian
      chown debian:debian /home/debian

      # Install Homebrew as debian user
      sudo -u debian bash -c 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' || {
        echo "WARNING: Homebrew installation failed"
      }

      # Add Homebrew to debian user's PATH
      if [-d "/home/linuxbrew/.linuxbrew"]; then
        sudo -u debian bash -c 'echo "# Homebrew" &amp;gt;&amp;gt; ~/.bashrc'
        sudo -u debian bash -c 'echo "eval \"$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)\"" &amp;gt;&amp;gt; ~/.bashrc'
        echo "Homebrew installed successfully"
      else
        echo "WARNING: Homebrew directory not found after installation"
      fi

      update_progress "Finalising..."

      echo "=== Installation Complete at $(date) ==="
      echo "Node.js: $(node --version 2&amp;gt;/dev/null || echo 'FAILED')" &amp;gt; /root/install-versions.txt
      echo "npm: $(npm --version 2&amp;gt;/dev/null || echo 'FAILED')" &amp;gt;&amp;gt; /root/install-versions.txt
      echo "pnpm: $(pnpm --version 2&amp;gt;/dev/null || echo 'FAILED')" &amp;gt;&amp;gt; /root/install-versions.txt
      echo "OpenClaw: $(openclaw --version 2&amp;gt;/dev/null || echo 'FAILED')" &amp;gt;&amp;gt; /root/install-versions.txt
      echo "Tailscale: $(tailscale version 2&amp;gt;/dev/null || echo 'FAILED')" &amp;gt;&amp;gt; /root/install-versions.txt
      echo "Homebrew: $(sudo -u debian /home/linuxbrew/.linuxbrew/bin/brew --version 2&amp;gt;/dev/null | head -n1 || echo 'FAILED')" &amp;gt;&amp;gt; /root/install-versions.txt

      # Mark installation as complete
      update_progress "COMPLETE"
      touch /root/.openclaw-ready

      echo ""
      echo "Check /root/install-versions.txt for results"

runcmd:
  - systemctl enable qemu-guest-agent
  - systemctl start qemu-guest-agent
  - echo "INITIALISING" &amp;gt; /var/run/openclaw-install-progress
  - chmod +x /root/install-openclaw.sh
  - /root/install-openclaw.sh
  - chown debian:debian /usr/local/bin/setup-openclaw.sh
  - |
    cat &amp;gt;&amp;gt; /etc/motd &amp;lt;&amp;lt; 'MOTD'

    ========================================
    OpenClaw Ready Template
    ========================================
    Created by Ali Shaikh
    https://alishaikh.me

    Pre-installed (globally):
      - Node.js 24 (NodeSource)
      - pnpm (latest)
      - OpenClaw CLI
      - Tailscale (official)
      - Homebrew (for plugins)

    Check installation: cat /root/install-versions.txt
    Check status: check-install-status
    Setup guide: setup-openclaw.sh
    Access info: openclaw-access-info (after configuration)

    MOTD
  - apt-get clean
  - apt-get autoremove -y

power_state:
  mode: reboot
  message: "Rebooting after OpenClaw installation"
  timeout: 30
  condition: True

CLOUDCONFIG

# Apply Cloud-Init configuration
qm set $VMID --cicustom "vendor=local:snippets/debian13_openclaw_ready.yaml"
qm set $VMID --tags debian-template,openclaw-ready,nodejs24,production
qm set ${VMID} --ciuser ${CI_USER} --cipassword "${CI_PASSWORD}"
qm set $VMID --sshkeys ~/ssh.pub
qm set $VMID --ipconfig0 ip=dhcp

# Convert to template
qm template ${VMID}

echo ""
echo "=========================================="
echo "PRODUCTION TEMPLATE CREATED SUCCESSFULLY"
echo "=========================================="
echo "Template ID: ${VMID}"
echo "Template Name: ${VM_NAME}"
echo ""
echo "Globally installed:"
echo " - Node.js 24 (NodeSource)"
echo " - pnpm (latest)"
echo " - OpenClaw CLI"
echo " - Tailscale (official)"
echo " - Homebrew (for plugins)"
echo ""
echo "DEPLOYMENT:"
echo " qm clone ${VMID} 201 --name openclaw-prod --full"
echo " qm start 201"
echo ""
echo "IMPORTANT: Wait 5-7 minutes for installation to complete"
echo ""
echo "CHECK INSTALLATION STATUS:"
echo " ssh debian@&amp;lt;vm-ip&amp;gt;"
echo " check-install-status # Quick status check"
echo " tail -f /var/log/openclaw-install.log"
echo " cat /root/install-versions.txt"
echo ""

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;p&gt;You now have a production-ready AI assistant running on your own infrastructure, accessible from anywhere via WhatsApp, Telegram, or your browser, with zero public exposure. Your conversations and command history stay on your server, your files remain under your control, and you decide exactly what the AI can access. However, be clear about the privacy boundary: when you use cloud models like Claude, GPT-4o, or Gemini, your prompts and responses still go to Anthropic, OpenAI, or Google. Self-hosting OpenClaw gives you infrastructure control and data residency for everything except the LLM API calls. For true end-to-end privacy, you'd need to run local models via &lt;a href="https://alishaikh.me/how-to-run-local-llms-with-ollama-quick-setup-guide/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; or &lt;a href="https://alishaikh.me/run-a-local-llm-with-lm-studio-easy-2025-tutorial/" rel="noopener noreferrer"&gt;LM Studio&lt;/a&gt;, though at the cost of performance compared to cloud models.&lt;/p&gt;

&lt;p&gt;Before you start feeding your assistant sensitive data, remember what you've built: a system that can execute arbitrary commands, access files, and interact with external services. The security measures in this guide (loopback binding, token authentication, Tailscale isolation) are foundational, not exhaustive. Treat this like any other privileged service: rotate credentials regularly, monitor access logs, keep the system patched, and never expose the gateway publicly. The Proxmox snapshot feature is your friend - take one before major changes so you can roll back easily.&lt;/p&gt;

&lt;p&gt;The Proxmox template makes this repeatable: clone it whenever you need a fresh instance, whether for testing new configurations or deploying assistants for different use cases. OpenClaw is still evolving rapidly (remember, it went from zero to 100k stars in two months), so expect frequent updates and new capabilities. Consider this deployment a learning platform first, a production tool second. Test destructively in clones, understand the permission model, and gradually increase what you trust it to do. Report issues or contribute on the GitHub repo.&lt;/p&gt;

&lt;p&gt;Self-hosting means self-responsibility, but it also means you're not at the mercy of a vendor's API changes or pricing decisions. Join the community, experiment with custom skills, contribute back what you learn, and most importantly, keep your gateway token secure. Stay vigilant, stay curious, and enjoy having an AI that actually works for you. Happy automating! 🦞&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.openclaw.ai/?ref=alishaikh.me" rel="noopener noreferrer"&gt;OpenClaw Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/openclaw/openclaw?ref=alishaikh.me" rel="noopener noreferrer"&gt;OpenClaw GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tailscale.com/kb/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Tailscale Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pve.proxmox.com/wiki/Cloud-Init_Support?ref=alishaikh.me" rel="noopener noreferrer"&gt;Proxmox Cloud-Init&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.debian.org/images/cloud/trixie/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Debian Cloud Images&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Festive STEM Adventure: Building the Acebott ESP32 QD001 Robot Car with My Daughter</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Tue, 30 Dec 2025 20:43:59 +0000</pubDate>
      <link>https://dev.to/alishaikh/festive-stem-adventure-building-the-acebott-esp32-qd001-robot-car-with-my-daughter-2ph1</link>
      <guid>https://dev.to/alishaikh/festive-stem-adventure-building-the-acebott-esp32-qd001-robot-car-with-my-daughter-2ph1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F36xl7mdx1zyoev57hdml.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F36xl7mdx1zyoev57hdml.png" alt="Festive STEM Adventure: Building the Acebott ESP32 QD001 Robot Car with My Daughter" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Remember that &lt;a href="https://alishaikh.me/building-a-traffic-light-controller-for-my-2-year-old-esp32-with-web-interface-ota-updates/" rel="noopener noreferrer"&gt;traffic light controller I built&lt;/a&gt; for my daughter? Then the &lt;a href="https://alishaikh.me/peek-a-boo-but-with-esp32-upgrading-the-traffic-light-controller-with-hc-sr04/" rel="noopener noreferrer"&gt;proximity sensor upgrade&lt;/a&gt; that turned it into a peek-a-boo game? Well, this Christmas, I thought: why not go bigger?&lt;/p&gt;

&lt;p&gt;Enter the Acebott QD001 ESP32 4WD Robot Kit. Four wheels, motors, sensors, and an ESP32 brain. A proper robot car that she could actually drive around the living room. The kind of thing that would've cost hundreds a few years ago, now sitting at a fraction of that price.&lt;/p&gt;

&lt;p&gt;I'll be honest—I was curious. Would a ready-made kit be as satisfying as building something from scratch? Would the quality hold up? And more importantly, could my 2-year-old actually get involved in putting this together?&lt;/p&gt;

&lt;p&gt;Turns out, yes to all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Get in the Box
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://acebott.com/docs/qd001-smart-car-starter-kit-for-esp32/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Acebott QD001&lt;/a&gt; arrives as a complete starter kit. No hunting for compatible parts across three different websites. No "oh, I forgot to order the motor driver" moments at 11 PM.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4c34tlbtwjznnsexfytj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4c34tlbtwjznnsexfytj.png" alt="Festive STEM Adventure: Building the Acebott ESP32 QD001 Robot Car with My Daughter" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's what's included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Electronics:&lt;/strong&gt; ESP32 MAX V1.0 development board and L298N motor driver shield&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sensors:&lt;/strong&gt; HC-SR04 ultrasonic sensor, line tracking sensors, and IR receiver&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mechanical:&lt;/strong&gt; 4WD chassis with motors and wheels, acrylic chassis plates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessories:&lt;/strong&gt; IR remote control, LED headlights, buzzer, battery holder (18650 batteries not included), screws, standoffs, and cables&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The documentation is solid too. Clear assembly diagrams, wiring schematics, and example code for different functionalities. You can find it all on &lt;a href="https://acebott.com/docs/qd001-smart-car-starter-kit-for-esp32/?ref=alishaikh.me" rel="noopener noreferrer"&gt;their documentation site&lt;/a&gt;. They also have YouTube videos for assembly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together
&lt;/h2&gt;

&lt;p&gt;This is where it got fun. The assembly is genuinely straightforward—no ambiguous instructions or mystery screws left over at the end. The chassis uses an acrylic sandwich design: bottom plate, motors mounted with brackets, middle plate for the electronics, top plate for the sensors.&lt;/p&gt;

&lt;p&gt;My daughter's contribution? She handed me screws (sometimes the right ones), held the screwdriver when I needed both hands, plugged in a few of the larger connectors, and helped push the wheels onto the motor shafts. That last bit required a bit of force, and she was genuinely proud when they clicked into place.&lt;/p&gt;

&lt;p&gt;The motors mount with simple brackets. The motor driver board sits on standoffs in the middle. Wiring is colour-coded. Takes maybe 2-3 hours if you're going slow and letting a toddler "help."&lt;/p&gt;

&lt;p&gt;The only slightly fiddly bit was routing all the wires neatly so they wouldn't get caught in the wheels. Small zip ties helped there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Upload Problem (and Solution)
&lt;/h2&gt;

&lt;p&gt;Right, so the robot's built. Time to upload some code and make it move.&lt;/p&gt;

&lt;p&gt;Except... it wouldn't upload. At all.&lt;/p&gt;

&lt;p&gt;Arduino IDE kept throwing errors: "Failed to connect with the device" and "Wrong boot mode detected (0x13)." The ESP32 was booting normally instead of entering download mode to accept new firmware.&lt;/p&gt;

&lt;p&gt;Here's the thing about the Acebott ESP32 MAX V1.0—it doesn't have a dedicated BOOT button like most development boards. When you want to upload code, you need to manually force it into download mode.&lt;/p&gt;

&lt;p&gt;The fix: connect GPIO0 (labelled "00" on the board) to GND with a jumper wire, then press the RST button. This pulls GPIO0 low during reset, which is what triggers the bootloader.&lt;/p&gt;

&lt;p&gt;I wrote up the &lt;a href="https://alishaikh.me/how-to-enter-download-mode-on-acebott-esp32-max-v1-0/" rel="noopener noreferrer"&gt;full solution here&lt;/a&gt; if you run into the same problem. The short version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use a jumper wire to connect GPIO0 to GND&lt;/li&gt;
&lt;li&gt;Press RST&lt;/li&gt;
&lt;li&gt;Start your upload&lt;/li&gt;
&lt;li&gt;Keep the jumper connected until upload completes&lt;/li&gt;
&lt;li&gt;Remove jumper, press RST again to boot normally&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Annoying? Yes. But once you know the trick, it's fine. Takes ten extra seconds per upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making It Work Better
&lt;/h2&gt;

&lt;p&gt;The kit comes with separate example programs—one for IR remote control, another for app control via WiFi. Both worked fine, but I wanted both control methods available at the same time. Why choose?&lt;/p&gt;

&lt;p&gt;Combining them was straightforward. The IR receiver and WiFi stack don't interfere with each other. I merged the two examples into a single program that listens for commands from either source. Whichever one sends a signal first wins. Simple.&lt;/p&gt;

&lt;p&gt;One limitation with the app control is that you need to connect to the car's WiFi network to control it. It works, but it's not ideal—you lose internet connectivity on your phone whilst connected. I'll explore creating a custom app in the future that might handle this better, perhaps using a different network architecture.&lt;/p&gt;

&lt;p&gt;The other change I made was moving some of the larger constant arrays into PROGMEM. The example code had a bunch of lookup tables and configuration data sitting in RAM. On an ESP32, you've got plenty of flash storage—why waste precious RAM?&lt;/p&gt;

&lt;p&gt;Not a massive difference for a project this size, but it's good practice. Keeps RAM free for runtime variables and buffers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Troubleshooting the IR Remote
&lt;/h3&gt;

&lt;p&gt;Getting the IR remote working turned into an interesting learning exercise. The remote uses infrared signals to communicate with the receiver on the robot—essentially flashing an IR LED in specific patterns that encode commands.&lt;/p&gt;

&lt;p&gt;The protocol is fairly simple once you understand it. When you press a button, the remote sends a burst of IR pulses at 38kHz (the carrier frequency). The pulses are modulated to encode data: different combinations of short and long pulses represent different commands. The IR receiver on the robot demodulates this signal and outputs the raw timing data.&lt;/p&gt;

&lt;p&gt;The Arduino IR libraries (like IRremote) handle the decoding. They measure the pulse widths and decode them into command codes. Each button on the remote has a unique code—forward might be &lt;code&gt;0xFF18E7&lt;/code&gt;, backward &lt;code&gt;0xFF4AB5&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;My first issue was that pin 4 on the shield wasn't responding at all. No matter what I tried, the IR receiver wouldn't pick up signals. After checking the wiring multiple times, I moved the IR receiver connection to GPIO 27 instead. Worked immediately. Sometimes it's just a dodgy connection point on the board.&lt;/p&gt;

&lt;p&gt;The other thing I wanted was to control the robot's lights with the remote—not just movement. To do this, I needed to know the exact codes each button sends. I flashed a simple debug sketch first that just printed out the raw IR codes to the Serial Monitor. Pressed each button on the remote, noted down the hex codes, then added those to the main program. The &lt;code&gt;*&lt;/code&gt; button now toggles the headlights on and off. I captured the other button codes as well and stored them in the code for future upgrades—having those codes ready means I can easily map new functions later without running the debug sketch again.&lt;/p&gt;

&lt;p&gt;It's a nice reminder that even with kits, you often need to go off-script to get exactly what you want.&lt;/p&gt;

&lt;p&gt;The complete code will be available on my GitHub if you want to see the changes. Nothing ground-breaking—just sensible tweaks that made the robot more flexible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Kits Like This Matter
&lt;/h2&gt;

&lt;p&gt;There's something to be said for building everything from scratch. I still enjoy that. Picking components, writing custom code, troubleshooting weird hardware interactions.&lt;/p&gt;

&lt;p&gt;But kits like the Acebott QD001 serve a different purpose. They lower the barrier to entry. Someone who's never touched robotics can open this box, follow the instructions, and have a working robot in an afternoon. That's powerful.&lt;/p&gt;

&lt;p&gt;The cost is reasonable too. Around £50-60 depending where you buy it. That includes everything except batteries. Compare that to buying motors, chassis, driver boards, sensors, and a microcontroller separately—you'd spend more and have to figure out compatibility yourself.&lt;/p&gt;

&lt;p&gt;And for kids? There's real value in seeing a pile of parts transform into something that moves and responds. My daughter doesn't understand motor drivers or PWM signals. But she absolutely understands that when she presses the button on the remote, the robot goes forward. Cause and effect. That's where learning starts.&lt;/p&gt;

&lt;p&gt;These kits also teach you the fundamentals without getting lost in the weeds. You learn how motor control works, how sensors provide feedback, how wireless communication lets you control things remotely. The principles transfer to other projects.&lt;/p&gt;

&lt;p&gt;Could I have built something similar from scratch? Sure. Would it have been as polished? Probably not. Would my daughter have cared? Definitely not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching Her Play
&lt;/h2&gt;

&lt;p&gt;The first time she made the robot move, her face lit up. Same expression as when she discovered the traffic light responded to the proximity sensor.&lt;/p&gt;

&lt;p&gt;She's still working out the controls—forward is intuitive, turning less so. But she's figuring it out. Sometimes the robot crashes into furniture. Sometimes she just drives it in circles. Doesn't matter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fezs9av9mgs8y736t3inp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fezs9av9mgs8y736t3inp.png" alt="Festive STEM Adventure: Building the Acebott ESP32 QD001 Robot Car with My Daughter" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What matters is that she's experimenting. Testing what happens when she presses different buttons. Learning that her actions have predictable results. That's the foundation of computational thinking, even if she won't call it that for another decade.&lt;/p&gt;

&lt;p&gt;And when she gets bored of just driving it around? The kit has line-following sensors and obstacle avoidance capabilities built in. Though I'll admit, the obstacle avoidance mode isn't particularly accurate—it's a bit hit and miss. That's something I'll explore improving, along with finding better control methods that don't require connecting to the car's WiFi. There's room to grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next: Expansion Packs
&lt;/h2&gt;

&lt;p&gt;One thing I appreciate about the Acebott ecosystem is the availability of expansion packs. The QD001 is designed to be modular—you're not stuck with what comes in the box.&lt;/p&gt;

&lt;p&gt;Acebott sells several add-ons: &lt;a href="https://acebott.com/stem-blogs/how-do-you-add-the-extension-packs-to-the-smart-car-kit-and-transforms/?ref=alishaikh.me" rel="noopener noreferrer"&gt;robot arm packs&lt;/a&gt;, tank treads, shooting mechanisms, even solar panels and GPS modules. I've already picked up the &lt;a href="https://www.amazon.com/ACEBOTT-Functions-Coding-Expansion-Without/dp/B0DHS75QN2?ref=alishaikh.me" rel="noopener noreferrer"&gt;QD002 Camera Expansion Pack&lt;/a&gt; for the next upgrade.&lt;/p&gt;

&lt;p&gt;The QD002 adds an ESP32-CAM module to the robot, giving it vision capabilities. Real-time video streaming to your phone, image recognition, wireless image transmission—all the things that turn a simple remote-control car into something that can "see" its environment. It's designed for beginners to learn about camera integration and wireless streaming without getting overwhelmed by the hardware side.&lt;/p&gt;

&lt;p&gt;I'll probably tackle that upgrade in a future article. Adding a camera opens up possibilities for autonomous navigation, line-following with visual feedback, or even facial recognition experiments. The robot's already got the processing power with the ESP32—might as well put it to use.&lt;/p&gt;

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

&lt;p&gt;The Acebott QD001 isn't trying to be everything to everyone. It's a starter kit. It gives you a solid foundation and lets you build from there.&lt;/p&gt;

&lt;p&gt;The quality is good. The motors have decent torque. The chassis is sturdy enough to survive a two-year-old's driving. The documentation is clear. And when you inevitably want to modify it—add more sensors, change the behaviour, integrate it with other systems—it's all ESP32-based. You're not locked into proprietary hardware.&lt;/p&gt;

&lt;p&gt;Is it perfect? No. The lack of a BOOT button is annoying. The battery holder could be more secure. But these are minor gripes.&lt;/p&gt;

&lt;p&gt;For anyone wanting to learn robotics, or teach it to someone else, or just have a fun weekend project, this kit delivers. It's accessible, educational, and actually works.&lt;/p&gt;

&lt;p&gt;Plus, my living room now has a small yellow robot roaming around. That's worth something.&lt;/p&gt;

&lt;h2&gt;
  
  
  References &amp;amp; Resources
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Product &amp;amp; Documentation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://acebott.com/docs/qd001-smart-car-starter-kit-for-esp32/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Acebott QD001 Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://shop.acebott.com/products/qd001-esp32-smart-car-kit-collection?ref=alishaikh.me" rel="noopener noreferrer"&gt;QD001 Product Page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.amazon.com/ACEBOTT-Functions-Coding-Expansion-Without/dp/B0DHS75QN2?ref=alishaikh.me" rel="noopener noreferrer"&gt;QD002 Camera Expansion Pack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://acebott.com/stem-blogs/how-do-you-add-the-extension-packs-to-the-smart-car-kit-and-transforms/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Acebott Expansion Packs Guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting &amp;amp; Guides:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/how-to-enter-download-mode-on-acebott-esp32-max-v1-0/" rel="noopener noreferrer"&gt;How to Enter Download Mode on Acebott ESP32 MAX V1.0&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Related Projects:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/building-a-traffic-light-controller-for-my-2-year-old-esp32-with-web-interface-ota-updates/" rel="noopener noreferrer"&gt;Building a Traffic Light Controller for My 2-Year-Old&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://alishaikh.me/peek-a-boo-but-with-esp32-upgrading-the-traffic-light-controller-with-hc-sr04/" rel="noopener noreferrer"&gt;Peek-a-Boo with ESP32: Proximity Sensor Upgrade&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;More parent-engineer adventures to come.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>esp32</category>
      <category>robotics</category>
      <category>stemeducation</category>
      <category>cars</category>
    </item>
    <item>
      <title>Kubernetes 1.35: In-Place Pod Vertical Scaling Reaches GA</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Mon, 29 Dec 2025 20:48:39 +0000</pubDate>
      <link>https://dev.to/alishaikh/kubernetes-135-in-place-pod-vertical-scaling-reaches-ga-4b06</link>
      <guid>https://dev.to/alishaikh/kubernetes-135-in-place-pod-vertical-scaling-reaches-ga-4b06</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqbkqtp7ljy8o8mqfrdhs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqbkqtp7ljy8o8mqfrdhs.png" alt="Kubernetes 1.35: In-Place Pod Vertical Scaling Reaches GA" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kubernetes v1.35, released on 17 December 2025, brings the long-awaited in-place pod vertical scaling feature to general availability (GA). This feature, officially called 'In-Place Pod Resize', allows administrators to adjust CPU and memory resources for running containers without recreating pods.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Traditional Approach to Resource Scaling
&lt;/h2&gt;

&lt;p&gt;Before this feature, scaling pod resources required deleting and recreating the pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Update deployment/pod specification with new resource values
2. Kubernetes terminates the existing pod
3. Scheduler creates a new pod with updated resources
4. Container runtime starts the new pod
5. Application initialises and becomes ready

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dropped network connections&lt;/li&gt;
&lt;li&gt;Loss of in-memory application state&lt;/li&gt;
&lt;li&gt;Service disruption during pod restart&lt;/li&gt;
&lt;li&gt;Extended downtime for applications with long startup times&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is In-Place Pod Vertical Scaling?
&lt;/h2&gt;

&lt;p&gt;In-Place Pod Vertical Scaling enables runtime modification of CPU and memory resource requests and limits without pod recreation. The Kubelet adjusts cgroup limits for running containers, allowing resource changes with minimal disruption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Development Timeline
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v1.27 (April 2023)&lt;/strong&gt;: Alpha release&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v1.33 (May 2025)&lt;/strong&gt;: Beta release&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v1.35 (December 2025)&lt;/strong&gt;: General availability (stable)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The feature required 6+ years of development to solve complex challenges, including container runtime coordination, scheduler synchronisation, and memory safety guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Features in v1.35
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Memory Limit Decrease
&lt;/h3&gt;

&lt;p&gt;Previous versions prohibited memory limit decreases due to out-of-memory (OOM) concerns. Version 1.35 implements best-effort memory decrease with safety checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kubelet verifies current memory usage is below the new limit&lt;/li&gt;
&lt;li&gt;Resize fails gracefully if usage exceeds the new limit&lt;/li&gt;
&lt;li&gt;Not guaranteed to prevent OOM, but significantly safer than forced decrease&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Previously prohibited, now allowed
resources:
  requests:
    memory: "512Mi" # Decreased from 1Gi
  limits:
    memory: "1Gi" # Decreased from 2Gi

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Prioritised Resize Queue
&lt;/h3&gt;

&lt;p&gt;When node capacity is insufficient for all resize requests, Kubernetes prioritises them by:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PriorityClass&lt;/strong&gt; value (higher first)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QoS class&lt;/strong&gt; (Guaranteed &amp;gt; Burstable &amp;gt; BestEffort)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration deferred&lt;/strong&gt; (oldest first)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This ensures critical workloads receive resources before lower-priority pods.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Enhanced Observability
&lt;/h3&gt;

&lt;p&gt;New metrics and events improve resize operation tracking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubelet Metrics:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pod_resource_resize_requests_total&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pod_resource_resize_failures_total&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pod_resource_resize_duration_seconds&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pod Conditions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PodResizePending&lt;/code&gt;: Request cannot be immediately granted&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PodResizeInProgress&lt;/code&gt;: Kubelet is applying changes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. VPA Integration
&lt;/h3&gt;

&lt;p&gt;Vertical Pod Autoscaler (VPA) &lt;code&gt;InPlaceOrRecreate&lt;/code&gt; update mode graduated to beta, enabling automatic resource adjustment using in-place resize when possible.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Resize Policies
&lt;/h3&gt;

&lt;p&gt;Containers specify restart behaviour for each resource type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: v1
kind: Pod
metadata:
  name: example
spec:
  containers:
  - name: app
    image: nginx:1.27
    resources:
      requests:
        cpu: "500m"
        memory: "512Mi"
      limits:
        cpu: "1000m"
        memory: "1Gi"
    resizePolicy:
    - resourceName: cpu
      restartPolicy: NotRequired # No restart for CPU changes
    - resourceName: memory
      restartPolicy: RestartContainer # Restart required for memory

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Policy Options:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NotRequired&lt;/code&gt;: Apply changes without container restart (default for CPU)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RestartContainer&lt;/code&gt;: Restart container to apply changes (often needed for memory)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Applying Resource Changes
&lt;/h3&gt;

&lt;p&gt;Use the &lt;code&gt;--subresource=resize&lt;/code&gt; flag with kubectl (requires kubectl v1.32+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl patch pod example --subresource=resize --patch '
{
  "spec": {
    "containers": [
      {
        "name": "app",
        "resources": {
          "requests": {"cpu": "800m"},
          "limits": {"cpu": "1600m"}
        }
      }
    ]
  }
}'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Monitoring Resize Status
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Check desired vs actual resources:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Desired (spec)
kubectl get pod example -o jsonpath='{.spec.containers[0].resources}'

# Actual (status)
kubectl get pod example -o jsonpath='{.status.containerStatuses[0].resources}'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;View resize conditions:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl get pod example -o jsonpath='{.status.conditions[?(@.type=="PodResizeInProgress")]}'
kubectl describe pod example | grep -A 10 Events

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Use Cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Peak Hour Resource Scaling
&lt;/h3&gt;

&lt;p&gt;Scale resources to match daily traffic patterns without service disruption. For example, scale up CPU/memory at 08:55 for business hours, then scale down at 18:05 after hours, eliminating the need for pod recreation.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Database Maintenance Operations
&lt;/h3&gt;

&lt;p&gt;Temporarily increase CPU allocation for intensive maintenance tasks like VACUUM or REINDEX operations, then restore normal allocation once complete. This avoids pod restart and preserves warm database caches.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. JIT Compilation Warmup
&lt;/h3&gt;

&lt;p&gt;Provide additional CPU during application startup for JIT compilation warmup, then reduce allocation once the application reaches steady state. Particularly beneficial for Java applications with large codebases.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Cost Optimisation
&lt;/h3&gt;

&lt;p&gt;Dynamically right-size resources to reduce waste by scaling down during low-traffic periods, reducing over-provisioning based on actual usage patterns, and implementing time-based scaling without pod recreation overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations and Constraints
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Resource Support:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only CPU and memory can be resized (GPU, ephemeral storage, hugepages remain immutable)&lt;/li&gt;
&lt;li&gt;Init and ephemeral containers cannot be resized&lt;/li&gt;
&lt;li&gt;QoS class cannot change during resize&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Platform Requirements:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Not supported on Windows nodes&lt;/li&gt;
&lt;li&gt;Requires compatible container runtime (containerd v2.0+, CRI-O v1.25+)&lt;/li&gt;
&lt;li&gt;Cannot resize with static CPU or Memory manager policies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Important Behaviours:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most runtimes (Java, Python, Node.js) require restart for memory changes&lt;/li&gt;
&lt;li&gt;Updating Deployment specs does not auto-resize existing pods&lt;/li&gt;
&lt;li&gt;Cannot remove requests or limits entirely, only modify values&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Upgrading to Kubernetes v1.35
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes v1.34.x cluster&lt;/li&gt;
&lt;li&gt;kubectl v1.32+ client&lt;/li&gt;
&lt;li&gt;Compatible container runtime&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Upgrade Process
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Add v1.35 repository to all nodes:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# On each node
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.35/deb/Release.key | \
  sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-1.35-apt-keyring.gpg

echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-1.35-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.35/deb/ /' | \
  sudo tee /etc/apt/sources.list.d/kubernetes-1.35.list

sudo apt update

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Upgrade control plane:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt-mark unhold kubeadm
sudo apt install -y kubeadm=1.35.0-1.1
sudo kubeadm upgrade apply v1.35.0 -y
sudo apt-mark unhold kubelet kubectl
sudo apt install -y kubelet=1.35.0-1.1 kubectl=1.35.0-1.1
sudo systemctl restart kubelet

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Upgrade workers (one at a time):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl drain &amp;lt;worker-name&amp;gt; --ignore-daemonsets --delete-emptydir-data --force

ssh &amp;lt;worker-node&amp;gt;
sudo apt-mark unhold kubeadm kubelet kubectl
sudo apt install -y kubeadm=1.35.0-1.1 kubelet=1.35.0-1.1 kubectl=1.35.0-1.1
sudo kubeadm upgrade node
sudo systemctl restart kubelet

kubectl uncordon &amp;lt;worker-name&amp;gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Verify upgrade:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl get nodes
# All nodes should show v1.35.0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing In-Place Resize
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Basic CPU Resize Test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: v1
kind: Pod
metadata:
  name: cpu-test
spec:
  containers:
  - name: nginx
    image: nginx:1.27
    resources:
      requests:
        cpu: "250m"
      limits:
        cpu: "500m"
    resizePolicy:
    - resourceName: cpu
      restartPolicy: NotRequired

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create pod and verify baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl apply -f cpu-test.yaml
kubectl get pod cpu-test -o jsonpath='{.status.containerStatuses[0].restartCount}'
# Output: 0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resize CPU:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl patch pod cpu-test --subresource=resize --patch '
{
  "spec": {
    "containers": [{
      "name": "nginx",
      "resources": {
        "requests": {"cpu": "800m"},
        "limits": {"cpu": "800m"}
      }
    }]
  }
}'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify no restart occurred:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl get pod cpu-test -o jsonpath='{.status.containerStatuses[0].restartCount}'
# Output: 0 (unchanged)

kubectl get pod cpu-test -o jsonpath='{.spec.containers[0].resources.requests.cpu}'
# Output: 800m (updated)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Memory Decrease Test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Create pod with high memory
kubectl apply -f memory-test.yaml

# Increase memory first
kubectl patch pod memory-test --subresource=resize --patch '
{
  "spec": {
    "containers": [{
      "name": "app",
      "resources": {
        "requests": {"memory": "1Gi"},
        "limits": {"memory": "2Gi"}
      }
    }]
  }
}'

# Decrease memory (new in v1.35)
kubectl patch pod memory-test --subresource=resize --patch '
{
  "spec": {
    "containers": [{
      "name": "app",
      "resources": {
        "requests": {"memory": "512Mi"},
        "limits": {"memory": "1Gi"}
      }
    }]
  }
}'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Testing:&lt;/strong&gt; Verify application behaviour in non-production before enabling in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resize Policies:&lt;/strong&gt; Use &lt;code&gt;NotRequired&lt;/code&gt; for CPU (usually safe), &lt;code&gt;RestartContainer&lt;/code&gt; for memory (often requires restart).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring:&lt;/strong&gt; Track Kubelet metrics for resize operations, alert on persistent &lt;code&gt;PodResizePending&lt;/code&gt; conditions, monitor OOM events after decreases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource Changes:&lt;/strong&gt; Make incremental adjustments, avoid large jumps, verify current usage before decreasing limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Priority:&lt;/strong&gt; Use PriorityClasses for critical workloads to ensure resize priority during capacity constraints.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Resize Rejected (stuck in &lt;code&gt;PodResizePending&lt;/code&gt;):&lt;/strong&gt; Check node capacity (&lt;code&gt;kubectl describe node&lt;/code&gt;), verify QoS class compatibility, review kubelet configuration for static resource managers, ensure resize policy allows the change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unexpected Restarts:&lt;/strong&gt; Memory changes often require restart regardless of policy. Use &lt;code&gt;RestartContainer&lt;/code&gt; for memory, test application behaviour, monitor OOM events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OOM After Memory Decrease:&lt;/strong&gt; Verify current usage first (&lt;code&gt;kubectl top pod&lt;/code&gt;), make gradual decreases, ensure application can release memory under pressure, consider using &lt;code&gt;RestartContainer&lt;/code&gt; to force release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-Patterns: When NOT to Use In-Place Resize
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; In-place pod resize is a powerful feature that can become an anti-pattern if misused. It should be reserved for specific edge cases, not used as a general scaling strategy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why It Can Be an Anti-Pattern
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Violates Immutable Infrastructure:&lt;/strong&gt; Kubernetes follows "cattle not pets" philosophy with ephemeral, replaceable pods. In-place resize makes pods mutable and long-lived, contradicting this design principle and reducing system resilience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Breaks GitOps:&lt;/strong&gt; Manual pod patching creates drift between Git repository and cluster reality. Next deployment overwrites manual changes, cluster state doesn't match version control, and you cannot reproduce environments from Git.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Configuration Drift:&lt;/strong&gt; Pods in the same Deployment can have different resource allocations, causing inconsistent behaviour across replicas, difficult debugging, and unpredictable load distribution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Reduces Failure Isolation:&lt;/strong&gt; Encourages keeping pods alive longer rather than quick replacement, which can hide underlying issues like memory leaks and delay necessary restarts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Better Alternatives Exist:&lt;/strong&gt; Traffic spikes should use HPA (scale out), resource changes should update Deployments (declarative), and environment differences should use Kustomize/Helm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Some Valid Use Cases
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Stateful Applications with Expensive Startup:&lt;/strong&gt; Databases with 15+ minute startup times (cache warming, index loading). Example: Scale up CPU for PostgreSQL VACUUM/ANALYZE operations, then scale down. Justified because recreation cost exceeds configuration drift cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Single-Instance Workloads:&lt;/strong&gt; Legacy applications with shared file locks, non-distributed state, or connection pooling limitations. Justified because horizontal scaling isn't possible without full rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Emergency Production Incidents:&lt;/strong&gt; Immediate relief for OOM conditions at 3am when proper solutions require hours of testing. Must be temporary (fix properly within 24 hours) and documented.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Predictable Temporary Spikes:&lt;/strong&gt; Monthly report generation requiring 4x CPU for 2 hours. Automate with CronJobs to scale up/down on schedule. Justified because running 4x resources 24/7 wastes 96% of allocation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Testing and Capacity Planning:&lt;/strong&gt; Non-production experimentation to measure performance at different resource levels and determine optimal production sizing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future Enhancements
&lt;/h2&gt;

&lt;p&gt;Kubernetes SIG-Node is working on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removing static CPU/Memory manager restrictions&lt;/li&gt;
&lt;li&gt;Support for additional resource types&lt;/li&gt;
&lt;li&gt;Improved safety for memory decreases (runtime-level checks)&lt;/li&gt;
&lt;li&gt;Resource pre-emption (evict lower-priority pods for high-priority resizes)&lt;/li&gt;
&lt;li&gt;Better integration with horizontal autoscaling&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;In-Place Pod Vertical Scaling addresses a long-standing Kubernetes limitation, enabling resource adjustments without service disruption. The v1.35 GA release brings production-ready capability with important improvements including memory decrease support, prioritised queuing, and enhanced observability.&lt;/p&gt;

&lt;p&gt;While this feature enables dynamic resource management for stateful workloads and emergency scenarios, it should complement rather than replace traditional scaling patterns. Use HPA for traffic-based scaling, update Deployments for permanent changes, and reserve in-place resize for the specific edge cases where pod recreation cost genuinely exceeds operational complexity.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/blog/2025/12/17/kubernetes-v1-35-release/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Kubernetes v1.35 Release Announcement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/blog/2025/12/19/kubernetes-v1-35-in-place-pod-resize-ga/?ref=alishaikh.me" rel="noopener noreferrer"&gt;In-Place Pod Resize GA Blog Post&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/resize-container-resources/?ref=alishaikh.me" rel="noopener noreferrer"&gt;Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/1287-in-place-update-pod-resources?ref=alishaikh.me" rel="noopener noreferrer"&gt;KEP-1287: In-Place Update of Pod Resources&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kubernetes/kubernetes/blob/master/CHANGELOG/CHANGELOG-1.35.md?ref=alishaikh.me" rel="noopener noreferrer"&gt;Kubernetes v1.35 Release Notes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>homelab</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
