Back to All Projects
Backend · Serverless · AWS SAM · 2026

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.

AWS Lambda DynamoDB API Gateway AWS SAM IAM least-privilege Node.js Docker
Status
Working
Year
2026
Role
Solo Developer
Backend
Lambda · DynamoDB
Hosting
Local + Tunnel
01 Project Overview

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.

Being upfront about hosting: snip is not deployed on AWS yet. My AWS account is stuck on payment verification (an Indonesian debit card that AWS won’t authorise), so right now snip runs locally on Docker and is exposed to the internet through Cloudflare Tunnel at 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.
02 Architecture

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.

Request lifecycle
POST /api/linksCreateLinkFunction — 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.

03 The One Write That Took the Longest

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.

01DynamoDB Atomic Click Update
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" }
04 Engineering Decisions
302, not 301
My first version returned a 301 and the click counter froze after the very first hit — browsers cache a 301 permanently and never call Lambda again. Switching to a 302 with cache-control: no-store means every click reaches the function and gets counted.
Least-privilege IAM, per function
Each Lambda gets an inline policy scoped to exactly the actions it needs on exactly one table ARN. The first deploy failed with AccessDenied on UpdateItem because I’d only granted GetItem — annoying for two minutes, but it forced me to actually map what each function touches.
CORS handled at the API, not in code
The web page couldn’t POST until the HTTP API answered the browser’s preflight OPTIONS request. Configuring CORS on the API Gateway resource itself keeps the Lambda handlers clean — they only ever deal with the real request.
arm64 + on-demand billing
The functions target arm64 (Graviton), which is a little cheaper than x86, and the table is on-demand so there’s nothing to pay for while it sits idle. Cost-awareness baked in from the start — the whole thing is designed to live inside the free tier.
05 Where It Actually Runs (Today)

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.

The live demo you can click is that local server, exposed through Cloudflare Tunnel at 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.
06 Use Cases & Impact
Cleaner Link Sharing
Long, ugly URLs become short, shareable codes — for a bio, a slide, or a message where the full link would look messy.
Campaign Tracking
Per-link click counts, daily trends, and referrer breakdown show which channel a click actually came from — the same job a marketing team uses a paid shortener for.
QR & Print
A short code fits a QR or a printed handout far better than a 90-character URL, and still resolves to the original destination.
A Real DVA-C02 Artefact
Most of all: proof, for anyone reading my CV, that the AWS certificate maps onto a stack I’ve actually wired together — not just an exam I passed.
Tech Stack
AWS Lambda DynamoDB API Gateway AWS SAM IAM CloudWatch Node.js Express Docker DynamoDB Local Cloudflare Tunnel
A certificate is a multiple-choice exam. This is the thing I deployed.
Lambda, DynamoDB, API Gateway and IAM, wired together as infrastructure-as-code and running end to end — currently self-hosted via Cloudflare Tunnel, one sam deploy away from AWS.
View on GitHub