Case study · September 2026

Self-deploying app on AWS

Every push to main is tested, containerized and rolled out to ECS Fargate by GitHub Actions. Authenticated with GitHub OIDC — no stored AWS keys — and rolled back by image tag.

FastAPI·GitHub Actions·Docker·ECR·ECS Fargate·DynamoDB·IAM

A small URL shortener — but the app was never really the point. The point was proving I could take a container from a git push to running behind a load balancer on AWS with nobody touching the console, and then prove it actually works by pushing a real change through it.

What it does

A real, if tiny, link shortener — not a toy JSON endpoint:

  • POST /api/links stores a URL and returns a 7-character code.
  • GET /{code} redirects to the original URL and counts the click.
  • The home page is a live dashboard: health, the exact commit currently running, the storage backend, and the last deploy's pipeline stages, next to a working shortener with recent links.

Architecture

Layer Choice
App Python, FastAPI, port 8080
Storage DynamoDB in production, in-memory for local dev and tests
Registry Amazon ECR (private)
Compute ECS Fargate, private subnets
Networking Application Load Balancer in public subnets, custom VPC
CI/CD GitHub Actions
Auth GitHub OIDC → short-lived IAM role, no stored AWS keys

Every push to main runs the test suite, builds a Docker image tagged with the commit SHA, pushes it to ECR, renders a new ECS task definition with that image, and updates the service — the deploy step waits for the new tasks to reach a healthy steady state before it reports success.

Auth without stored keys

The workflow authenticates to AWS through GitHub's OIDC provider instead of a long-lived access key sitting in a repo secret. GitHub stores only an IAM role ARN; each workflow run exchanges a short-lived OIDC token for temporary AWS credentials that expire when the job ends, and the role's trust policy only accepts tokens from this one repository. There's no static key to leak.

Idempotent by design

Short codes are the first 7 characters of a SHA-256 hash of the URL, so the same URL always produces the same code. Creating a link twice is a no-op instead of a duplicate row: the DynamoDB write uses a conditional expression (attribute_not_exists(code)), so two concurrent requests for the same URL can't race each other into two records.

One storage interface, two backends

storage.py defines a single interface with a DynamoDB implementation and an in-memory one behind it. Whether TABLE_NAME is set is the only thing that decides which one runs — set on the ECS task definition in production, unset locally and in CI. That's what lets the test suite — eight tests covering health, redirects, click counts, idempotency, and validation — run against the store's real contract without touching AWS.

Least privilege, actually

The task's IAM role is scoped to GetItem, PutItem, UpdateItem, and Scan on that one table's ARN — not dynamodb:*, not Resource: "*".

Rollback without a rebuild

Images are tagged by commit SHA, so every deployed version stays in ECR. Rolling back is redeploying the previous ECS task definition revision — no rebuild, no waiting on CI.

Proving the pipeline, not just building it

The app and the pipeline went in as one push on September 8. Two days later I pushed a single line of CSS — the UI's accent color — for no reason other than to watch the whole thing work end to end on a change I could actually verify: tests run, image builds, ECS rolls out the new task, the ALB health check passes, and the dashboard on the live commit shows the new SHA. A pipeline that deploys on its first push isn't proven yet. One that redeploys cleanly on the second is.

What I'd do differently next

  • Infrastructure as code. The VPC, ALB, and ECS cluster here were stood up by hand, following a checklist in the README. My other projects use Terraform for exactly this reason — this one should too.
  • A staging environment. Right now main deploys straight to the only environment there is. A second ECS service behind a second target group, promoted after a smoke test, would catch a bad deploy before it's live.
  • Deploy alerting. The GitHub Actions run fails if the new tasks never go healthy, but nothing pages anyone when that happens. A Slack or SNS notification on a failed deploy is a small addition with a real payoff.

The repository is public: karan-sohi/ship-on-push.