Project 01 / 03Live
EasyIntern
AI-powered internship board with hybrid lexical/semantic search, run serverless on AWS at $0/month.
EasyIntern v2 architecture
Problem
Internship listings are scattered across employer applicant-tracking systems, so students re-run the same searches on many sites and still miss roles whose titles don't match their keywords. EasyIntern pulls listings into one place and ranks them by meaning as well as by keyword.
I founded it in July 2024 and still run it. The v2 architecture below is fully serverless on AWS and stays inside the free tier, which keeps the monthly bill at $0.
Architecture
- Frontend: Next.js 15 on Amplify Hosting.
- API: FastAPI packaged as Lambda container images, fronted by an API Gateway HTTP API with a JWT authorizer.
- Ingestion: an ECS Fargate task on a daily EventBridge schedule pulls listings from 4 ATS integrations.
- Search: PostgreSQL 17 on RDS with pgvector, plus ONNX Runtime running bge-small, combined into hybrid lexical/semantic ranking.
- Identity: Cognito hosted UI with PKCE; the API only accepts requests that pass the JWT authorizer.
Key decisions & trade-offs
- Hybrid ranking instead of vector-only search. Keyword matching keeps exact terms such as job titles and skills precise, while embeddings catch paraphrases; the cost is two scoring paths to tune and keep consistent.
- Lambda container images for the API. Containers fit heavier dependencies such as ONNX Runtime and a local embedding model; the trade-off is a larger artifact and cold starts to keep an eye on.
- Ingestion on ECS Fargate rather than Lambda. A daily batch across 4 ATS integrations is long-running work, which fits a scheduled container better than Lambda's 15-minute limit.
- RDS IAM authentication and a Postgres-level read/write role split. There are no database passwords to store or rotate, and a compromised read path cannot write; the price is token-based connections and role management to maintain.
- Cognito hosted UI with PKCE rather than a custom auth flow. Less control over the sign-in UI in exchange for far less authentication code to get wrong.
- A 10-job merge gate with Terraform lifecycle preconditions. Merges are slower, but misconfigurations (Checkov, tflint), leaked secrets (gitleaks), vulnerable dependencies (pip-audit), and regressions (Playwright) are caught before anything reaches AWS.
Security, cost & testing
- Security: JWT authorizer at the API edge, Cognito hosted UI with PKCE, RDS IAM auth (no DB passwords), and read/write privileges split at the Postgres role level.
- Infrastructure: Terraform with 114 resources across 6 custom modules, S3 remote state with locking, and lifecycle preconditions; 20 architecture decision records.
- Delivery: GitHub Actions with OIDC federation, so there are no long-lived AWS keys in CI.
- Cost: $0/month on the AWS free tier.
- Tests: 477 pytest cases, 134 Vitest cases, and 32 Playwright end-to-end tests run in the merge gate.
What I'd do differently
- Put a cold-start budget on the request path first. The Lambda container image carries ONNX Runtime and the bge-small model, which makes it the heaviest thing between a search and its results. I'd set a p95 target and an alarm before tuning anything else, and push more of the ranking work to ingestion time so the hot path does less.
- Treat ingestion as its own product. Four ATS integrations fail in four different ways, and a daily schedule hides partial failures well. Per-source success counts, a dead-letter path for rejected listings, and a freshness metric per source would be my first observability work.
- Keep the ADR habit, but make it automatic: any pull request that changes Terraform should link the record that justifies it, so the 20 ADRs stay in step with the 114 resources they describe.