URL Shortener on Kubernetes (EKS)
In progress
A Python/FastAPI URL shortener being built in phases from an empty AWS account,
targeting a Kubernetes cluster on AWS EKS 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 2
lays the Terraform remote-state foundation, and Phase 2.5 pivots the compute layer
to Kubernetes on EKS.
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," but 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, the
same principle as a secret hitting Slack or a log, and 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 do the health endpoints need to be dedicated routes instead of health-checking
/? Because on Kubernetes these become the pod's liveness and readiness
probes: an automated control loop deciding whether to restart the container
(liveness) and whether to send it traffic (readiness). Couple either to a business
route and you get false positives (a static page returns 200 regardless of real
health) or false negatives (a slow route triggers needless pod restarts under load).
The service keeps /health (liveness) and /ready
(readiness, where dependency checks will live) as separate, cheap, dependency-light
routes that map directly onto the two probe types.
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, so 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.
Phase 2: Terraform state, before any stack needs it
Before any real infrastructure, the Terraform state needed a home: a remote backend
(an S3 bucket for the state file, a DynamoDB table for locking) so state isn't a
local file that no one else, not even future-me on another machine, can see or safely
share. The bucket was written with versioning (to recover from a bad apply), default
server-side encryption, and a public-access block, each as its own resource, since
the inline versioning and encryption blocks on aws_s3_bucket
are deprecated in the current AWS provider. It also carries
lifecycle { prevent_destroy = true }, so a stray
terraform destroy can't wipe the bucket that holds every other stack's
state.
The mistake caught here was a placeholder that almost shipped: the bucket was still
named mycompany-terraform-state. Two problems, one obvious and one not.
S3 bucket names are globally unique across every AWS account, not just mine,
so a generic name like that would likely collide and fail with
BucketAlreadyExists. The subtler one: the terraform-deploy
IAM policy from Phase 0 was scoped to a specific bucket ARN, so a name that didn't
match would fail apply with AccessDenied. Two pieces of
infrastructure written phases apart still have to agree. Renamed to
rtchan-devops-portfolio-tfstate, with the DynamoDB lock table
(PAY_PER_REQUEST, LockID as its hash key, the exact schema
Terraform's S3 backend expects) added where a bare TODO had been left.
Phase 2.5: the pivot to Kubernetes
The compute layer was originally going to be ECS Fargate; partway in, it became EKS — a deliberate pivot, reasoned out in the build log rather than quietly rewritten. The ECS choice had been sound for the project in isolation (less operational surface, no flat ~$73/month control-plane bill), but it optimized the wrong thing: the point of the project is closing real skill gaps for a job search, and Kubernetes was the gap. A managed EC2 node group was chosen over Fargate profiles for the same reason — it keeps the node management, autoscaling, and IAM surface that make the exercise worth doing. The added cost is stated plainly, not hidden, and the cluster is stood up on demand and torn down between sessions rather than left billing around the clock.