URL Shortener on ECS Fargate

In progress

A Python/FastAPI URL shortener being built in phases from an empty AWS account, targeting ECS Fargate provisioned by Terraform, with a GitHub Actions pipeline and CloudWatch observability in later phases. The centerpiece is docs/BUILD_LOG.md — a narrative record kept as the project progresses, not reconstructed afterwards. It documents the reasoning behind each decision and the mistakes as they happened. Phases 0 and 1 are complete: IAM bootstrap, the containerized service, local dev, and secret scanning.

Phase 0 — IAM before any infrastructure

The first design question: why not just use root access keys for a solo project? Not because "least privilege is good practice" — because root literally cannot be scoped down by IAM policy the way a normal identity can. A leaked root key means total account takeover (billing changes, account closure, deleted CloudTrail logs), not a contained incident. The build log also records the tempting shortcut of attaching AdministratorAccess to the new terraform-deploy user "since we don't know every service we'll touch yet" — rejected, since recreating admin on a differently-named identity defeats the point of moving off root. The working policy instead covers only what the current phase needs, extended when terraform apply fails with a specific AccessDenied. The user itself was built with five CLI calls in CloudShell (create-policy, create-user, attach-user-policy, create-access-key).

Two real mistakes followed immediately. First, aws configure on the local machine ended up authenticated as root — via a browser-based root login session, a flow that isn't even valid for a classic IAM user. The fix: a real access key for terraform-deploy under a named profile, with the root session cleared out of default entirely, so anything that forgets to specify a profile fails loudly instead of silently running as root. Second, while fixing that, the actual AccessKeyId/SecretAccessKey pair got pasted into a chat window. It was treated as compromised on the spot — same principle as a secret hitting Slack or a log — revoked immediately, with the replacement typed directly into aws configure in a real terminal. Knowing why a mistake is wrong in the abstract and not making it under real conditions turned out to be different skills.

Phase 1 — the container, done properly

Why does ECS/ALB need a dedicated health endpoint instead of health-checking /? Because the health check is an automated control loop deciding whether to route traffic to a task and whether to kill and replace it. Couple it to a business route and you get false positives (a static page returns 200 regardless of real health) or false negatives (a slow route causes unnecessary task cycling under load). The service keeps /health (liveness) and /ready (readiness, where dependency checks will live) as separate, cheap, dependency-light routes.

The Dockerfile started as a deliberately naive single stage (FROM python:3.12, no USER, COPY . .) and was rebuilt multi-stage: a builder stage installs dependencies via pip install --user so only /root/.local is copied into a python:3.12-slim runtime; a non-root appuser with a nologin shell; explicit --chown on both COPY layers; PYTHONUNBUFFERED=1 so container logs aren't buffered and lost; and PATH extended to ~/.local/bin, without which uvicorn isn't found at start. Result: a 230MB image (54.9MB unique content) versus the 1GB+ the naive build produces. Verification went beyond "looks right": 5/5 tests, every endpoint curled, Docker's healthcheck reporting healthy, and docker compose exec app whoami confirming the process actually runs as appuser.

Secret scanning, tested before trusted

The Phase 0 credential exposure led to a gitleaks pre-commit hook — .gitignore only stops new files being tracked; it does nothing for a secret already in history. The hook was tested before being trusted, which surfaced something non-obvious: a bare AKIA...-style key ID isn't flagged (gitleaks' AWS rule requires the real key's checksum structure, and the well-known AKIAIOSFODNN7EXAMPLE placeholder is explicitly allowlisted — a naive test would have given false confidence). A high-entropy fake secret key was caught by the generic-api-key rule and the hook blocked the commit. The junk test commits were wiped with git update-ref -d HEAD before anything was pushed.