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.