DEV Community

Cover image for From Empty Folder to Live URL: A Beginner's Full-Stack App on AWS Fargate
Sri for AWS Community Builders

Posted on

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 DynamoDB, and deploying it to ECS Fargate — with the actual code, screenshots, and every mistake included.

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.

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:

  • A Vue 3 frontend where you can browse, search, and add movies
  • An Express REST API that stores everything in DynamoDB
  • Two Docker containers running on ECS Fargate behind a load balancer
  • All of it defined in code with AWS CDK, and tested with Playwright

Here's what the finished thing looks like:

The browse view: a dark-themed grid of movie cards showing title, year, director, genre tags and a star rating

The full source is on GitHub: ecs-fargate-movies-api-blueprint

Table of contents

First, a plain-English map of the AWS pieces

Before any code, here's every AWS term in this post, translated:

  • 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.
  • 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.
  • ECS (Elastic Container Service) — AWS's system for running containers and restarting them when they crash.
  • 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.
  • 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.
  • 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.
  • 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.

The architecture, in one diagram:

                       ┌─────────────────────────────────────────┐
                       │                 AWS VPC                  │
Internet ──► ALB ──────┤  /api/* ──► Fargate: Express API ────────┼──► DynamoDB
                       │  /*     ──► Fargate: Nginx + Vue files   │
                       └─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

One front door, two containers, one table. That's the whole app.

Step 1: Design the database before writing any code

This 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.

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.

Every 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:

Movie      PK=MOVIE#123      SK=METADATA
Director   PK=DIRECTOR#456   SK=METADATA
Genre      PK=GENRE#789      SK=METADATA
Enter fullscreen mode Exit fullscreen mode

"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:

GSI1PK=ENTITY#MOVIE   GSI1SK=TITLE#inception
Enter fullscreen mode Exit fullscreen mode

Now "all movies A→Z" is one query against ENTITY#MOVIE, and the search box is the same query plus a prefix match:

// apps/api/src/repositories/movieRepository.ts
const result = await ddb.send(
  new QueryCommand({
    TableName: TABLE_NAME,
    IndexName: 'GSI1',
    KeyConditionExpression: 'GSI1PK = :pk AND begins_with(GSI1SK, :prefix)',
    ExpressionAttributeValues: {
      ':pk': 'ENTITY#MOVIE',
      ':prefix': `TITLE#${prefix.toLowerCase()}`,
    },
  }),
);
Enter fullscreen mode Exit fullscreen mode

That one query powers this:

Typing

The "a movie has many genres" problem

No 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.

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 transaction — all the writes succeed together or none happen:

await ddb.send(
  new TransactWriteCommand({
    TransactItems: [
      {
        Put: {
          TableName,
          Item: movieItem,
          ConditionExpression: 'attribute_not_exists(PK)',
        },
      },
      ...genreIds.map((genreId) => ({
        Put: { TableName, Item: membershipItem(genreId, movieId) },
      })),
    ],
  }),
);
Enter fullscreen mode Exit fullscreen mode

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.

Step 2: The Express API

Nothing exotic here — routes, validation with Zod, and a repository layer so the DynamoDB details stay in one place:

// apps/api/src/app.ts
app.use(helmet());
app.use(cors({ origin: config.corsOrigin }));
app.use(express.json({ limit: '100kb' }));

app.use(healthRouter); // GET /health for the load balancer
app.use('/api/movies', moviesRouter);
app.use('/api/directors', directorsRouter);
app.use('/api/genres', genresRouter);
Enter fullscreen mode Exit fullscreen mode

One 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.

The 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.

Step 3: The Vue 3 frontend

Vite for the build, Pinia for state, Vue Router for pages. The store is small enough to read in one sitting:

// apps/web/src/stores/movies.ts
export const useMoviesStore = defineStore('movies', {
  state: () => ({
    movies: [],
    directors: [],
    genres: [],
    loading: false,
    error: null,
  }),
  actions: {
    async fetchMovies(search?: string) {
      this.loading = true;
      try {
        const { items } = await api.listMovies(search);
        this.movies = items;
      } finally {
        this.loading = false;
      }
    },
    async addMovie(payload) {
      const movie = await api.createMovie(payload);
      this.movies = [movie, ...this.movies];
      return movie;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

The add-movie form loads directors and genres from the API so the dropdowns always reflect real data:

The add-movie form filled in: title, release year, a director dropdown, genre checkboxes, synopsis and rating

And each card links to a detail page with a delete button:

The Inception detail page showing director, genres, synopsis, rating, and Back/Delete buttons

The 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.

Step 4: Put both apps in boxes

Each 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:

# apps/web/Dockerfile  -  build context is the REPO ROOT
FROM node:20-alpine AS build
WORKDIR /repo
COPY package.json package-lock.json ./
COPY apps/web/package.json ./apps/web/
RUN npm ci --workspace @movies/web --include-workspace-root
COPY apps/web ./apps/web
RUN npm run build --workspace @movies/web

FROM nginx:1.27-alpine AS runtime
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/apps/web/dist /usr/share/nginx/html
EXPOSE 80
Enter fullscreen mode Exit fullscreen mode

That 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:

npm error The `npm ci` command can only install with an existing package-lock.json
Enter fullscreen mode Exit fullscreen mode

The 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.

If 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.

One 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:

location / {
    try_files $uri $uri/ /index.html;
}
Enter fullscreen mode Exit fullscreen mode

The 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.

One 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.

Step 5: The actual AWS part (it's ~200 lines)

Everything AWS-side lives in one CDK file: infra/cdk/lib/movies-stack.ts. The table:

const table = new dynamodb.Table(this, 'MoviesTable', {
  tableName: 'MoviesApp',
  partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
  sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, // pay per request, no capacity math
  removalPolicy: cdk.RemovalPolicy.RETAIN, // stack deleted ≠ data deleted
});
Enter fullscreen mode Exit fullscreen mode

The 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:

apiTaskRole.addToPolicy(
  new iam.PolicyStatement({
    actions: [
      'dynamodb:GetItem',
      'dynamodb:PutItem',
      'dynamodb:UpdateItem',
      'dynamodb:DeleteItem',
      'dynamodb:Query',
      'dynamodb:TransactWriteItems',
      'dynamodb:BatchWriteItem',
      'dynamodb:ConditionCheckItem',
    ],
    resources: [table.tableArn, `${table.tableArn}/index/*`],
  }),
);
Enter fullscreen mode Exit fullscreen mode

Two beginner traps hiding in there, both of which got me:

  1. Querying a secondary index needs the /index/* resource line. Without it: AccessDeniedException, even though queries on the base table work fine.
  2. TransactWriteItems is its own permission. PutItem being allowed does not cover it.

Then the load balancer, with the path-routing rule that makes the whole one-domain setup work:

listener.addTargets('ApiTargets', {
  priority: 10,
  conditions: [elbv2.ListenerCondition.pathPatterns(['/api/*', '/health'])],
  port: 3000,
  targets: [apiService],
  healthCheck: { path: '/health' },
});
// everything else falls through to the web container
Enter fullscreen mode Exit fullscreen mode

Because 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.

And 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:

vpc.addGatewayEndpoint('DynamoDbEndpoint', {
  service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
});
Enter fullscreen mode Exit fullscreen mode

Deploying is genuinely this:

cd infra/cdk
npx cdk bootstrap        # once per AWS account+region
cd ../..
npm run cdk:deploy       # builds both images, pushes them, creates everything
Enter fullscreen mode Exit fullscreen mode

Those two cds 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:

Specify an environment name like 'aws://123456789012/ap-southeast-2',
or run in a directory with 'cdk.json'.
Enter fullscreen mode Exit fullscreen mode

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:

npx cdk bootstrap aws://<account-id>/ap-southeast-2
Enter fullscreen mode Exit fullscreen mode

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.

Ten-ish minutes later CDK prints AlbDnsName — your app's public URL. The first time that worked I just stared at it.

Then I opened it and the grid was empty. curl http://<AlbDnsName>/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:

# from the repo root, with AWS credentials active for the deploy account
DYNAMODB_ENDPOINT= AWS_REGION=ap-southeast-2 TABLE_NAME=MoviesApp npm run db:seed
Enter fullscreen mode Exit fullscreen mode

The 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://<AlbDnsName>/api/... with curl.)

Step 6: Prove it works, automatically

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.

Playwright can even start your servers for you:

// tests/e2e/playwright.config.ts
webServer: [
  {
    command: 'npm run dev --workspace apps/api',
    url: 'http://localhost:3000/health',
    reuseExistingServer: true,
  },
  {
    command: 'npm run dev --workspace apps/web',
    url: 'http://localhost:5173',
    reuseExistingServer: true,
  },
];
Enter fullscreen mode Exit fullscreen mode

So 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.)

Playwright's HTML report showing both tests passing: the API CRUD lifecycle and the browser add-movie flow

One 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:

npm run test:e2e:api       # just the API CRUD suite
npm run test:e2e:browser   # just the browser E2E test
Enter fullscreen mode Exit fullscreen mode

The 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.

Troubleshooting: every error I hit, and what it actually meant

Every one of these cost me real time. If you're following along and see one of these messages, jump straight here.

npm error The 'npm ci' command can only install with an existing package-lock.json

When: building the frontend (or API) Docker image.
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.
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:

COPY package.json package-lock.json ./
COPY apps/web/package.json ./apps/web/
RUN npm ci --workspace @movies/web --include-workspace-root
Enter fullscreen mode Exit fullscreen mode

Refreshing /movies/abc123 returns 404 from Nginx

When: you deep-link or hit reload on any client-side route.
Why: Vue Router owns that URL, but no file by that name exists in the image.
Fix: fall back to index.html in nginx.conf:

location / {
    try_files $uri $uri/ /index.html;
}
Enter fullscreen mode Exit fullscreen mode

The page renders under docker compose but never loads any data

When: local development with docker compose up, not in AWS.
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.
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.

AccessDeniedException on DynamoDB queries, even though base-table reads and writes work

When: the first query that hits the GSI1 secondary index (browse A→Z, search).
Why: querying an index needs the index ARN listed separately in the IAM policy.
Fix: add the /index/* resource:

resources: [table.tableArn, `${table.tableArn}/index/*`];
Enter fullscreen mode Exit fullscreen mode

AccessDeniedException when creating a movie, but plain PutItem calls succeed

When: the multi-item transactional write that adds a movie plus its genre memberships.
Why: dynamodb:TransactWriteItems is its own IAM action — PutItem being allowed does not cover it.
Fix: add TransactWriteItems (and ConditionCheckItem) to the policy actions list.

Deleting a movie leaves it showing on genre pages

When: after a delete, "movies in this genre" still lists the removed movie.
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.
Fix: every write path that touches duplicated data — create and delete — must be a TransactWriteCommand.

Specify an environment name like 'aws://123456789012/ap-southeast-2', or run in a directory with 'cdk.json'.

When: running npx cdk bootstrap from the repo root.
Why: cdk.json lives in infra/cdk, not at the root.
Fix: cd infra/cdk first, or name the target explicitly (works from anywhere):

npx cdk bootstrap aws://<account-id>/ap-southeast-2
Enter fullscreen mode Exit fullscreen mode

npm run cdk:deploy needs no cd — the workspace script already runs inside infra/cdk.

Project(s) "api" not found. Available projects: ""

When: running npx playwright test --project=api from the repo root.
Why: the Playwright config is in tests/e2e/. From the root, Playwright finds no config and falls back to a single unnamed project.
Fix: run it from tests/e2e/, pass -c tests/e2e/playwright.config.ts, or use the root shortcuts:

npm run test:e2e:api       # just the API CRUD suite
npm run test:e2e:browser   # just the browser E2E test
Enter fullscreen mode Exit fullscreen mode

Browser test: expect(locator).toBeAttached() failed on getByTestId('director-select').locator('option').nth(1)

When: npm run test:e2e:browser against a database with no directors/genres in it (fresh table, or you forgot npm run db:seed).
Why: the add-movie form builds its director dropdown from the API. With no directors, the only <option> is the placeholder, so option index 1 never appears and the test times out waiting for it.
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.

The deployed app returns {"items":[]}curl http://<AlbDnsName>/api/movies is empty

When: right after a successful npm run cdk:deploy.
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.
Fix: run the seed script against the real table, clearing the local endpoint so it doesn't hit your laptop:

DYNAMODB_ENDPOINT= AWS_REGION=ap-southeast-2 TABLE_NAME=MoviesApp npm run db:seed
Enter fullscreen mode Exit fullscreen mode

Needs AWS credentials with write access to the MoviesApp table. db:create-table is not needed — CDK already created it.

The update endpoint returns 200 for a movie that doesn't exist (or 500 instead of 422)

When: caught by the API CRUD test, not by clicking around.
Why: missing existence checks and error mapping in the route handler.
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.

What this costs, and how to turn it off

Real 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.

For a learning project, deploy it, poke at it, show a friend, then:

npm run cdk:destroy
Enter fullscreen mode Exit fullscreen mode

Everything 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.

What I'd tell you if you're where I was

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 /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.

Also: 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.

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.

The 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.

Top comments (0)