<?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 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>
    <item>
      <title>LocalStack: Run AWS Services Locally for Free</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Wed, 12 Nov 2025 19:53:47 +0000</pubDate>
      <link>https://dev.to/alishaikh/localstack-run-aws-services-locally-for-free-5ed4</link>
      <guid>https://dev.to/alishaikh/localstack-run-aws-services-locally-for-free-5ed4</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%2Fv279kq7nxqkw0qxoj081.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%2Fv279kq7nxqkw0qxoj081.png" alt="LocalStack: Run AWS Services Locally for Free"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if you could spin up S3 buckets, Lambda functions, and DynamoDB tables on your laptop - without spending a penny or touching real AWS?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're learning AWS or building cloud applications, you've probably worried about test environment costs. LocalStack solves this by running AWS services entirely on your local machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is LocalStack?
&lt;/h2&gt;

&lt;p&gt;LocalStack is a cloud service emulator that mimics AWS on your computer. It uses the same API calls, SDKs, and service behaviors as real AWS. Your application can't tell the difference.&lt;/p&gt;

&lt;p&gt;S3 buckets, Lambda functions, DynamoDB tables, and SQS queues all run locally. You can even work offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Use LocalStack?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;For Learning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AWS's free tier runs out quickly when experimenting. Lambda invocations add up, forgotten DynamoDB tables keep charging, and you're constantly worried about costs. LocalStack removes that anxiety - experiment freely without any AWS bill.&lt;/p&gt;

&lt;p&gt;You can break things safely. Misconfigure IAM policies to see what happens. Create dozens of resources without consequences. LocalStack also speeds up feedback loops. CloudFormation deployments that take minutes on AWS happen in seconds locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For Development&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;LocalStack improves professional workflows in three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Better Testing&lt;/strong&gt; : Write integration tests that actually test cloud infrastructure. Your CI/CD pipeline runs full end-to-end tests against LocalStack without AWS credentials or test accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team Consistency&lt;/strong&gt; : Everyone runs the same LocalStack environment. No more "works on my AWS account" problems. Infrastructure-as-code gets tested locally before touching production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easier Debugging&lt;/strong&gt; : Develop Lambda functions with real breakpoints. Step through code and inspect variables using your IDE's debugging tools - all running on localhost.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Option 1: Using pip&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;localstack
localstack start

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option 2: Using Docker Compose (Recommended)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Create a &lt;code&gt;docker-compose.yml&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localstack-main"&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&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;DEBUG=0&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;./volume:/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;For more configuration options, check the &lt;a href="https://docs.localstack.cloud/getting-started/installation/?ref=alishaikh.me" rel="noopener noreferrer"&gt;official LocalStack documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Start LocalStack:&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 up &lt;span class="nt"&gt;-d&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Check status:&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

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

&lt;/div&gt;



&lt;p&gt;Stop when done:&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

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Using LocalStack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Point your AWS CLI at LocalStack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:4566 s3 mb s3://my-test-bucket

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

&lt;/div&gt;



&lt;p&gt;The only difference is that &lt;code&gt;--endpoint-url&lt;/code&gt; flag. Everything else works identically to real AWS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Services Are Available?
&lt;/h2&gt;

&lt;p&gt;The free Community Edition includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt; : S3&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute&lt;/strong&gt; : Lambda, EC2&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Databases&lt;/strong&gt; : DynamoDB (including Streams)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messaging&lt;/strong&gt; : SQS, SNS, Kinesis, EventBridge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API&lt;/strong&gt; : API Gateway (REST)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure&lt;/strong&gt; : CloudFormation, Step Functions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt; : IAM, Secrets Manager, KMS, STS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring&lt;/strong&gt; : CloudWatch (Logs and Metrics)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics&lt;/strong&gt; : OpenSearch, Redshift, Kinesis Firehose&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's enough to build and test most serverless applications for free.&lt;/p&gt;

&lt;p&gt;The pro version adds ECS, EKS, RDS, ElastiCache, AppSync, and features like full IAM policy enforcement and cloud pods for sharing state between team members.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Examples
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Building an Image Upload Service&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Say you're building an application that uploads user photos to S3. Normally, you'd create buckets on AWS, set up permissions, and pay for every test upload.&lt;/p&gt;

&lt;p&gt;With LocalStack, you just start it locally, point your AWS SDK to &lt;code&gt;http://localhost:4566&lt;/code&gt;, and test unlimited uploads on your laptop. When ready to deploy, remove the endpoint configuration and your code works on real AWS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More Complex Scenarios&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;LocalStack handles real-world architectures too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Event-Driven Systems&lt;/strong&gt; : Build a Lambda function that triggers when files land in S3, processes them, and stores results in DynamoDB. Test the entire flow locally before deploying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microservices Communication&lt;/strong&gt; : Set up SQS queues between services, test message handling, retry logic, and dead-letter queues without touching AWS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Development&lt;/strong&gt; : Create API Gateway endpoints that invoke Lambda functions, validate request/response formats, and test authentication - all on localhost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure as Code&lt;/strong&gt; : Write CloudFormation or CDK templates, deploy them to LocalStack, catch errors early, iterate quickly, then deploy to real AWS when everything works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow is always the same - build and test locally with LocalStack, deploy to AWS with confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Limitations?
&lt;/h2&gt;

&lt;p&gt;LocalStack isn't a perfect mirror of AWS - some behaviours differ slightly. Timing issues and eventual consistency don't always match exactly. The free version doesn't fully enforce complex IAM policies, and newer AWS features take a while to get implemented.&lt;/p&gt;

&lt;p&gt;That said, for learning and development work, you'll catch most issues locally. The edge cases that slip through typically show up in staging environments where they're easier to handle anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is LocalStack Worth Using?
&lt;/h2&gt;

&lt;p&gt;If you're learning AWS, absolutely. Being able to experiment without worrying about costs or breaking things is huge. You can try ideas, fail fast, and learn without the mental overhead of tracking resources and bills.&lt;/p&gt;

&lt;p&gt;For professional development, it's equally useful. The speed improvements alone make it worthwhile - no more waiting for CloudFormation stacks or Lambda deployments during development. Your tests run faster, your team stays aligned, and you catch infrastructure issues before they reach production.&lt;/p&gt;

&lt;p&gt;LocalStack isn't going to replace real AWS for actual deployments, but it makes the entire development process smoother and more enjoyable. You'll ship features faster and with more confidence knowing you've already tested everything locally.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Want to run S3 storage on your own hardware?&lt;/strong&gt; If you need a permanent S3-compatible storage solution that you can host yourself, check out my guide on &lt;a href="https://alishaikh.me/set-up-your-own-s3-compatible-minio-server-in-under-5-minutes-with-docker-no-aws-bills/" rel="noopener noreferrer"&gt;setting up MinIO - your own S3 server in under 5 minutes&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloudfomation</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Ghost Admin Login Gets Stuck on "Signing in": The staffDeviceVerification Problem</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Thu, 31 Jul 2025 20:38:10 +0000</pubDate>
      <link>https://dev.to/alishaikh/ghost-admin-login-gets-stuck-on-signing-in-the-staffdeviceverification-problem-3b00</link>
      <guid>https://dev.to/alishaikh/ghost-admin-login-gets-stuck-on-signing-in-the-staffdeviceverification-problem-3b00</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%2Fimages.unsplash.com%2Fphoto-1614064641938-3bbee52942c7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDV8fHNlY3VyaXR5fGVufDB8fHx8MTc1Mzk3Mjk0NXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" 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%2Fimages.unsplash.com%2Fphoto-1614064641938-3bbee52942c7%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDV8fHNlY3VyaXR5fGVufDB8fHx8MTc1Mzk3Mjk0NXww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D2000" alt="Ghost Admin Login Gets Stuck on "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I recently encountered a frustrating Ghost issue after I migrated from Digital Ocean to Hetzner that had me completely stumped. When trying to log into my Ghost admin panel, I'd enter my credentials and click "Sign in," but then get stuck on the loading screen showing "Signing in..." The login process would hang for minutes before eventually failing with a gateway timeout error.&lt;/p&gt;

&lt;p&gt;If you're experiencing similar symptoms after a server migration or a fresh Ghost installation, the culprit might be Ghost's &lt;code&gt;staffDeviceVerification&lt;/code&gt; security feature.&lt;/p&gt;

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

&lt;p&gt;Here's what the user experience looked like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enter email and password on the login page&lt;/li&gt;
&lt;li&gt;Click "Sign in"&lt;/li&gt;
&lt;li&gt;Page shows "Signing in..." loading state&lt;/li&gt;
&lt;li&gt;Wait... and wait... and wait...&lt;/li&gt;
&lt;li&gt;Eventually get a gateway timeout, or the page just stays stuck loading&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the Ghost logs, I could see the session endpoint taking an extremely long time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO "POST /ghost/api/admin/session" 200 60046ms

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

&lt;/div&gt;



&lt;p&gt;That's over 60 seconds for a login request that should complete in milliseconds. After this lengthy delay, subsequent API calls would fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR "GET /ghost/api/admin/users/me/?include=roles" 403 6ms

NAME: NoPermissionError
MESSAGE: Authorization failed
"Unable to determine the authenticated user or integration. Check that cookies are being passed through if using session authentication."

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  What is staffDeviceVerification?
&lt;/h2&gt;

&lt;p&gt;Ghost includes a security feature called &lt;code&gt;staffDeviceVerification&lt;/code&gt; that adds device-based authentication for admin users. When enabled, Ghost requires email verification for each new device that attempts to log into the admin panel.&lt;/p&gt;

&lt;p&gt;Here's how it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You log in from a new device&lt;/li&gt;
&lt;li&gt;Ghost sends a verification email&lt;/li&gt;
&lt;li&gt;You click the verification link to mark the device as trusted&lt;/li&gt;
&lt;li&gt;Future logins from that device don't require re-verification&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why It Breaks After Server Changes
&lt;/h2&gt;

&lt;p&gt;The problem occurs because Ghost uses various factors to create a "device fingerprint" - things like IP addresses, server environment variables, and session configurations. When you migrate to a new server or significantly change your setup, these fingerprints change.&lt;/p&gt;

&lt;p&gt;Ghost then considers your device "untrusted" and expects you to verify it via email. But if your email isn't configured properly, the login process gets stuck because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ghost accepts your password credentials&lt;/li&gt;
&lt;li&gt;Ghost tries to send a device verification email&lt;/li&gt;
&lt;li&gt;Email sending fails or times out (due to misconfigured email settings)&lt;/li&gt;
&lt;li&gt;The login request hangs waiting for the email process to complete&lt;/li&gt;
&lt;li&gt;Eventually the request times out, leaving you stuck on "Signing in..."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This creates the frustrating experience where you know your password is correct, but the login process just hangs indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;The fix is surprisingly simple: temporarily disable device verification in your Ghost configuration.&lt;/p&gt;

&lt;p&gt;Edit your &lt;code&gt;config.production.json&lt;/code&gt; file and add or modify the security section:&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://yourdomain.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"database"&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;your&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;database&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;config&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;"security"&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;"staffDeviceVerification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;Then restart Ghost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ghost restart

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

&lt;/div&gt;



&lt;h2&gt;
  
  
  When Should You Use Device Verification?
&lt;/h2&gt;

&lt;p&gt;Device verification is genuinely useful in certain scenarios:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enable it when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have multiple admin users accessing Ghost&lt;/li&gt;
&lt;li&gt;You frequently log in from different locations/devices&lt;/li&gt;
&lt;li&gt;You have a high-security blog with sensitive content&lt;/li&gt;
&lt;li&gt;Your email system is properly configured and reliable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Disable it when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're the only admin user&lt;/li&gt;
&lt;li&gt;You've recently migrated servers&lt;/li&gt;
&lt;li&gt;You're having email delivery issues&lt;/li&gt;
&lt;li&gt;You're still setting up your Ghost installation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Alternative: Fix Your Email Configuration
&lt;/h2&gt;

&lt;p&gt;If you want to keep device verification enabled, make sure your Ghost email configuration is working properly. Test it with a simple command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ghost config mail.transport SMTP
ghost config mail.options.host your-smtp-server.com
ghost config mail.options.port 587
ghost config mail.from your-email@domain.com

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

&lt;/div&gt;



&lt;p&gt;You can test email functionality by requesting a password reset - if that email arrives, device verification should work too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broader Lesson
&lt;/h2&gt;

&lt;p&gt;This issue highlights a common problem with modern web applications: security features that work great in stable environments can become roadblocks during migrations or configuration changes.&lt;/p&gt;

&lt;p&gt;Ghost's device verification is well-intentioned and genuinely useful for multi-user blogs, but it can create authentication loops that are difficult to diagnose. The error messages don't clearly indicate that device verification is the problem - they just show generic "Authorization failed" messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention for Future Migrations
&lt;/h2&gt;

&lt;p&gt;If you're planning a Ghost migration, consider disabling &lt;code&gt;staffDeviceVerification&lt;/code&gt; before starting the migration process. You can always re-enable it once everything is stable and your email system is confirmed working.&lt;/p&gt;

&lt;p&gt;This small configuration change can save you hours of debugging session management and proxy configurations that may not actually be the root cause of your authentication issues.&lt;/p&gt;

&lt;p&gt;Remember: sometimes the solution to complex technical problems is simpler than you think. When Ghost login works but the admin panel doesn't, check your security settings before diving deep into server configurations.&lt;/p&gt;

</description>
      <category>ghost</category>
      <category>authentication</category>
      <category>authorisation</category>
      <category>errors</category>
    </item>
    <item>
      <title>HTTP Status Codes Explained: Unlock the Web’s Secret Signals</title>
      <dc:creator>Ali Shaikh</dc:creator>
      <pubDate>Fri, 21 Mar 2025 08:55:41 +0000</pubDate>
      <link>https://dev.to/alishaikh/http-status-codes-explained-unlock-the-webs-secret-signals-217o</link>
      <guid>https://dev.to/alishaikh/http-status-codes-explained-unlock-the-webs-secret-signals-217o</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%2Frb4k63c2y62n24ecaihu.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%2Frb4k63c2y62n24ecaihu.png" alt="HTTP Status Codes Explained: Unlock the Web’s Secret Signals" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ever clicked a link and wondered what’s happening behind the scenes? Your browser and the server trade quick messages, and HTTP status codes are the server’s way of spilling the beans. These three-digit numbers might seem odd, but they’re packed with info. Let’s break them down and try a fun trick to see them in action!&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are HTTP Status Codes?
&lt;/h2&gt;

&lt;p&gt;HTTP status codes are short replies from web servers to your browser, summing up what happened with your request. Loading a page, submitting a form, or fetching data—they’ve got a code for it all. Grouped into five categories, they tell you if things went great, got redirected, or hit a snag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Categories of Status Codes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1xx: Informational Responses
&lt;/h3&gt;

&lt;p&gt;Rarely spotted, these codes mean your request is still cooking.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;100 Continue&lt;/strong&gt; : "Got the first bit—send more!"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;101 Switching Protocols&lt;/strong&gt; : "Switching connections per your request."&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2xx: Success Responses
&lt;/h3&gt;

&lt;p&gt;Sweet success! These mean your request nailed it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;200 OK&lt;/strong&gt; : Perfection—your page loaded without a hitch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;201 Created&lt;/strong&gt; : You’ve added something new, like a comment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;204 No Content&lt;/strong&gt; : All good, but nothing to display.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3xx: Redirection Responses
&lt;/h3&gt;

&lt;p&gt;A detour ahead—these point you somewhere else.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;301 Moved Permanently&lt;/strong&gt; : "This page has a new home."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;302 Found&lt;/strong&gt; : "It’s hanging out elsewhere for now."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;304 Not Modified&lt;/strong&gt; : "No updates since last time."&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4xx: Client Errors
&lt;/h3&gt;

&lt;p&gt;Uh-oh—something’s funky with your request.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;400 Bad Request&lt;/strong&gt; : "I can’t make sense of that."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;401 Unauthorized&lt;/strong&gt; : "Log in first, please."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;403 Forbidden&lt;/strong&gt; : "No entry allowed here."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;404 Not Found&lt;/strong&gt; : The classic "where’d it go?" error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;418 I’m a Teapot&lt;/strong&gt; : A goofy joke—servers don’t brew coffee!&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5xx: Server Errors
&lt;/h3&gt;

&lt;p&gt;Not your mess—the server’s struggling.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;500 Internal Server Error&lt;/strong&gt; : "We broke something."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;502 Bad Gateway&lt;/strong&gt; : "A middleman dropped the ball."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;503 Service Unavailable&lt;/strong&gt; : "Too busy or down for a tune-up."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Status Codes Matter
&lt;/h2&gt;

&lt;p&gt;These tiny numbers power the web:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They tell your browser what’s next (reload, redirect, or error out).&lt;/li&gt;
&lt;li&gt;They help developers squash bugs.&lt;/li&gt;
&lt;li&gt;They shape your journey, from snappy loads to clear error pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It Out: Catch a Status Code in the Wild
&lt;/h2&gt;

&lt;p&gt;Want to peek at these codes yourself? Here’s how:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Chrome or Firefox on your computer.&lt;/li&gt;
&lt;li&gt;Right-click any webpage and hit “Inspect” for Developer Tools.&lt;/li&gt;
&lt;li&gt;Click the “Network” tab, then reload the page (Ctrl+R or Cmd+R).&lt;/li&gt;
&lt;li&gt;Check the “Status” column—spot 200s, 304s, or a 404 if something’s AWOL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For kicks, try a fake URL (like &lt;a href="https://alishaikh.me/oops/" rel="noopener noreferrer"&gt;&lt;code&gt;alishaikh.me/oops&lt;/code&gt;&lt;/a&gt;) and watch a 404 appear!&lt;/p&gt;

&lt;h2&gt;
  
  
  Fun Fact
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;418 I’m a Teapot&lt;/strong&gt; code hatched from a 1998 April Fools’ prank. Proof the web’s got a playful side!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Web’s Pulse: What Will You Discover?
&lt;/h2&gt;

&lt;p&gt;From a flawless 200 OK to a cheeky 418, HTTP status codes are the heartbeat of every click. Now that you’ve decoded their signals, you’re no longer just a web surfer—you’re a digital detective. Fire up that “Network” tab and hunt for clues. What’s the wildest code you’ll catch today?&lt;/p&gt;

</description>
      <category>http</category>
      <category>webdev</category>
      <category>errors</category>
    </item>
  </channel>
</rss>
