Snip URL Shortener
A serverless URL shortener with click analytics. Three AWS Lambda functions behind API Gateway, a single DynamoDB table, all described as infrastructure-as-code with AWS SAM. Built to turn an AWS Developer Associate (DVA-C02) certificate into something real, deployable, and honest about where it actually runs today.
snip takes a long link and returns a short code. Opening that code redirects the visitor and records the click — total clicks, clicks per day, and the referring site. A small web page creates links and reads the stats.
The AWS Certified Developer – Associate (DVA-C02) exam is multiple-choice; passing it proves I can recognise the right answer, not that I’ve wired the services together myself. So I picked the smallest project that genuinely exercises that certificate — Lambda, DynamoDB, API Gateway, IAM, and infrastructure-as-code with SAM — and built it end to end.
snip.rafiarsya.com. The code is written against real AWS services and is deploy-ready — one sam deploy ships it to the cloud with zero code changes. I’d rather show a working thing and say exactly where it runs than fake an AWS URL.
Three single-purpose Lambda functions sit behind one HTTP API. I split them rather than writing one router so each gets only the IAM permissions it needs: create writes but can't read, stats reads but can't write, redirect does both and nothing else.
POST /api/links → CreateLinkFunction — validates the URL, generates a random 6-char code, writes the item to DynamoDB (PutItem).GET /{code} → RedirectFunction — looks the code up (GetItem), bumps the counters (UpdateItem), returns a 302 to the real URL.GET /api/links/{code} → StatsFunction — reads the item and returns the analytics as JSON.
The whole stack — the DynamoDB table, the three functions, the HTTP API, and a scoped IAM role per function — lives in one template.yaml. Nothing is clicked together by hand in a console; the infrastructure is the file, and the file is version-controlled.
Every click has to do three things in a single, atomic DynamoDB update: increment the total, increment today’s bucket, and increment the count for the referring site. The total is easy. The two map-keys were the part I got wrong a few times before it clicked.
UpdateExpression:
SET lastClickedAt = :now,
clicksByDay.#day = if_not_exists(clicksByDay.#day, :zero) + :one,
referrers.#ref = if_not_exists(referrers.#ref, :zero) + :one
ADD clicks :one
ExpressionAttributeNames: { "#day": "2026-06-25", "#ref": "github.com" }
ExpressionAttributeValues: { ":one": 1, ":zero": 0, ":now": "" } Two gotchas. A date like 2026-06-25 or a hostname like github.com isn’t a valid raw attribute path, so both pass as placeholders through ExpressionAttributeNames. And you can’t add 1 to a map key that doesn’t exist — the first click of any day or referrer would fail. if_not_exists(path, :zero) + :one seeds the key at 0 the first time and increments it every time after. The total counter uses ADD instead, which is DynamoDB’s built-in atomic increment and never needs seeding. SET and ADD are combined in one expression so the whole thing is a single atomic write.
# Someone opens snip.rafiarsya.com/aZ3kQ9, referred from github.com
before: { code:"aZ3kQ9", clicks:46,
clicksByDay:{ "2026-06-24":46 },
referrers:{ "github.com":40, "direct":6 } }
# RedirectFunction runs the update above with
# #day = "2026-06-25" #ref = "github.com"
after: { code:"aZ3kQ9", clicks:47,
clicksByDay:{ "2026-06-24":46, "2026-06-25":1 }, # new day seeded at 0, +1
referrers:{ "github.com":41, "direct":6 }, # existing key, +1
lastClickedAt:"2026-06-25T09:12:04Z" }cache-control: no-store means every click reaches the function and gets counted.AccessDenied on UpdateItem because I’d only granted GetItem — annoying for two minutes, but it forced me to actually map what each function touches.OPTIONS request. Configuring CORS on the API Gateway resource itself keeps the Lambda handlers clean — they only ever deal with the real request.I want to be precise about this, because it’s the honest part. snip is written for AWS, but it isn’t on AWS yet — my account is blocked on card verification. Instead of faking it, I made the same code run anywhere.
One shared module holds the DynamoDB logic; both the Lambda handlers and a small local server import it. With an environment variable pointing at a local endpoint it talks to DynamoDB Local in Docker; on Lambda that variable is unset, so the SDK uses the function’s IAM role and the real service. What I test locally is what would run in the cloud.
snip.rafiarsya.com — the same tunnelling setup I use for my other self-hosted projects. It runs while my machine is up; for a quick check it’s genuinely live. The moment AWS verifies my card, sam deploy moves the identical code to Lambda + DynamoDB with no edits, and it becomes 24/7 cloud-hosted.
sam deploy away from AWS.