[{"data":1,"prerenderedAt":45},["ShallowReactive",2],{"4526871":3},{"type_of":4,"id":5,"title":6,"description":7,"readable_publish_date":8,"slug":9,"path":10,"url":11,"comments_count":12,"public_reactions_count":12,"collection_id":13,"published_timestamp":14,"language":15,"subforem_id":16,"ai_disclosure_level":17,"ai_disclosure_label":18,"positive_reactions_count":12,"cover_image":19,"social_image":20,"canonical_url":11,"created_at":21,"edited_at":22,"crossposted_at":13,"published_at":14,"last_comment_at":14,"reading_time_minutes":23,"tag_list":24,"tags":25,"body_html":30,"body_markdown":31,"user":32,"organization":40},"article",4526871,"From Empty Folder to Live URL: A Beginner's Full-Stack App on AWS Fargate","A beginner-friendly walkthrough of building a movies app with Vue 3 and Express, storing data in...","Aug 30","from-empty-folder-to-live-url-a-beginners-full-stack-app-on-aws-fargate-3e6g","/aws-builders/from-empty-folder-to-live-url-a-beginners-full-stack-app-on-aws-fargate-3e6g","https://dev.to/aws-builders/from-empty-folder-to-live-url-a-beginners-full-stack-app-on-aws-fargate-3e6g",0,null,"2026-08-30T07:37:41Z","en",1,"not_disclosed","Not Disclosed","https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9b4i1t0fg012iab7e5gr.png","https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9b4i1t0fg012iab7e5gr.png","2026-08-30T06:51:54Z","2026-08-30T10:18:11Z",16,"aws, beginners, tutorial, vue",[26,27,28,29],"aws","beginners","tutorial","vue","\u003Cp>A beginner-friendly walkthrough of building a movies app with Vue 3 and Express, storing data in DynamoDB, and deploying it to ECS Fargate — with the actual code, screenshots, and every mistake included.\u003C/p>\n\n\u003Cp>When I started this project I could click around the AWS console without breaking anything, and that was about it. Words like \"Fargate\", \"task definition\" and \"single-table design\" made my eyes glaze over. So I did the thing everyone tells you to do and nobody does: I picked one small app I actually wanted to exist — a movies catalog — and built it all the way to a public URL on AWS.\u003C/p>\n\n\u003Cp>This post is the walkthrough I wish I'd had. No assumed knowledge beyond \"I've written some JavaScript and I know what an API is.\" We're building:\u003C/p>\n\n\u003Cul>\n\u003Cli>A \u003Cstrong>Vue 3\u003C/strong> frontend where you can browse, search, and add movies\u003C/li>\n\u003Cli>An \u003Cstrong>Express\u003C/strong> REST API that stores everything in \u003Cstrong>DynamoDB\u003C/strong>\n\u003C/li>\n\u003Cli>Two \u003Cstrong>Docker\u003C/strong> containers running on \u003Cstrong>ECS Fargate\u003C/strong> behind a load balancer\u003C/li>\n\u003Cli>All of it defined in code with \u003Cstrong>AWS CDK\u003C/strong>, and tested with \u003Cstrong>Playwright\u003C/strong>\n\u003C/li>\n\u003C/ul>\n\n\u003Cp>Here's what the finished thing looks like:\u003C/p>\n\n\u003Cp>\u003Ca href=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9b6usdhe9lsiwhamumnh.png\" class=\"article-body-image-wrapper\">\u003Cimg src=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9b6usdhe9lsiwhamumnh.png\" alt=\"The browse view: a dark-themed grid of movie cards showing title, year, director, genre tags and a star rating\" loading=\"lazy\" width=\"800\" height=\"500\">\u003C/a>\u003C/p>\n\n\u003Cp>The full source is on GitHub: \u003Cstrong>\u003Ca href=\"https://github.com/kasukur/fargate-movies-api\" target=\"_blank\" rel=\"noopener noreferrer\">fargate-movies-api\u003Cbr>\n\u003C/a>\u003C/strong>\u003C/p>\n\u003Ch2>\n  \u003Ca name=\"table-of-contents\" href=\"#table-of-contents\">\n  \u003C/a>\n  Table of contents\n\u003C/h2>\n\n\u003Cul>\n\u003Cli>\u003Ca href=\"#first-a-plain-english-map-of-the-aws-pieces\">First, a plain-English map of the AWS pieces\u003C/a>\u003C/li>\n\u003Cli>\n\u003Ca href=\"#step-1-design-the-database-before-writing-any-code\">Step 1: Design the database before writing any code\u003C/a>\n\n\u003Cul>\n\u003Cli>\u003Ca href=\"#the-a-movie-has-many-genres-problem\">The \"a movie has many genres\" problem\u003C/a>\u003C/li>\n\u003C/ul>\n\u003C/li>\n\u003Cli>\u003Ca href=\"#step-2-the-express-api\">Step 2: The Express API\u003C/a>\u003C/li>\n\u003Cli>\u003Ca href=\"#step-3-the-vue-3-frontend\">Step 3: The Vue 3 frontend\u003C/a>\u003C/li>\n\u003Cli>\u003Ca href=\"#step-4-put-both-apps-in-boxes\">Step 4: Put both apps in boxes\u003C/a>\u003C/li>\n\u003Cli>\u003Ca href=\"#step-5-the-actual-aws-part-its-200-lines\">Step 5: The actual AWS part (it's ~200 lines)\u003C/a>\u003C/li>\n\u003Cli>\u003Ca href=\"#step-6-prove-it-works-automatically\">Step 6: Prove it works, automatically\u003C/a>\u003C/li>\n\u003Cli>\u003Ca href=\"#troubleshooting-every-error-i-hit-and-what-it-actually-meant\">Troubleshooting: every error I hit, and what it actually meant\u003C/a>\u003C/li>\n\u003Cli>\u003Ca href=\"#what-this-costs-and-how-to-turn-it-off\">What this costs, and how to turn it off\u003C/a>\u003C/li>\n\u003Cli>\u003Ca href=\"#what-id-tell-you-if-youre-where-i-was\">What I'd tell you if you're where I was\u003C/a>\u003C/li>\n\u003C/ul>\n\u003Ch2>\n  \u003Ca name=\"first-a-plainenglish-map-of-the-aws-pieces\" href=\"#first-a-plainenglish-map-of-the-aws-pieces\">\n  \u003C/a>\n  First, a plain-English map of the AWS pieces\n\u003C/h2>\n\n\u003Cp>Before any code, here's every AWS term in this post, translated:\u003C/p>\n\n\u003Cul>\n\u003Cli>\n\u003Cstrong>DynamoDB\u003C/strong> — a database you never install, patch, or resize. You create a \u003Cem>table\u003C/em>, and AWS scales it for you. You pay per read/write. There's no SQL; you fetch items by their keys.\u003C/li>\n\u003Cli>\n\u003Cstrong>A container\u003C/strong> — your app plus everything it needs (Node, your dependencies, your code) zipped into one runnable box. If it runs on your laptop, it runs the same way in the cloud. Docker is the tool that builds these boxes.\u003C/li>\n\u003Cli>\n\u003Cstrong>ECS\u003C/strong> (Elastic Container Service) — AWS's system for running containers and restarting them when they crash.\u003C/li>\n\u003Cli>\n\u003Cstrong>Fargate\u003C/strong> — the \"no servers, please\" mode of ECS. You say \"run 2 copies of this container with 0.25 CPU and 512 MB of memory\" and AWS finds the machines. You never SSH into anything, because there's nothing to SSH into.\u003C/li>\n\u003Cli>\n\u003Cstrong>ALB\u003C/strong> (Application Load Balancer) — the front door. It has a public web address, receives all traffic, and forwards each request to the right container based on the URL path.\u003C/li>\n\u003Cli>\n\u003Cstrong>IAM role\u003C/strong> — a badge you pin on your running container that says what AWS things it may do. Our API's badge says \"may read and write one specific DynamoDB table\" and nothing else.\u003C/li>\n\u003Cli>\n\u003Cstrong>CDK\u003C/strong> (Cloud Development Kit) — instead of clicking through the console (and forgetting what you clicked), you describe your infrastructure in TypeScript and CDK creates it. Your whole cloud setup becomes a file you can read, review, and re-run.\u003C/li>\n\u003C/ul>\n\n\u003Cp>The architecture, in one diagram:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight plaintext\">\u003Ccode>                       ┌─────────────────────────────────────────┐\n                       │                 AWS VPC                  │\nInternet ──► ALB ──────┤  /api/* ──► Fargate: Express API ────────┼──► DynamoDB\n                       │  /*     ──► Fargate: Nginx + Vue files   │\n                       └─────────────────────────────────────────┘\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>One front door, two containers, one table. That's the whole app.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"step-1-design-the-database-before-writing-any-code\" href=\"#step-1-design-the-database-before-writing-any-code\">\n  \u003C/a>\n  Step 1: Design the database before writing any code\n\u003C/h2>\n\n\u003Cp>This was the biggest mindset shift. In a SQL world I'd make three tables — \u003Ccode>movies\u003C/code>, \u003Ccode>directors\u003C/code>, \u003Ccode>genres\u003C/code> — and JOIN them. DynamoDB has no JOINs. The DynamoDB way is to ask, \u003Cstrong>\"what questions will my app ask?\"\u003C/strong> and design the keys so each question is a single lookup.\u003C/p>\n\n\u003Cp>My questions were: get a movie by id, browse all movies A→Z, search by title, list a director's films, list a genre's films.\u003C/p>\n\n\u003Cp>Every item in DynamoDB has a \u003Cstrong>partition key\u003C/strong> (\u003Ccode>PK\u003C/code>, which bucket it lives in) and a \u003Cstrong>sort key\u003C/strong> (\u003Ccode>SK\u003C/code>, its position in that bucket). All three of my resource types share \u003Cem>one\u003C/em> table, told apart by prefixes in the keys:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight plaintext\">\u003Ccode>Movie      PK=MOVIE#123      SK=METADATA\nDirector   PK=DIRECTOR#456   SK=METADATA\nGenre      PK=GENRE#789      SK=METADATA\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>\"Browse all movies sorted by title\" can't be answered by those keys, so the table gets a \u003Cstrong>secondary index\u003C/strong> — think of it as the same data automatically re-sorted a second way:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight plaintext\">\u003Ccode>GSI1PK=ENTITY#MOVIE   GSI1SK=TITLE#inception\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>Now \"all movies A→Z\" is one query against \u003Ccode>ENTITY#MOVIE\u003C/code>, and the search box is the same query plus a prefix match:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"c1\">// apps/api/src/repositories/movieRepository.ts\u003C/span>\n\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">result\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"k\">await\u003C/span> \u003Cspan class=\"nx\">ddb\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">send\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\n  \u003Cspan class=\"k\">new\u003C/span> \u003Cspan class=\"nc\">QueryCommand\u003C/span>\u003Cspan class=\"p\">({\u003C/span>\n    \u003Cspan class=\"na\">TableName\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"nx\">TABLE_NAME\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"na\">IndexName\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">GSI1\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"na\">KeyConditionExpression\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">GSI1PK = :pk AND begins_with(GSI1SK, :prefix)\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"na\">ExpressionAttributeValues\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">:pk\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">ENTITY#MOVIE\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">:prefix\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"s2\">`TITLE#\u003C/span>\u003Cspan class=\"p\">${\u003C/span>\u003Cspan class=\"nx\">prefix\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">toLowerCase\u003C/span>\u003Cspan class=\"p\">()}\u003C/span>\u003Cspan class=\"s2\">`\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"p\">},\u003C/span>\n  \u003Cspan class=\"p\">}),\u003C/span>\n\u003Cspan class=\"p\">);\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>That one query powers this:\u003C/p>\n\n\u003Cp>\u003Ca href=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgbjw3qmu386kxo6h5hku.png\" class=\"article-body-image-wrapper\">\u003Cimg src=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgbjw3qmu386kxo6h5hku.png\" alt=\"Typing \" loading=\"lazy\" width=\"800\" height=\"500\">\u003C/a>\u003C/p>\n\n\u003Ch3>\n  \u003Ca name=\"the-a-movie-has-many-genres-problem\" href=\"#the-a-movie-has-many-genres-problem\">\n  \u003C/a>\n  The \"a movie has many genres\" problem\n\u003C/h3>\n\n\u003Cp>No JOINs, remember? The trick is to write a tiny extra item per genre when a movie is created — \u003Ccode>PK=GENRE#789, SK=MOVIE#123\u003C/code> — so \"movies in this genre\" becomes a simple query on the genre's bucket.\u003C/p>\n\n\u003Cp>But now one \"add movie\" writes several items, and if the process dies halfway you get a movie that's missing from half its genres. DynamoDB's answer is a \u003Cstrong>transaction\u003C/strong> — all the writes succeed together or none happen:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"k\">await\u003C/span> \u003Cspan class=\"nx\">ddb\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">send\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\n  \u003Cspan class=\"k\">new\u003C/span> \u003Cspan class=\"nc\">TransactWriteCommand\u003C/span>\u003Cspan class=\"p\">({\u003C/span>\n    \u003Cspan class=\"na\">TransactItems\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[\u003C/span>\n      \u003Cspan class=\"p\">{\u003C/span>\n        \u003Cspan class=\"na\">Put\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n          \u003Cspan class=\"nx\">TableName\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n          \u003Cspan class=\"na\">Item\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"nx\">movieItem\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n          \u003Cspan class=\"na\">ConditionExpression\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">attribute_not_exists(PK)\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n        \u003Cspan class=\"p\">},\u003C/span>\n      \u003Cspan class=\"p\">},\u003C/span>\n      \u003Cspan class=\"p\">...\u003C/span>\u003Cspan class=\"nx\">genreIds\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">map\u003C/span>\u003Cspan class=\"p\">((\u003C/span>\u003Cspan class=\"nx\">genreId\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"p\">({\u003C/span>\n        \u003Cspan class=\"na\">Put\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"nx\">TableName\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">Item\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"nf\">membershipItem\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">genreId\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"nx\">movieId\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n      \u003Cspan class=\"p\">})),\u003C/span>\n    \u003Cspan class=\"p\">],\u003C/span>\n  \u003Cspan class=\"p\">}),\u003C/span>\n\u003Cspan class=\"p\">);\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>I learned this the honest way: my first delete was a plain single-item delete, and my genre pages kept listing a movie I'd removed. Orphaned data in a NoSQL store doesn't announce itself — design every write path that touches duplicated data as a transaction.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"step-2-the-express-api\" href=\"#step-2-the-express-api\">\n  \u003C/a>\n  Step 2: The Express API\n\u003C/h2>\n\n\u003Cp>Nothing exotic here — routes, validation with Zod, and a repository layer so the DynamoDB details stay in one place:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"c1\">// apps/api/src/app.ts\u003C/span>\n\u003Cspan class=\"nx\">app\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">use\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nf\">helmet\u003C/span>\u003Cspan class=\"p\">());\u003C/span>\n\u003Cspan class=\"nx\">app\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">use\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nf\">cors\u003C/span>\u003Cspan class=\"p\">({\u003C/span> \u003Cspan class=\"na\">origin\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"nx\">config\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">corsOrigin\u003C/span> \u003Cspan class=\"p\">}));\u003C/span>\n\u003Cspan class=\"nx\">app\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">use\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">express\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">json\u003C/span>\u003Cspan class=\"p\">({\u003C/span> \u003Cspan class=\"na\">limit\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">100kb\u003C/span>\u003Cspan class=\"dl\">'\u003C/span> \u003Cspan class=\"p\">}));\u003C/span>\n\n\u003Cspan class=\"nx\">app\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">use\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">healthRouter\u003C/span>\u003Cspan class=\"p\">);\u003C/span> \u003Cspan class=\"c1\">// GET /health for the load balancer\u003C/span>\n\u003Cspan class=\"nx\">app\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">use\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">/api/movies\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"nx\">moviesRouter\u003C/span>\u003Cspan class=\"p\">);\u003C/span>\n\u003Cspan class=\"nx\">app\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">use\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">/api/directors\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"nx\">directorsRouter\u003C/span>\u003Cspan class=\"p\">);\u003C/span>\n\u003Cspan class=\"nx\">app\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">use\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">/api/genres\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"nx\">genresRouter\u003C/span>\u003Cspan class=\"p\">);\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>One detail worth copying: the API refuses to create a movie pointing at a director or genre that doesn't exist, returning \u003Ccode>422\u003C/code> instead of silently storing a broken reference. Relational databases give you that for free with foreign keys; in DynamoDB \u003Cem>you\u003C/em> are the foreign key.\u003C/p>\n\n\u003Cp>The magic part for beginners is what's \u003Cstrong>missing\u003C/strong>: there is no database password anywhere. Locally the API talks to DynamoDB Local (a fake DynamoDB in a container) with dummy credentials; in AWS, the SDK automatically picks up permissions from the IAM role attached to the running container. Same code, zero secrets, switched by one environment variable.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"step-3-the-vue-3-frontend\" href=\"#step-3-the-vue-3-frontend\">\n  \u003C/a>\n  Step 3: The Vue 3 frontend\n\u003C/h2>\n\n\u003Cp>Vite for the build, Pinia for state, Vue Router for pages. The store is small enough to read in one sitting:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"c1\">// apps/web/src/stores/movies.ts\u003C/span>\n\u003Cspan class=\"k\">export\u003C/span> \u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">useMoviesStore\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nf\">defineStore\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">movies\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"na\">state\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">()\u003C/span> \u003Cspan class=\"o\">=&gt;\u003C/span> \u003Cspan class=\"p\">({\u003C/span>\n    \u003Cspan class=\"na\">movies\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[],\u003C/span>\n    \u003Cspan class=\"na\">directors\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[],\u003C/span>\n    \u003Cspan class=\"na\">genres\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[],\u003C/span>\n    \u003Cspan class=\"na\">loading\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"kc\">false\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"na\">error\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"kc\">null\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n  \u003Cspan class=\"p\">}),\u003C/span>\n  \u003Cspan class=\"na\">actions\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n    \u003Cspan class=\"k\">async\u003C/span> \u003Cspan class=\"nf\">fetchMovies\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">search\u003C/span>\u003Cspan class=\"p\">?:\u003C/span> \u003Cspan class=\"kr\">string\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n      \u003Cspan class=\"k\">this\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">loading\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"kc\">true\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n      \u003Cspan class=\"k\">try\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n        \u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"nx\">items\u003C/span> \u003Cspan class=\"p\">}\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"k\">await\u003C/span> \u003Cspan class=\"nx\">api\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">listMovies\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">search\u003C/span>\u003Cspan class=\"p\">);\u003C/span>\n        \u003Cspan class=\"k\">this\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">movies\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nx\">items\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n      \u003Cspan class=\"p\">}\u003C/span> \u003Cspan class=\"k\">finally\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n        \u003Cspan class=\"k\">this\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">loading\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"kc\">false\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n      \u003Cspan class=\"p\">}\u003C/span>\n    \u003Cspan class=\"p\">},\u003C/span>\n    \u003Cspan class=\"k\">async\u003C/span> \u003Cspan class=\"nf\">addMovie\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">payload\u003C/span>\u003Cspan class=\"p\">)\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n      \u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">movie\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"k\">await\u003C/span> \u003Cspan class=\"nx\">api\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">createMovie\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"nx\">payload\u003C/span>\u003Cspan class=\"p\">);\u003C/span>\n      \u003Cspan class=\"k\">this\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">movies\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"p\">[\u003C/span>\u003Cspan class=\"nx\">movie\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"p\">...\u003C/span>\u003Cspan class=\"k\">this\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">movies\u003C/span>\u003Cspan class=\"p\">];\u003C/span>\n      \u003Cspan class=\"k\">return\u003C/span> \u003Cspan class=\"nx\">movie\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n    \u003Cspan class=\"p\">},\u003C/span>\n  \u003Cspan class=\"p\">},\u003C/span>\n\u003Cspan class=\"p\">});\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>The add-movie form loads directors and genres from the API so the dropdowns always reflect real data:\u003C/p>\n\n\u003Cp>\u003Ca href=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdqprljo8x34uldqcg4x.png\" class=\"article-body-image-wrapper\">\u003Cimg src=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdqprljo8x34uldqcg4x.png\" alt=\"The add-movie form filled in: title, release year, a director dropdown, genre checkboxes, synopsis and rating\" loading=\"lazy\" width=\"800\" height=\"500\">\u003C/a>\u003C/p>\n\n\u003Cp>And each card links to a detail page with a delete button:\u003C/p>\n\n\u003Cp>\u003Ca href=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hj5tvjia1hsy66xsg9v.png\" class=\"article-body-image-wrapper\">\u003Cimg src=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hj5tvjia1hsy66xsg9v.png\" alt=\"The Inception detail page showing director, genres, synopsis, rating, and Back/Delete buttons\" loading=\"lazy\" width=\"800\" height=\"500\">\u003C/a>\u003C/p>\n\n\u003Cp>The frontend calls the API with \u003Cem>relative\u003C/em> URLs (\u003Ccode>/api/movies\u003C/code>, no hostname). Hold that thought — it's why we won't have any CORS pain in production.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"step-4-put-both-apps-in-boxes\" href=\"#step-4-put-both-apps-in-boxes\">\n  \u003C/a>\n  Step 4: Put both apps in boxes\n\u003C/h2>\n\n\u003Cp>Each app gets a \u003Cstrong>multi-stage Dockerfile\u003C/strong>: a fat stage that builds, a slim stage that ships. The frontend one is my favorite because the final image contains no Node at all — just Nginx and a folder of static files:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight docker\">\u003Ccode>\u003Cspan class=\"c\"># apps/web/Dockerfile  -  build context is the REPO ROOT\u003C/span>\n\u003Cspan class=\"k\">FROM\u003C/span>\u003Cspan class=\"w\"> \u003C/span>\u003Cspan class=\"s\">node:20-alpine\u003C/span>\u003Cspan class=\"w\"> \u003C/span>\u003Cspan class=\"k\">AS\u003C/span>\u003Cspan class=\"w\"> \u003C/span>\u003Cspan class=\"s\">build\u003C/span>\n\u003Cspan class=\"k\">WORKDIR\u003C/span>\u003Cspan class=\"s\"> /repo\u003C/span>\n\u003Cspan class=\"k\">COPY\u003C/span>\u003Cspan class=\"s\"> package.json package-lock.json ./\u003C/span>\n\u003Cspan class=\"k\">COPY\u003C/span>\u003Cspan class=\"s\"> apps/web/package.json ./apps/web/\u003C/span>\n\u003Cspan class=\"k\">RUN \u003C/span>npm ci \u003Cspan class=\"nt\">--workspace\u003C/span> @movies/web \u003Cspan class=\"nt\">--include-workspace-root\u003C/span>\n\u003Cspan class=\"k\">COPY\u003C/span>\u003Cspan class=\"s\"> apps/web ./apps/web\u003C/span>\n\u003Cspan class=\"k\">RUN \u003C/span>npm run build \u003Cspan class=\"nt\">--workspace\u003C/span> @movies/web\n\n\u003Cspan class=\"k\">FROM\u003C/span>\u003Cspan class=\"w\"> \u003C/span>\u003Cspan class=\"s\">nginx:1.27-alpine\u003C/span>\u003Cspan class=\"w\"> \u003C/span>\u003Cspan class=\"k\">AS\u003C/span>\u003Cspan class=\"w\"> \u003C/span>\u003Cspan class=\"s\">runtime\u003C/span>\n\u003Cspan class=\"k\">COPY\u003C/span>\u003Cspan class=\"s\"> apps/web/nginx.conf /etc/nginx/conf.d/default.conf\u003C/span>\n\u003Cspan class=\"k\">COPY\u003C/span>\u003Cspan class=\"s\"> --from=build /repo/apps/web/dist /usr/share/nginx/html\u003C/span>\n\u003Cspan class=\"k\">EXPOSE\u003C/span>\u003Cspan class=\"s\"> 80\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>That \u003Ccode>WORKDIR /repo\u003C/code> is the scar tissue from an hour I would like back. My first version was the obvious one — build from inside \u003Ccode>apps/web\u003C/code>, \u003Ccode>COPY package*.json ./\u003C/code>, \u003Ccode>RUN npm ci\u003C/code> — and it died immediately:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight console\">\u003Ccode>\u003Cspan class=\"go\">npm error The `npm ci` command can only install with an existing package-lock.json\n\u003C/span>\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>The repo is an \u003Cstrong>npm workspaces\u003C/strong> monorepo, and workspaces deliberately keep \u003Cem>one\u003C/em> lockfile, at the root. Inside \u003Ccode>apps/web\u003C/code> there is a \u003Ccode>package.json\u003C/code> and nothing else, so \u003Ccode>npm ci\u003C/code> has nothing to install from. The fix is to build with the repo root as the Docker context, copy the root lockfile plus just that workspace's manifest, and scope the install with \u003Ccode>--workspace\u003C/code>. npm resolves the rest from the lockfile and does not mind that the sibling workspace folders are not in the image.\u003C/p>\n\n\u003Cp>If you take one thing from this section: \u003Cstrong>the Docker build context is a decision, not a detail.\u003C/strong> In a monorepo it is almost never the folder your app lives in.\u003C/p>\n\n\u003Cp>One line in \u003Ccode>nginx.conf\u003C/code> will save you a confused evening. A Vue app with routing owns URLs like \u003Ccode>/movies/abc123\u003C/code>, but no \u003Cem>file\u003C/em> with that name exists — so refreshing the page 404s unless Nginx falls back to \u003Ccode>index.html\u003C/code>:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight nginx\">\u003Ccode>\u003Cspan class=\"k\">location\u003C/span> \u003Cspan class=\"n\">/\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n    \u003Cspan class=\"kn\">try_files\u003C/span> \u003Cspan class=\"nv\">$uri\u003C/span> \u003Cspan class=\"nv\">$uri\u003C/span>\u003Cspan class=\"n\">/\u003C/span> \u003Cspan class=\"n\">/index.html\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"p\">}\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>The API image follows the same pattern, plus it runs as a non-root user and compiles TypeScript in the build stage. \u003Ccode>docker compose up --build\u003C/code> runs the entire stack — fake DynamoDB included — on your laptop.\u003C/p>\n\n\u003Cp>One asymmetry to know about: in AWS the load balancer routes \u003Ccode>/api/*\u003C/code> to the API before a request ever reaches Nginx, so the production \u003Ccode>nginx.conf\u003C/code> has no proxy block at all. Compose has no load balancer, so the frontend container would happily serve \u003Ccode>index.html\u003C/code> in response to \u003Ccode>/api/movies\u003C/code> and leave you staring at a page that renders but never loads data. The repo keeps a separate \u003Ccode>nginx.local.conf\u003C/code> with an \u003Ccode>/api\u003C/code> proxy and mounts it over the config in the compose file — same image, different routing, no production config polluted with a hostname that only exists on your laptop.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"step-5-the-actual-aws-part-its-200-lines\" href=\"#step-5-the-actual-aws-part-its-200-lines\">\n  \u003C/a>\n  Step 5: The actual AWS part (it's ~200 lines)\n\u003C/h2>\n\n\u003Cp>Everything AWS-side lives in one CDK file: \u003Ccode>infra/cdk/lib/movies-stack.ts\u003C/code>. The table:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"kd\">const\u003C/span> \u003Cspan class=\"nx\">table\u003C/span> \u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"k\">new\u003C/span> \u003Cspan class=\"nx\">dynamodb\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nc\">Table\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"k\">this\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">MoviesTable\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"na\">tableName\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">MoviesApp\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n  \u003Cspan class=\"na\">partitionKey\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">name\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">PK\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">type\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"nx\">dynamodb\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">AttributeType\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">STRING\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n  \u003Cspan class=\"na\">sortKey\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">name\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">SK\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"na\">type\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"nx\">dynamodb\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">AttributeType\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">STRING\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n  \u003Cspan class=\"na\">billingMode\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"nx\">dynamodb\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">BillingMode\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">PAY_PER_REQUEST\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"c1\">// pay per request, no capacity math\u003C/span>\n  \u003Cspan class=\"na\">removalPolicy\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"nx\">cdk\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">RemovalPolicy\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">RETAIN\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"c1\">// stack deleted ≠ data deleted\u003C/span>\n\u003Cspan class=\"p\">});\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>The permissions badge for the API container — and this is the part most tutorials get lazy about. Don't grant \u003Ccode>DynamoDBFullAccess\u003C/code>; list what the code does, against one table:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"nx\">apiTaskRole\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">addToPolicy\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\n  \u003Cspan class=\"k\">new\u003C/span> \u003Cspan class=\"nx\">iam\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nc\">PolicyStatement\u003C/span>\u003Cspan class=\"p\">({\u003C/span>\n    \u003Cspan class=\"na\">actions\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">dynamodb:GetItem\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">dynamodb:PutItem\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">dynamodb:UpdateItem\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">dynamodb:DeleteItem\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">dynamodb:Query\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">dynamodb:TransactWriteItems\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">dynamodb:BatchWriteItem\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n      \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">dynamodb:ConditionCheckItem\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"p\">],\u003C/span>\n    \u003Cspan class=\"na\">resources\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[\u003C/span>\u003Cspan class=\"nx\">table\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">tableArn\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"s2\">`\u003C/span>\u003Cspan class=\"p\">${\u003C/span>\u003Cspan class=\"nx\">table\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">tableArn\u003C/span>\u003Cspan class=\"p\">}\u003C/span>\u003Cspan class=\"s2\">/index/*`\u003C/span>\u003Cspan class=\"p\">],\u003C/span>\n  \u003Cspan class=\"p\">}),\u003C/span>\n\u003Cspan class=\"p\">);\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>Two beginner traps hiding in there, both of which got me:\u003C/p>\n\n\u003Col>\n\u003Cli>Querying a secondary index needs the \u003Ccode>/index/*\u003C/code> resource line. Without it: \u003Ccode>AccessDeniedException\u003C/code>, even though queries on the base table work fine.\u003C/li>\n\u003Cli>\n\u003Ccode>TransactWriteItems\u003C/code> is its own permission. \u003Ccode>PutItem\u003C/code> being allowed does not cover it.\u003C/li>\n\u003C/ol>\n\n\u003Cp>Then the load balancer, with the path-routing rule that makes the whole one-domain setup work:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"nx\">listener\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">addTargets\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">ApiTargets\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"na\">priority\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">10\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n  \u003Cspan class=\"na\">conditions\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[\u003C/span>\u003Cspan class=\"nx\">elbv2\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">ListenerCondition\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">pathPatterns\u003C/span>\u003Cspan class=\"p\">([\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">/api/*\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">/health\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">])],\u003C/span>\n  \u003Cspan class=\"na\">port\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"mi\">3000\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n  \u003Cspan class=\"na\">targets\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[\u003C/span>\u003Cspan class=\"nx\">apiService\u003C/span>\u003Cspan class=\"p\">],\u003C/span>\n  \u003Cspan class=\"na\">healthCheck\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">{\u003C/span> \u003Cspan class=\"na\">path\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">/health\u003C/span>\u003Cspan class=\"dl\">'\u003C/span> \u003Cspan class=\"p\">},\u003C/span>\n\u003Cspan class=\"p\">});\u003C/span>\n\u003Cspan class=\"c1\">// everything else falls through to the web container\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>Because frontend and API share one domain, those relative \u003Ccode>/api/...\u003C/code> calls from the Vue app just work. No CORS configuration, no API URL baked into the JavaScript bundle.\u003C/p>\n\n\u003Cp>And one two-line money-saver: containers in private subnets normally reach AWS services through a NAT gateway, which charges per GB. A \u003Cstrong>gateway endpoint\u003C/strong> routes DynamoDB traffic over AWS's internal network instead, for free:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"nx\">vpc\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nf\">addGatewayEndpoint\u003C/span>\u003Cspan class=\"p\">(\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">DynamoDbEndpoint\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n  \u003Cspan class=\"na\">service\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"nx\">ec2\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">GatewayVpcEndpointAwsService\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">DYNAMODB\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n\u003Cspan class=\"p\">});\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>Deploying is genuinely this:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight shell\">\u003Ccode>\u003Cspan class=\"nb\">cd \u003C/span>infra/cdk\nnpx cdk bootstrap        \u003Cspan class=\"c\"># once per AWS account+region\u003C/span>\n\u003Cspan class=\"nb\">cd\u003C/span> ../..\nnpm run cdk:deploy       \u003Cspan class=\"c\"># builds both images, pushes them, creates everything\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>Those two \u003Ccode>cd\u003C/code>s are load-bearing, and they cost me my third stupid twenty minutes of the project. Run \u003Ccode>npx cdk bootstrap\u003C/code> from the repo root and you get:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight plaintext\">\u003Ccode>Specify an environment name like 'aws://123456789012/ap-southeast-2',\nor run in a directory with 'cdk.json'.\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>\u003Ccode>cdk.json\u003C/code> is what tells the CLI how to run your app, and in this repo it lives in \u003Ccode>infra/cdk\u003C/code>, not at the root. Either stand in that directory, or name the target account and region explicitly, which works from anywhere:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight shell\">\u003Ccode>npx cdk bootstrap aws://&lt;account-id&gt;/ap-southeast-2\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>\u003Ccode>npm run cdk:deploy\u003C/code> needs no \u003Ccode>cd\u003C/code>, because the npm workspace script already runs inside \u003Ccode>infra/cdk\u003C/code>. That inconsistency — some commands care where you stand, some do not — is worth internalising early; it explains most \"but it worked yesterday\" moments in a monorepo.\u003C/p>\n\n\u003Cp>Ten-ish minutes later CDK prints \u003Ccode>AlbDnsName\u003C/code> — your app's public URL. The first time that worked I just stared at it.\u003C/p>\n\n\u003Cp>Then I opened it and the grid was empty. \u003Ccode>curl http://&lt;AlbDnsName&gt;/api/movies\u003C/code> returned \u003Ccode>{\"items\":[]}\u003C/code>. Nothing is wrong — \u003Cstrong>CDK creates the table empty and nothing seeds it.\u003C/strong> \u003Ccode>npm run db:seed\u003C/code> in the local flow only ever talked to DynamoDB Local. The seed script itself is endpoint-agnostic, so point it at the real table by clearing the local endpoint and giving it AWS credentials:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight shell\">\u003Ccode>\u003Cspan class=\"c\"># from the repo root, with AWS credentials active for the deploy account\u003C/span>\n\u003Cspan class=\"nv\">DYNAMODB_ENDPOINT\u003C/span>\u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nv\">AWS_REGION\u003C/span>\u003Cspan class=\"o\">=\u003C/span>ap-southeast-2 \u003Cspan class=\"nv\">TABLE_NAME\u003C/span>\u003Cspan class=\"o\">=\u003C/span>MoviesApp npm run db:seed\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>The explicit \u003Ccode>DYNAMODB_ENDPOINT=\u003C/code> matters — if you exported it for local dev, an unqualified \u003Ccode>npm run db:seed\u003C/code> will cheerfully seed your laptop's container instead of AWS. You don't need \u003Ccode>db:create-table\u003C/code>; CDK already made the table with both GSIs. (Or just POST directors, genres, then movies to \u003Ccode>http://&lt;AlbDnsName&gt;/api/...\u003C/code> with \u003Ccode>curl\u003C/code>.)\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"step-6-prove-it-works-automatically\" href=\"#step-6-prove-it-works-automatically\">\n  \u003C/a>\n  Step 6: Prove it works, automatically\n\u003C/h2>\n\n\u003Cp>The repo has two Playwright tests. One drives a real browser through the add-movie form and checks the new title appears in the grid. The other skips the browser and hits the API directly: create → read → update → search → delete → verify the 404s.\u003C/p>\n\n\u003Cp>Playwright can even start your servers for you:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"c1\">// tests/e2e/playwright.config.ts\u003C/span>\n\u003Cspan class=\"nx\">webServer\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[\u003C/span>\n  \u003Cspan class=\"p\">{\u003C/span>\n    \u003Cspan class=\"na\">command\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">npm run dev --workspace apps/api\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"na\">url\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">http://localhost:3000/health\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"na\">reuseExistingServer\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"kc\">true\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n  \u003Cspan class=\"p\">},\u003C/span>\n  \u003Cspan class=\"p\">{\u003C/span>\n    \u003Cspan class=\"na\">command\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">npm run dev --workspace apps/web\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"na\">url\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"s1\">http://localhost:5173\u003C/span>\u003Cspan class=\"dl\">'\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n    \u003Cspan class=\"na\">reuseExistingServer\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"kc\">true\u003C/span>\u003Cspan class=\"p\">,\u003C/span>\n  \u003Cspan class=\"p\">},\u003C/span>\n\u003Cspan class=\"p\">];\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>So the full flow is: \u003Ccode>npm run db:local\u003C/code>, \u003Ccode>npm run db:create-table\u003C/code>, then \u003Ccode>npm run test:e2e\u003C/code>. Both tests provision the reference data they need — the API test creates a throwaway director and genre in a helper, and the browser test does the same through the API in a \u003Ccode>beforeAll\u003C/code> hook before it ever opens a page — so \u003Ccode>npm run db:seed\u003C/code> is not a prerequisite for the suite. (It stays useful for populating a table you want to click around in.)\u003C/p>\n\n\u003Cp>\u003Ca href=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe7pkr2ik4fc6y67m6oc8.png\" class=\"article-body-image-wrapper\">\u003Cimg src=\"https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe7pkr2ik4fc6y67m6oc8.png\" alt=\"Playwright's HTML report showing both tests passing: the API CRUD lifecycle and the browser add-movie flow\" loading=\"lazy\" width=\"800\" height=\"475\">\u003C/a>\u003C/p>\n\n\u003Cp>One stumble worth passing on, because you \u003Cem>will\u003C/em> hit it: the Playwright config lives in \u003Ccode>tests/e2e/\u003C/code>, so running \u003Ccode>npx playwright test --project=api\u003C/code> from the repo root fails with the baffling \u003Ccode>Project(s) \"api\" not found. Available projects: \"\"\u003C/code>. Playwright found no config and fell back to an unnamed default project. The fix is to run it from \u003Ccode>tests/e2e/\u003C/code>, point at the config with \u003Ccode>-c tests/e2e/playwright.config.ts\u003C/code>, or use the root shortcuts the repo defines:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight shell\">\u003Ccode>npm run \u003Cspan class=\"nb\">test\u003C/span>:e2e:api       \u003Cspan class=\"c\"># just the API CRUD suite\u003C/span>\nnpm run \u003Cspan class=\"nb\">test\u003C/span>:e2e:browser   \u003Cspan class=\"c\"># just the browser E2E test\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>The API test earned its keep before the browser test even existed — it caught my update endpoint returning \u003Ccode>200\u003C/code> for movies that didn't exist, and a \u003Ccode>500\u003C/code> where a \u003Ccode>422\u003C/code> belonged. Write the API tests first; they're cheap and brutal.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"troubleshooting-every-error-i-hit-and-what-it-actually-meant\" href=\"#troubleshooting-every-error-i-hit-and-what-it-actually-meant\">\n  \u003C/a>\n  Troubleshooting: every error I hit, and what it actually meant\n\u003C/h2>\n\n\u003Cp>Every one of these cost me real time. If you're following along and see one of these messages, jump straight here.\u003C/p>\n\n\u003Ch3>\n  \u003Ca name=\"-raw-npm-error-the-npm-ci-command-can-only-install-with-an-existing-packagelockjson-endraw-\" href=\"#-raw-npm-error-the-npm-ci-command-can-only-install-with-an-existing-packagelockjson-endraw-\">\n  \u003C/a>\n  \u003Ccode>npm error The 'npm ci' command can only install with an existing package-lock.json\u003C/code>\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> building the frontend (or API) Docker image.\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> this repo is an npm \u003Cstrong>workspaces\u003C/strong> monorepo, so there's exactly one lockfile, at the repo root. Inside \u003Ccode>apps/web\u003C/code> there's only a \u003Ccode>package.json\u003C/code>.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> build with the \u003Cstrong>repo root\u003C/strong> as the Docker build context, copy the root \u003Ccode>package.json\u003C/code> + \u003Ccode>package-lock.json\u003C/code> plus just that workspace's manifest, then scope the install:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight docker\">\u003Ccode>\u003Cspan class=\"k\">COPY\u003C/span>\u003Cspan class=\"s\"> package.json package-lock.json ./\u003C/span>\n\u003Cspan class=\"k\">COPY\u003C/span>\u003Cspan class=\"s\"> apps/web/package.json ./apps/web/\u003C/span>\n\u003Cspan class=\"k\">RUN \u003C/span>npm ci \u003Cspan class=\"nt\">--workspace\u003C/span> @movies/web \u003Cspan class=\"nt\">--include-workspace-root\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Ch3>\n  \u003Ca name=\"refreshing-raw-moviesabc123-endraw-returns-404-from-nginx\" href=\"#refreshing-raw-moviesabc123-endraw-returns-404-from-nginx\">\n  \u003C/a>\n  Refreshing \u003Ccode>/movies/abc123\u003C/code> returns 404 from Nginx\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> you deep-link or hit reload on any client-side route.\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> Vue Router owns that URL, but no \u003Cem>file\u003C/em> by that name exists in the image.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> fall back to \u003Ccode>index.html\u003C/code> in \u003Ccode>nginx.conf\u003C/code>:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight nginx\">\u003Ccode>\u003Cspan class=\"k\">location\u003C/span> \u003Cspan class=\"n\">/\u003C/span> \u003Cspan class=\"p\">{\u003C/span>\n    \u003Cspan class=\"kn\">try_files\u003C/span> \u003Cspan class=\"nv\">$uri\u003C/span> \u003Cspan class=\"nv\">$uri\u003C/span>\u003Cspan class=\"n\">/\u003C/span> \u003Cspan class=\"n\">/index.html\u003C/span>\u003Cspan class=\"p\">;\u003C/span>\n\u003Cspan class=\"p\">}\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Ch3>\n  \u003Ca name=\"the-page-renders-under-raw-docker-compose-endraw-but-never-loads-any-data\" href=\"#the-page-renders-under-raw-docker-compose-endraw-but-never-loads-any-data\">\n  \u003C/a>\n  The page renders under \u003Ccode>docker compose\u003C/code> but never loads any data\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> local development with \u003Ccode>docker compose up\u003C/code>, not in AWS.\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> in AWS the ALB routes \u003Ccode>/api/*\u003C/code> to the API before Nginx sees it. Compose has no load balancer, so the frontend container serves \u003Ccode>index.html\u003C/code> in response to \u003Ccode>/api/movies\u003C/code>.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> keep a separate \u003Ccode>nginx.local.conf\u003C/code> with an \u003Ccode>/api\u003C/code> proxy block and mount it over the production config in \u003Ccode>docker-compose.yml\u003C/code>. The production \u003Ccode>nginx.conf\u003C/code> stays proxy-free on purpose.\u003C/p>\n\u003Ch3>\n  \u003Ca name=\"-raw-accessdeniedexception-endraw-on-dynamodb-queries-even-though-basetable-reads-and-writes-work\" href=\"#-raw-accessdeniedexception-endraw-on-dynamodb-queries-even-though-basetable-reads-and-writes-work\">\n  \u003C/a>\n  \u003Ccode>AccessDeniedException\u003C/code> on DynamoDB queries, even though base-table reads and writes work\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> the first query that hits the \u003Ccode>GSI1\u003C/code> secondary index (browse A→Z, search).\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> querying an index needs the index ARN listed separately in the IAM policy.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> add the \u003Ccode>/index/*\u003C/code> resource:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight typescript\">\u003Ccode>\u003Cspan class=\"nx\">resources\u003C/span>\u003Cspan class=\"p\">:\u003C/span> \u003Cspan class=\"p\">[\u003C/span>\u003Cspan class=\"nx\">table\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">tableArn\u003C/span>\u003Cspan class=\"p\">,\u003C/span> \u003Cspan class=\"s2\">`\u003C/span>\u003Cspan class=\"p\">${\u003C/span>\u003Cspan class=\"nx\">table\u003C/span>\u003Cspan class=\"p\">.\u003C/span>\u003Cspan class=\"nx\">tableArn\u003C/span>\u003Cspan class=\"p\">}\u003C/span>\u003Cspan class=\"s2\">/index/*`\u003C/span>\u003Cspan class=\"p\">];\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Ch3>\n  \u003Ca name=\"-raw-accessdeniedexception-endraw-when-creating-a-movie-but-plain-raw-putitem-endraw-calls-succeed\" href=\"#-raw-accessdeniedexception-endraw-when-creating-a-movie-but-plain-raw-putitem-endraw-calls-succeed\">\n  \u003C/a>\n  \u003Ccode>AccessDeniedException\u003C/code> when creating a movie, but plain \u003Ccode>PutItem\u003C/code> calls succeed\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> the multi-item transactional write that adds a movie plus its genre memberships.\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> \u003Ccode>dynamodb:TransactWriteItems\u003C/code> is its own IAM action — \u003Ccode>PutItem\u003C/code> being allowed does not cover it.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> add \u003Ccode>TransactWriteItems\u003C/code> (and \u003Ccode>ConditionCheckItem\u003C/code>) to the policy \u003Ccode>actions\u003C/code> list.\u003C/p>\n\u003Ch3>\n  \u003Ca name=\"deleting-a-movie-leaves-it-showing-on-genre-pages\" href=\"#deleting-a-movie-leaves-it-showing-on-genre-pages\">\n  \u003C/a>\n  Deleting a movie leaves it showing on genre pages\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> after a delete, \"movies in this genre\" still lists the removed movie.\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> a plain single-item delete removes \u003Ccode>PK=MOVIE#123, SK=METADATA\u003C/code> but not the \u003Ccode>PK=GENRE#789, SK=MOVIE#123\u003C/code> membership items written alongside it.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> every write path that touches duplicated data — create \u003Cem>and\u003C/em> delete — must be a \u003Ccode>TransactWriteCommand\u003C/code>.\u003C/p>\n\u003Ch3>\n  \u003Ca name=\"-raw-specify-an-environment-name-like-aws123456789012apsoutheast2-or-run-in-a-directory-with-cdkjson-endraw-\" href=\"#-raw-specify-an-environment-name-like-aws123456789012apsoutheast2-or-run-in-a-directory-with-cdkjson-endraw-\">\n  \u003C/a>\n  \u003Ccode>Specify an environment name like 'aws://123456789012/ap-southeast-2', or run in a directory with 'cdk.json'.\u003C/code>\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> running \u003Ccode>npx cdk bootstrap\u003C/code> from the repo root.\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> \u003Ccode>cdk.json\u003C/code> lives in \u003Ccode>infra/cdk\u003C/code>, not at the root.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> \u003Ccode>cd infra/cdk\u003C/code> first, or name the target explicitly (works from anywhere):\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight shell\">\u003Ccode>npx cdk bootstrap aws://&lt;account-id&gt;/ap-southeast-2\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>\u003Ccode>npm run cdk:deploy\u003C/code> needs no \u003Ccode>cd\u003C/code> — the workspace script already runs inside \u003Ccode>infra/cdk\u003C/code>.\u003C/p>\n\n\u003Ch3>\n  \u003Ca name=\"-raw-projects-api-not-found-available-projects-endraw-\" href=\"#-raw-projects-api-not-found-available-projects-endraw-\">\n  \u003C/a>\n  \u003Ccode>Project(s) \"api\" not found. Available projects: \"\"\u003C/code>\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> running \u003Ccode>npx playwright test --project=api\u003C/code> from the repo root.\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> the Playwright config is in \u003Ccode>tests/e2e/\u003C/code>. From the root, Playwright finds no config and falls back to a single unnamed project.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> run it from \u003Ccode>tests/e2e/\u003C/code>, pass \u003Ccode>-c tests/e2e/playwright.config.ts\u003C/code>, or use the root shortcuts:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight shell\">\u003Ccode>npm run \u003Cspan class=\"nb\">test\u003C/span>:e2e:api       \u003Cspan class=\"c\"># just the API CRUD suite\u003C/span>\nnpm run \u003Cspan class=\"nb\">test\u003C/span>:e2e:browser   \u003Cspan class=\"c\"># just the browser E2E test\u003C/span>\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Ch3>\n  \u003Ca name=\"browser-test-raw-expectlocatortobeattached-failed-endraw-on-raw-getbytestiddirectorselectlocatoroptionnth1-endraw-\" href=\"#browser-test-raw-expectlocatortobeattached-failed-endraw-on-raw-getbytestiddirectorselectlocatoroptionnth1-endraw-\">\n  \u003C/a>\n  Browser test: \u003Ccode>expect(locator).toBeAttached() failed\u003C/code> on \u003Ccode>getByTestId('director-select').locator('option').nth(1)\u003C/code>\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> \u003Ccode>npm run test:e2e:browser\u003C/code> against a database with no directors/genres in it (fresh table, or you forgot \u003Ccode>npm run db:seed\u003C/code>).\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> the add-movie form builds its director dropdown from the API. With no directors, the only \u003Ccode>&lt;option&gt;\u003C/code> is the placeholder, so \u003Ccode>option\u003C/code> index 1 never appears and the test times out waiting for it.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> don't make the test depend on seed data. The browser spec now provisions its own director and genre through the API in a \u003Ccode>beforeAll\u003C/code> hook (and removes them in \u003Ccode>afterAll\u003C/code>), so it passes on an empty table. DynamoDB Local still has to be running and the table created.\u003C/p>\n\u003Ch3>\n  \u003Ca name=\"the-deployed-app-returns-raw-items-endraw-raw-curl-httpltalbdnsnamegtapimovies-endraw-is-empty\" href=\"#the-deployed-app-returns-raw-items-endraw-raw-curl-httpltalbdnsnamegtapimovies-endraw-is-empty\">\n  \u003C/a>\n  The deployed app returns \u003Ccode>{\"items\":[]}\u003C/code> — \u003Ccode>curl http://&lt;AlbDnsName&gt;/api/movies\u003C/code> is empty\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> right after a successful \u003Ccode>npm run cdk:deploy\u003C/code>.\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> CDK provisions the DynamoDB table empty. There is no seed step in the deploy — \u003Ccode>npm run db:seed\u003C/code> in the walkthrough only ever wrote to DynamoDB Local.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> run the seed script against the real table, clearing the local endpoint so it doesn't hit your laptop:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight shell\">\u003Ccode>\u003Cspan class=\"nv\">DYNAMODB_ENDPOINT\u003C/span>\u003Cspan class=\"o\">=\u003C/span> \u003Cspan class=\"nv\">AWS_REGION\u003C/span>\u003Cspan class=\"o\">=\u003C/span>ap-southeast-2 \u003Cspan class=\"nv\">TABLE_NAME\u003C/span>\u003Cspan class=\"o\">=\u003C/span>MoviesApp npm run db:seed\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>Needs AWS credentials with write access to the \u003Ccode>MoviesApp\u003C/code> table. \u003Ccode>db:create-table\u003C/code> is not needed — CDK already created it.\u003C/p>\n\n\u003Ch3>\n  \u003Ca name=\"the-update-endpoint-returns-raw-200-endraw-for-a-movie-that-doesnt-exist-or-raw-500-endraw-instead-of-raw-422-endraw-\" href=\"#the-update-endpoint-returns-raw-200-endraw-for-a-movie-that-doesnt-exist-or-raw-500-endraw-instead-of-raw-422-endraw-\">\n  \u003C/a>\n  The update endpoint returns \u003Ccode>200\u003C/code> for a movie that doesn't exist (or \u003Ccode>500\u003C/code> instead of \u003Ccode>422\u003C/code>)\n\u003C/h3>\n\n\u003Cp>\u003Cstrong>When:\u003C/strong> caught by the API CRUD test, not by clicking around.\u003Cbr>\n\u003Cstrong>Why:\u003C/strong> missing existence checks and error mapping in the route handler.\u003Cbr>\n\u003Cstrong>Fix:\u003C/strong> return \u003Ccode>404\u003C/code> when the target isn't found, and \u003Ccode>422\u003C/code> when a referenced director or genre doesn't exist. In DynamoDB \u003Cem>you\u003C/em> are the foreign key — enforce it in code.\u003C/p>\n\u003Ch2>\n  \u003Ca name=\"what-this-costs-and-how-to-turn-it-off\" href=\"#what-this-costs-and-how-to-turn-it-off\">\n  \u003C/a>\n  What this costs, and how to turn it off\n\u003C/h2>\n\n\u003Cp>Real talk for beginners: this stack is \u003Cstrong>not\u003C/strong> free-tier. The always-on pieces — two-ish Fargate tasks per service, the ALB, and one NAT gateway — land somewhere around US$70–100/month if you leave it running. The NAT gateway alone is about a dollar a day.\u003C/p>\n\n\u003Cp>For a learning project, deploy it, poke at it, show a friend, then:\u003Cbr>\n\u003C/p>\n\n\u003Cdiv class=\"highlight js-code-highlight\">\n\u003Cpre class=\"highlight shell\">\u003Ccode>npm run cdk:destroy\n\u003C/code>\u003C/pre>\n\u003Cdiv class=\"highlight__panel js-actions-panel\">\n\u003Cdiv class=\"highlight__panel-action js-fullscreen-code-action\">\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-on\">\u003Ctitle>Enter fullscreen mode\u003C/title>\n    \u003Cpath d=\"M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z\">\u003C/path>\n\u003C/svg>\n\n    \u003Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"20px\" height=\"20px\" viewbox=\"0 0 24 24\" class=\"highlight-action crayons-icon highlight-action--fullscreen-off\">\u003Ctitle>Exit fullscreen mode\u003C/title>\n    \u003Cpath d=\"M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z\">\u003C/path>\n\u003C/svg>\n\n\u003C/div>\n\u003C/div>\n\u003C/div>\n\n\n\n\u003Cp>Everything disappears except the DynamoDB table (we set \u003Ccode>RETAIN\u003C/code> on purpose, so a fat-fingered destroy can't eat your data — delete the table manually in the console when you're truly done). Develop locally with docker compose the rest of the time; DynamoDB Local costs nothing.\u003C/p>\n\n\u003Ch2>\n  \u003Ca name=\"what-id-tell-you-if-youre-where-i-was\" href=\"#what-id-tell-you-if-youre-where-i-was\">\n  \u003C/a>\n  What I'd tell you if you're where I was\n\u003C/h2>\n\n\u003Cp>Design the DynamoDB keys from your access patterns before writing a line of app code — retrofitting is misery. Give your containers the narrowest IAM badge that runs, and expect the \u003Ccode>/index/*\u003C/code> and \u003Ccode>TransactWriteItems\u003C/code> traps. Put frontend and API behind one load balancer and let paths do the routing; your future self will never debug CORS at midnight. And write the boring API tests first.\u003C/p>\n\n\u003Cp>Also: half the time I lost on this project had nothing to do with AWS. It was \u003Ccode>npm ci\u003C/code> in the wrong directory, a config file one folder up from where I was standing, an Nginx route that only exists in production. The cloud parts are documented and predictable. The plumbing between your own folders is the part nobody writes down — so write it down.\u003C/p>\n\n\u003Cp>Mostly, though: pick a small thing you want to exist and build it to a URL. The AWS nouns stop being scary about two hours in.\u003C/p>\n\n\u003Cp>The full repo — API, frontend, CDK stack, tests, screenshots, all of it — is an npm-workspaces monorepo, one \u003Ccode>npm install\u003C/code> from running on your machine. Happy shipping.\u003C/p>\n\n","A beginner-friendly walkthrough of building a movies app with Vue 3 and Express, storing data in DynamoDB, and deploying it to ECS Fargate — with the actual code, screenshots, and every mistake included.\n\nWhen I started this project I could click around the AWS console without breaking anything, and that was about it. Words like \"Fargate\", \"task definition\" and \"single-table design\" made my eyes glaze over. So I did the thing everyone tells you to do and nobody does: I picked one small app I actually wanted to exist — a movies catalog — and built it all the way to a public URL on AWS.\n\nThis post is the walkthrough I wish I'd had. No assumed knowledge beyond \"I've written some JavaScript and I know what an API is.\" We're building:\n\n- A **Vue 3** frontend where you can browse, search, and add movies\n- An **Express** REST API that stores everything in **DynamoDB**\n- Two **Docker** containers running on **ECS Fargate** behind a load balancer\n- All of it defined in code with **AWS CDK**, and tested with **Playwright**\n\nHere's what the finished thing looks like:\n\n![The browse view: a dark-themed grid of movie cards showing title, year, director, genre tags and a star rating](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/k74exyognuoo53ncjref.png)\n\nThe full source is on GitHub: **[fargate-movies-api\n](https://github.com/kasukur/fargate-movies-api)**\n\n## Table of contents\n\n- [First, a plain-English map of the AWS pieces](#first-a-plain-english-map-of-the-aws-pieces)\n- [Step 1: Design the database before writing any code](#step-1-design-the-database-before-writing-any-code)\n  - [The \"a movie has many genres\" problem](#the-a-movie-has-many-genres-problem)\n- [Step 2: The Express API](#step-2-the-express-api)\n- [Step 3: The Vue 3 frontend](#step-3-the-vue-3-frontend)\n- [Step 4: Put both apps in boxes](#step-4-put-both-apps-in-boxes)\n- [Step 5: The actual AWS part (it's ~200 lines)](#step-5-the-actual-aws-part-its-200-lines)\n- [Step 6: Prove it works, automatically](#step-6-prove-it-works-automatically)\n- [Troubleshooting: every error I hit, and what it actually meant](#troubleshooting-every-error-i-hit-and-what-it-actually-meant)\n- [What this costs, and how to turn it off](#what-this-costs-and-how-to-turn-it-off)\n- [What I'd tell you if you're where I was](#what-id-tell-you-if-youre-where-i-was)\n\n## First, a plain-English map of the AWS pieces\n\nBefore any code, here's every AWS term in this post, translated:\n\n- **DynamoDB** — a database you never install, patch, or resize. You create a _table_, and AWS scales it for you. You pay per read/write. There's no SQL; you fetch items by their keys.\n- **A container** — your app plus everything it needs (Node, your dependencies, your code) zipped into one runnable box. If it runs on your laptop, it runs the same way in the cloud. Docker is the tool that builds these boxes.\n- **ECS** (Elastic Container Service) — AWS's system for running containers and restarting them when they crash.\n- **Fargate** — the \"no servers, please\" mode of ECS. You say \"run 2 copies of this container with 0.25 CPU and 512 MB of memory\" and AWS finds the machines. You never SSH into anything, because there's nothing to SSH into.\n- **ALB** (Application Load Balancer) — the front door. It has a public web address, receives all traffic, and forwards each request to the right container based on the URL path.\n- **IAM role** — a badge you pin on your running container that says what AWS things it may do. Our API's badge says \"may read and write one specific DynamoDB table\" and nothing else.\n- **CDK** (Cloud Development Kit) — instead of clicking through the console (and forgetting what you clicked), you describe your infrastructure in TypeScript and CDK creates it. Your whole cloud setup becomes a file you can read, review, and re-run.\n\nThe architecture, in one diagram:\n\n```plaintext\n                       ┌─────────────────────────────────────────┐\n                       │                 AWS VPC                  │\nInternet ──► ALB ──────┤  /api/* ──► Fargate: Express API ────────┼──► DynamoDB\n                       │  /*     ──► Fargate: Nginx + Vue files   │\n                       └─────────────────────────────────────────┘\n```\n\nOne front door, two containers, one table. That's the whole app.\n\n## Step 1: Design the database before writing any code\n\nThis was the biggest mindset shift. In a SQL world I'd make three tables — `movies`, `directors`, `genres` — and JOIN them. DynamoDB has no JOINs. The DynamoDB way is to ask, **\"what questions will my app ask?\"** and design the keys so each question is a single lookup.\n\nMy questions were: get a movie by id, browse all movies A→Z, search by title, list a director's films, list a genre's films.\n\nEvery item in DynamoDB has a **partition key** (`PK`, which bucket it lives in) and a **sort key** (`SK`, its position in that bucket). All three of my resource types share _one_ table, told apart by prefixes in the keys:\n\n```plaintext\nMovie      PK=MOVIE#123      SK=METADATA\nDirector   PK=DIRECTOR#456   SK=METADATA\nGenre      PK=GENRE#789      SK=METADATA\n```\n\n\"Browse all movies sorted by title\" can't be answered by those keys, so the table gets a **secondary index** — think of it as the same data automatically re-sorted a second way:\n\n```plaintext\nGSI1PK=ENTITY#MOVIE   GSI1SK=TITLE#inception\n```\n\nNow \"all movies A→Z\" is one query against `ENTITY#MOVIE`, and the search box is the same query plus a prefix match:\n\n```typescript\n// apps/api/src/repositories/movieRepository.ts\nconst result = await ddb.send(\n  new QueryCommand({\n    TableName: TABLE_NAME,\n    IndexName: 'GSI1',\n    KeyConditionExpression: 'GSI1PK = :pk AND begins_with(GSI1SK, :prefix)',\n    ExpressionAttributeValues: {\n      ':pk': 'ENTITY#MOVIE',\n      ':prefix': `TITLE#${prefix.toLowerCase()}`,\n    },\n  }),\n);\n```\n\nThat one query powers this:\n\n![Typing \"in\" into the search box instantly filters the grid down to Inception](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/98geekiod2rb2ymbxp77.png)\n\n### The \"a movie has many genres\" problem\n\nNo JOINs, remember? The trick is to write a tiny extra item per genre when a movie is created — `PK=GENRE#789, SK=MOVIE#123` — so \"movies in this genre\" becomes a simple query on the genre's bucket.\n\nBut now one \"add movie\" writes several items, and if the process dies halfway you get a movie that's missing from half its genres. DynamoDB's answer is a **transaction** — all the writes succeed together or none happen:\n\n```typescript\nawait ddb.send(\n  new TransactWriteCommand({\n    TransactItems: [\n      {\n        Put: {\n          TableName,\n          Item: movieItem,\n          ConditionExpression: 'attribute_not_exists(PK)',\n        },\n      },\n      ...genreIds.map((genreId) => ({\n        Put: { TableName, Item: membershipItem(genreId, movieId) },\n      })),\n    ],\n  }),\n);\n```\n\nI learned this the honest way: my first delete was a plain single-item delete, and my genre pages kept listing a movie I'd removed. Orphaned data in a NoSQL store doesn't announce itself — design every write path that touches duplicated data as a transaction.\n\n## Step 2: The Express API\n\nNothing exotic here — routes, validation with Zod, and a repository layer so the DynamoDB details stay in one place:\n\n```typescript\n// apps/api/src/app.ts\napp.use(helmet());\napp.use(cors({ origin: config.corsOrigin }));\napp.use(express.json({ limit: '100kb' }));\n\napp.use(healthRouter); // GET /health for the load balancer\napp.use('/api/movies', moviesRouter);\napp.use('/api/directors', directorsRouter);\napp.use('/api/genres', genresRouter);\n```\n\nOne detail worth copying: the API refuses to create a movie pointing at a director or genre that doesn't exist, returning `422` instead of silently storing a broken reference. Relational databases give you that for free with foreign keys; in DynamoDB _you_ are the foreign key.\n\nThe magic part for beginners is what's **missing**: there is no database password anywhere. Locally the API talks to DynamoDB Local (a fake DynamoDB in a container) with dummy credentials; in AWS, the SDK automatically picks up permissions from the IAM role attached to the running container. Same code, zero secrets, switched by one environment variable.\n\n## Step 3: The Vue 3 frontend\n\nVite for the build, Pinia for state, Vue Router for pages. The store is small enough to read in one sitting:\n\n```typescript\n// apps/web/src/stores/movies.ts\nexport const useMoviesStore = defineStore('movies', {\n  state: () => ({\n    movies: [],\n    directors: [],\n    genres: [],\n    loading: false,\n    error: null,\n  }),\n  actions: {\n    async fetchMovies(search?: string) {\n      this.loading = true;\n      try {\n        const { items } = await api.listMovies(search);\n        this.movies = items;\n      } finally {\n        this.loading = false;\n      }\n    },\n    async addMovie(payload) {\n      const movie = await api.createMovie(payload);\n      this.movies = [movie, ...this.movies];\n      return movie;\n    },\n  },\n});\n```\n\nThe add-movie form loads directors and genres from the API so the dropdowns always reflect real data:\n\n![The add-movie form filled in: title, release year, a director dropdown, genre checkboxes, synopsis and rating](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/kpa4x2kaytyocovgzov9.png)\n\nAnd each card links to a detail page with a delete button:\n\n![The Inception detail page showing director, genres, synopsis, rating, and Back/Delete buttons](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/2ej982bcs1zqju8203k9.png)\n\nThe frontend calls the API with _relative_ URLs (`/api/movies`, no hostname). Hold that thought — it's why we won't have any CORS pain in production.\n\n## Step 4: Put both apps in boxes\n\nEach app gets a **multi-stage Dockerfile**: a fat stage that builds, a slim stage that ships. The frontend one is my favorite because the final image contains no Node at all — just Nginx and a folder of static files:\n\n```dockerfile\n# apps/web/Dockerfile  -  build context is the REPO ROOT\nFROM node:20-alpine AS build\nWORKDIR /repo\nCOPY package.json package-lock.json ./\nCOPY apps/web/package.json ./apps/web/\nRUN npm ci --workspace @movies/web --include-workspace-root\nCOPY apps/web ./apps/web\nRUN npm run build --workspace @movies/web\n\nFROM nginx:1.27-alpine AS runtime\nCOPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf\nCOPY --from=build /repo/apps/web/dist /usr/share/nginx/html\nEXPOSE 80\n```\n\nThat `WORKDIR /repo` is the scar tissue from an hour I would like back. My first version was the obvious one — build from inside `apps/web`, `COPY package*.json ./`, `RUN npm ci` — and it died immediately:\n\n```console\nnpm error The `npm ci` command can only install with an existing package-lock.json\n```\n\nThe repo is an **npm workspaces** monorepo, and workspaces deliberately keep _one_ lockfile, at the root. Inside `apps/web` there is a `package.json` and nothing else, so `npm ci` has nothing to install from. The fix is to build with the repo root as the Docker context, copy the root lockfile plus just that workspace's manifest, and scope the install with `--workspace`. npm resolves the rest from the lockfile and does not mind that the sibling workspace folders are not in the image.\n\nIf you take one thing from this section: **the Docker build context is a decision, not a detail.** In a monorepo it is almost never the folder your app lives in.\n\nOne line in `nginx.conf` will save you a confused evening. A Vue app with routing owns URLs like `/movies/abc123`, but no _file_ with that name exists — so refreshing the page 404s unless Nginx falls back to `index.html`:\n\n```nginx\nlocation / {\n    try_files $uri $uri/ /index.html;\n}\n```\n\nThe API image follows the same pattern, plus it runs as a non-root user and compiles TypeScript in the build stage. `docker compose up --build` runs the entire stack — fake DynamoDB included — on your laptop.\n\nOne asymmetry to know about: in AWS the load balancer routes `/api/*` to the API before a request ever reaches Nginx, so the production `nginx.conf` has no proxy block at all. Compose has no load balancer, so the frontend container would happily serve `index.html` in response to `/api/movies` and leave you staring at a page that renders but never loads data. The repo keeps a separate `nginx.local.conf` with an `/api` proxy and mounts it over the config in the compose file — same image, different routing, no production config polluted with a hostname that only exists on your laptop.\n\n## Step 5: The actual AWS part (it's ~200 lines)\n\nEverything AWS-side lives in one CDK file: `infra/cdk/lib/movies-stack.ts`. The table:\n\n```typescript\nconst table = new dynamodb.Table(this, 'MoviesTable', {\n  tableName: 'MoviesApp',\n  partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },\n  sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },\n  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, // pay per request, no capacity math\n  removalPolicy: cdk.RemovalPolicy.RETAIN, // stack deleted ≠ data deleted\n});\n```\n\nThe permissions badge for the API container — and this is the part most tutorials get lazy about. Don't grant `DynamoDBFullAccess`; list what the code does, against one table:\n\n```typescript\napiTaskRole.addToPolicy(\n  new iam.PolicyStatement({\n    actions: [\n      'dynamodb:GetItem',\n      'dynamodb:PutItem',\n      'dynamodb:UpdateItem',\n      'dynamodb:DeleteItem',\n      'dynamodb:Query',\n      'dynamodb:TransactWriteItems',\n      'dynamodb:BatchWriteItem',\n      'dynamodb:ConditionCheckItem',\n    ],\n    resources: [table.tableArn, `${table.tableArn}/index/*`],\n  }),\n);\n```\n\nTwo beginner traps hiding in there, both of which got me:\n\n1. Querying a secondary index needs the `/index/*` resource line. Without it: `AccessDeniedException`, even though queries on the base table work fine.\n2. `TransactWriteItems` is its own permission. `PutItem` being allowed does not cover it.\n\nThen the load balancer, with the path-routing rule that makes the whole one-domain setup work:\n\n```typescript\nlistener.addTargets('ApiTargets', {\n  priority: 10,\n  conditions: [elbv2.ListenerCondition.pathPatterns(['/api/*', '/health'])],\n  port: 3000,\n  targets: [apiService],\n  healthCheck: { path: '/health' },\n});\n// everything else falls through to the web container\n```\n\nBecause frontend and API share one domain, those relative `/api/...` calls from the Vue app just work. No CORS configuration, no API URL baked into the JavaScript bundle.\n\nAnd one two-line money-saver: containers in private subnets normally reach AWS services through a NAT gateway, which charges per GB. A **gateway endpoint** routes DynamoDB traffic over AWS's internal network instead, for free:\n\n```typescript\nvpc.addGatewayEndpoint('DynamoDbEndpoint', {\n  service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,\n});\n```\n\nDeploying is genuinely this:\n\n```bash\ncd infra/cdk\nnpx cdk bootstrap        # once per AWS account+region\ncd ../..\nnpm run cdk:deploy       # builds both images, pushes them, creates everything\n```\n\nThose two `cd`s are load-bearing, and they cost me my third stupid twenty minutes of the project. Run `npx cdk bootstrap` from the repo root and you get:\n\n```plaintext\nSpecify an environment name like 'aws://123456789012/ap-southeast-2',\nor run in a directory with 'cdk.json'.\n```\n\n`cdk.json` is what tells the CLI how to run your app, and in this repo it lives in `infra/cdk`, not at the root. Either stand in that directory, or name the target account and region explicitly, which works from anywhere:\n\n```bash\nnpx cdk bootstrap aws://\u003Caccount-id>/ap-southeast-2\n```\n\n`npm run cdk:deploy` needs no `cd`, because the npm workspace script already runs inside `infra/cdk`. That inconsistency — some commands care where you stand, some do not — is worth internalising early; it explains most \"but it worked yesterday\" moments in a monorepo.\n\nTen-ish minutes later CDK prints `AlbDnsName` — your app's public URL. The first time that worked I just stared at it.\n\nThen I opened it and the grid was empty. `curl http://\u003CAlbDnsName>/api/movies` returned `{\"items\":[]}`. Nothing is wrong — **CDK creates the table empty and nothing seeds it.** `npm run db:seed` in the local flow only ever talked to DynamoDB Local. The seed script itself is endpoint-agnostic, so point it at the real table by clearing the local endpoint and giving it AWS credentials:\n\n```bash\n# from the repo root, with AWS credentials active for the deploy account\nDYNAMODB_ENDPOINT= AWS_REGION=ap-southeast-2 TABLE_NAME=MoviesApp npm run db:seed\n```\n\nThe explicit `DYNAMODB_ENDPOINT=` matters — if you exported it for local dev, an unqualified `npm run db:seed` will cheerfully seed your laptop's container instead of AWS. You don't need `db:create-table`; CDK already made the table with both GSIs. (Or just POST directors, genres, then movies to `http://\u003CAlbDnsName>/api/...` with `curl`.)\n\n## Step 6: Prove it works, automatically\n\nThe repo has two Playwright tests. One drives a real browser through the add-movie form and checks the new title appears in the grid. The other skips the browser and hits the API directly: create → read → update → search → delete → verify the 404s.\n\nPlaywright can even start your servers for you:\n\n```typescript\n// tests/e2e/playwright.config.ts\nwebServer: [\n  {\n    command: 'npm run dev --workspace apps/api',\n    url: 'http://localhost:3000/health',\n    reuseExistingServer: true,\n  },\n  {\n    command: 'npm run dev --workspace apps/web',\n    url: 'http://localhost:5173',\n    reuseExistingServer: true,\n  },\n];\n```\n\nSo the full flow is: `npm run db:local`, `npm run db:create-table`, then `npm run test:e2e`. Both tests provision the reference data they need — the API test creates a throwaway director and genre in a helper, and the browser test does the same through the API in a `beforeAll` hook before it ever opens a page — so `npm run db:seed` is not a prerequisite for the suite. (It stays useful for populating a table you want to click around in.)\n\n![Playwright's HTML report showing both tests passing: the API CRUD lifecycle and the browser add-movie flow](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/mjzzh9qf4lwpxsqruxw8.png)\n\nOne stumble worth passing on, because you _will_ hit it: the Playwright config lives in `tests/e2e/`, so running `npx playwright test --project=api` from the repo root fails with the baffling `Project(s) \"api\" not found. Available projects: \"\"`. Playwright found no config and fell back to an unnamed default project. The fix is to run it from `tests/e2e/`, point at the config with `-c tests/e2e/playwright.config.ts`, or use the root shortcuts the repo defines:\n\n```bash\nnpm run test:e2e:api       # just the API CRUD suite\nnpm run test:e2e:browser   # just the browser E2E test\n```\n\nThe API test earned its keep before the browser test even existed — it caught my update endpoint returning `200` for movies that didn't exist, and a `500` where a `422` belonged. Write the API tests first; they're cheap and brutal.\n\n## Troubleshooting: every error I hit, and what it actually meant\n\nEvery one of these cost me real time. If you're following along and see one of these messages, jump straight here.\n\n### `npm error The 'npm ci' command can only install with an existing package-lock.json`\n\n**When:** building the frontend (or API) Docker image.\n**Why:** this repo is an npm **workspaces** monorepo, so there's exactly one lockfile, at the repo root. Inside `apps/web` there's only a `package.json`.\n**Fix:** build with the **repo root** as the Docker build context, copy the root `package.json` + `package-lock.json` plus just that workspace's manifest, then scope the install:\n\n```dockerfile\nCOPY package.json package-lock.json ./\nCOPY apps/web/package.json ./apps/web/\nRUN npm ci --workspace @movies/web --include-workspace-root\n```\n\n### Refreshing `/movies/abc123` returns 404 from Nginx\n\n**When:** you deep-link or hit reload on any client-side route.\n**Why:** Vue Router owns that URL, but no _file_ by that name exists in the image.\n**Fix:** fall back to `index.html` in `nginx.conf`:\n\n```nginx\nlocation / {\n    try_files $uri $uri/ /index.html;\n}\n```\n\n### The page renders under `docker compose` but never loads any data\n\n**When:** local development with `docker compose up`, not in AWS.\n**Why:** in AWS the ALB routes `/api/*` to the API before Nginx sees it. Compose has no load balancer, so the frontend container serves `index.html` in response to `/api/movies`.\n**Fix:** keep a separate `nginx.local.conf` with an `/api` proxy block and mount it over the production config in `docker-compose.yml`. The production `nginx.conf` stays proxy-free on purpose.\n\n### `AccessDeniedException` on DynamoDB queries, even though base-table reads and writes work\n\n**When:** the first query that hits the `GSI1` secondary index (browse A→Z, search).\n**Why:** querying an index needs the index ARN listed separately in the IAM policy.\n**Fix:** add the `/index/*` resource:\n\n```typescript\nresources: [table.tableArn, `${table.tableArn}/index/*`];\n```\n\n### `AccessDeniedException` when creating a movie, but plain `PutItem` calls succeed\n\n**When:** the multi-item transactional write that adds a movie plus its genre memberships.\n**Why:** `dynamodb:TransactWriteItems` is its own IAM action — `PutItem` being allowed does not cover it.\n**Fix:** add `TransactWriteItems` (and `ConditionCheckItem`) to the policy `actions` list.\n\n### Deleting a movie leaves it showing on genre pages\n\n**When:** after a delete, \"movies in this genre\" still lists the removed movie.\n**Why:** a plain single-item delete removes `PK=MOVIE#123, SK=METADATA` but not the `PK=GENRE#789, SK=MOVIE#123` membership items written alongside it.\n**Fix:** every write path that touches duplicated data — create _and_ delete — must be a `TransactWriteCommand`.\n\n### `Specify an environment name like 'aws://123456789012/ap-southeast-2', or run in a directory with 'cdk.json'.`\n\n**When:** running `npx cdk bootstrap` from the repo root.\n**Why:** `cdk.json` lives in `infra/cdk`, not at the root.\n**Fix:** `cd infra/cdk` first, or name the target explicitly (works from anywhere):\n\n```bash\nnpx cdk bootstrap aws://\u003Caccount-id>/ap-southeast-2\n```\n\n`npm run cdk:deploy` needs no `cd` — the workspace script already runs inside `infra/cdk`.\n\n### `Project(s) \"api\" not found. Available projects: \"\"`\n\n**When:** running `npx playwright test --project=api` from the repo root.\n**Why:** the Playwright config is in `tests/e2e/`. From the root, Playwright finds no config and falls back to a single unnamed project.\n**Fix:** run it from `tests/e2e/`, pass `-c tests/e2e/playwright.config.ts`, or use the root shortcuts:\n\n```bash\nnpm run test:e2e:api       # just the API CRUD suite\nnpm run test:e2e:browser   # just the browser E2E test\n```\n\n### Browser test: `expect(locator).toBeAttached() failed` on `getByTestId('director-select').locator('option').nth(1)`\n\n**When:** `npm run test:e2e:browser` against a database with no directors/genres in it (fresh table, or you forgot `npm run db:seed`).\n**Why:** the add-movie form builds its director dropdown from the API. With no directors, the only `\u003Coption>` is the placeholder, so `option` index 1 never appears and the test times out waiting for it.\n**Fix:** don't make the test depend on seed data. The browser spec now provisions its own director and genre through the API in a `beforeAll` hook (and removes them in `afterAll`), so it passes on an empty table. DynamoDB Local still has to be running and the table created.\n\n### The deployed app returns `{\"items\":[]}` — `curl http://\u003CAlbDnsName>/api/movies` is empty\n\n**When:** right after a successful `npm run cdk:deploy`.\n**Why:** CDK provisions the DynamoDB table empty. There is no seed step in the deploy — `npm run db:seed` in the walkthrough only ever wrote to DynamoDB Local.\n**Fix:** run the seed script against the real table, clearing the local endpoint so it doesn't hit your laptop:\n\n```bash\nDYNAMODB_ENDPOINT= AWS_REGION=ap-southeast-2 TABLE_NAME=MoviesApp npm run db:seed\n```\n\nNeeds AWS credentials with write access to the `MoviesApp` table. `db:create-table` is not needed — CDK already created it.\n\n### The update endpoint returns `200` for a movie that doesn't exist (or `500` instead of `422`)\n\n**When:** caught by the API CRUD test, not by clicking around.\n**Why:** missing existence checks and error mapping in the route handler.\n**Fix:** return `404` when the target isn't found, and `422` when a referenced director or genre doesn't exist. In DynamoDB _you_ are the foreign key — enforce it in code.\n\n## What this costs, and how to turn it off\n\nReal talk for beginners: this stack is **not** free-tier. The always-on pieces — two-ish Fargate tasks per service, the ALB, and one NAT gateway — land somewhere around US$70–100/month if you leave it running. The NAT gateway alone is about a dollar a day.\n\nFor a learning project, deploy it, poke at it, show a friend, then:\n\n```bash\nnpm run cdk:destroy\n```\n\nEverything disappears except the DynamoDB table (we set `RETAIN` on purpose, so a fat-fingered destroy can't eat your data — delete the table manually in the console when you're truly done). Develop locally with docker compose the rest of the time; DynamoDB Local costs nothing.\n\n## What I'd tell you if you're where I was\n\nDesign the DynamoDB keys from your access patterns before writing a line of app code — retrofitting is misery. Give your containers the narrowest IAM badge that runs, and expect the `/index/*` and `TransactWriteItems` traps. Put frontend and API behind one load balancer and let paths do the routing; your future self will never debug CORS at midnight. And write the boring API tests first.\n\nAlso: half the time I lost on this project had nothing to do with AWS. It was `npm ci` in the wrong directory, a config file one folder up from where I was standing, an Nginx route that only exists in production. The cloud parts are documented and predictable. The plumbing between your own folders is the part nobody writes down — so write it down.\n\nMostly, though: pick a small thing you want to exist and build it to a URL. The AWS nouns stop being scary about two hours in.\n\nThe full repo — API, frontend, CDK stack, tests, screenshots, all of it — is an npm-workspaces monorepo, one `npm install` from running on your machine. Happy shipping.\n",{"name":33,"username":34,"twitter_username":35,"github_username":34,"user_id":36,"website_url":37,"profile_image":38,"profile_image_90":39},"Sri","kasukur","sri_k47",643937,"https://www.linkedin.com/in/sridharkasukurthy","https://media2.dev.to/dynamic/image/width=640,height=640,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F643937%2Fcd8d3f00-3a1b-4ac1-89cc-6b50a2e021de.JPG","https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F643937%2Fcd8d3f00-3a1b-4ac1-89cc-6b50a2e021de.JPG",{"name":41,"username":42,"slug":42,"profile_image":43,"profile_image_90":44},"AWS Community Builders ","aws-builders","https://media2.dev.to/dynamic/image/width=640,height=640,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F2794%2F88da75b6-aadd-4ea1-8083-ae2dfca8be94.png","https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F2794%2F88da75b6-aadd-4ea1-8083-ae2dfca8be94.png",1788519848654]