Tiny URL shortener built with Bun + Hono, a Svelte frontend, Redis for storage, and Traefik as the local API gateway.
This README covers local development (Traefik in Docker) and a production outline (Terraform on AWS). See infra/README.md
for full cloud details.
In addition, a GitHub Actions CI/CD pipeline validates changes on pull requests and deploys to AWS on pushes to main
.
infra/
Routing (Traefik):
Docs:
Run from repo root:
docker compose -f docker-compose.dev.yml up -d
What it does:
Bun loads .env files automatically. Create per-service .env files:
packages/shortening-service/.env
REDIS_URL=redis://localhost:6379
BASE_URL=http://localhost
PORT=3001
packages/forwarding-service/.env
REDIS_URL=redis://localhost:6379
BASE_URL=http://localhost
PORT=3002
Frontend needs the API base exposed via Traefik:
packages/frontend/.env.local
VITE_API_BASE_URL=http://localhost
From the repo root (uses workspaces):
bun install
Shortening API (port 3001):
cd packages/shortening-service
bun run dev
Forwarding API (port 3002):
cd packages/forwarding-service
bun run dev
Frontend (Vite dev server):
cd packages/frontend
bun run dev
Traefik will route:
curl -sS -X POST http://localhost/shorten \
-H 'Content-Type: application/json' \
-d '{"longUrl":"https://example.com"}'
Expected response:
{ "shortUrl": "http://localhost/abc123" }
Open the returned shortUrl in the browser to be redirected.
Run all unit tests from the repo root:
bun test
micro-url
├─ docker-compose.dev.yml # Traefik + Redis for local dev
├─ traefik/
│ └─ dynamic/routes.yml # File provider config for Traefik
├─ packages/
│ ├─ frontend/ # Svelte app
│ ├─ shortening-service/ # POST /shorten
│ ├─ forwarding-service/ # GET /{slug}
│ └─ shared/ # common code (env, redis, logger, slug)
└─ redis-data/ # Redis persistence
docker compose ... up -d
is running and REDIS_URL points to redis://localhost:6379
.High-level flow. For details, see infra/README.md
.
Prerequisites:
cd infra && terraform init && terraform apply
infra/lambda/index.js
routes paths. If you change domain/region, update the hardcoded ALB/S3 hostnames and re-apply, or ask to template it.make ecr-login
(uses AWS_ACCOUNT_ID
and AWS_REGION
, defaults set in Makefile)make deploy-all
make SERVICE=shortening deploy
or make SERVICE=forwarding deploy
make frontend
(uses DOMAIN
from Makefile; uploads packages/frontend/dist
to S3)murl.pw
) to the CloudFront distribution (terraform output cloudfront_domain_name
). If using Cloudflare, enable proxy on user-facing records and keep ACM validation CNAMEs DNS-only.make force-aws-redeploy
Useful outputs (cd infra && terraform output
):
cloudfront_domain_name
— target for DNSs3_bucket_domain_name
— frontend bucketalb_dns_name
— ALB endpoint (used by Lambda@Edge and for debugging)Two workflows live under .github/workflows
:
ci.yml
— Continuous Integrationbun test
).packages/frontend/**
, packages/shared/**
, or root config files trigger the check.deploy.yml
— Continuous Deploymentmain
and manual dispatch.shortening
and forwarding
) to ECR using the root Dockerfile
with SERVICE=<name>-service
build-arg.latest
and the commit SHA.linux/amd64
(matches Makefile’s buildx note).forwarding-ecs-service
and shortening-ecs-service
so the new images go live.packages/frontend/dist
to the S3 bucket named ${DOMAIN}-frontend
.Required repository secrets (Settings → Secrets and variables → Actions):
AWS_DEPLOY_ROLE_ARN
— IAM role to assume via OIDC (e.g., arn:aws:iam::877525430326:role/git-deployment-role
).AWS_REGION
— e.g., eu-central-1
.AWS_ACCOUNT_ID
— e.g., 877525430326
.DOMAIN
— your apex, e.g., murl.pw
(used to address S3: murl.pw-frontend
).Notes and options:
terraform plan
on PRs and apply
on main
, migrate state to a remote backend (e.g., S3 + DynamoDB) and add a workflow.micro-url
├─ .dockerignore
├─ Dockerfile
├─ Makefile
├─ README.md
├─ benchmarks
│ └─ rate-limit
│ ├─ README.md
│ ├─ run.sh
│ └─ wrk-report.lua
├─ bun.lock
├─ docker-compose.dev.yml
├─ docs
│ └─ ci-cd-improvement-checklist.md
├─ infra
│ ├─ .terraform.lock.hcl
│ ├─ README.md
│ ├─ lambda
│ │ └─ index.js
│ ├─ main.tf
│ ├─ modules
│ │ ├─ alb
│ │ │ ├─ main.tf
│ │ │ └─ variables.tf
│ │ ├─ cdn
│ │ │ ├─ main.tf
│ │ │ └─ variables.tf
│ │ ├─ ecr
│ │ │ ├─ main.tf
│ │ │ └─ variables.tf
│ │ ├─ ecs-cluster
│ │ │ ├─ main.tf
│ │ │ └─ variables.tf
│ │ ├─ ecs-services
│ │ │ ├─ main.tf
│ │ │ └─ variables.tf
│ │ ├─ network
│ │ │ ├─ main.tf
│ │ │ └─ variables.tf
│ │ └─ redis
│ │ ├─ main.tf
│ │ └─ variables.tf
│ ├─ plan.txt
│ └─ variables.tf
├─ package-lock.json
├─ package.json
├─ packages
│ ├─ .DS_Store
│ ├─ forwarding-service
│ │ ├─ README.md
│ │ ├─ package.json
│ │ ├─ src
│ │ │ ├─ index.ts
│ │ │ └─ schemas.ts
│ │ └─ tsconfig.json
│ ├─ frontend
│ │ ├─ README.md
│ │ ├─ index.html
│ │ ├─ package.json
│ │ ├─ public
│ │ │ ├─ fonts
│ │ │ │ ├─ Ephesis-Regular.ttf
│ │ │ │ └─ LeagueSpartan-VariableFont_wght.ttf
│ │ │ └─ murl_icon.svg
│ │ ├─ src
│ │ │ ├─ App.svelte
│ │ │ ├─ app.css
│ │ │ ├─ components
│ │ │ │ ├─ Button.svelte
│ │ │ │ ├─ Input.svelte
│ │ │ │ └─ Message.svelte
│ │ │ ├─ lib
│ │ │ │ └─ api
│ │ │ │ └─ shorten-url.ts
│ │ │ ├─ main.ts
│ │ │ ├─ reset.css
│ │ │ └─ vite-env.d.ts
│ │ ├─ svelte.config.js
│ │ ├─ tsconfig.app.json
│ │ ├─ tsconfig.json
│ │ ├─ tsconfig.node.json
│ │ └─ vite.config.ts
│ ├─ shared
│ │ ├─ constants.ts
│ │ ├─ db.ts
│ │ ├─ env.ts
│ │ ├─ logger.ts
│ │ ├─ middleware
│ │ │ ├─ errors.ts
│ │ │ └─ rate-limit.ts
│ │ ├─ slug.ts
│ │ ├─ tests
│ │ │ ├─ __mocks__
│ │ │ │ ├─ crypto.ts
│ │ │ │ └─ db.ts
│ │ │ ├─ constants.ts
│ │ │ └─ slug.test.ts
│ │ └─ types.ts
│ └─ shortening-service
│ ├─ README.md
│ ├─ package.json
│ ├─ src
│ │ ├─ index.ts
│ │ └─ schemas.ts
│ └─ tsconfig.json
├─ traefik
│ └─ dynamic
└─ tsconfig.json