Live in Production
MAY 2026 — PRESENT

Whetstone Platform
Production SaaS on AWS

Built for a real business. Running in production. Whetstone Platform is a multi-tenant SaaS pricing tool I designed, built, and operate end to end — from the Flask application and database layer through the container infrastructure, CI/CD pipeline, and serverless backend. The problem it solves is real. The infrastructure is production-grade. It has paying subscribers.

AWS ECS Fargate Terraform AWS Lambda DynamoDB Cognito Python / Flask API Gateway GitHub Actions Stripe Secrets Manager Route 53 ACM Docker ECR EventBridge LIVE
<4m Deploy Time
0 Downtime Deploys
59 Issues Documented
381 Products Priced
5 Lambdas Running
01

Architecture

// APPLICATION LAYER — ECS Fargate Route 53 (whetstoneplatform.com) └── ALB (HTTPS · ACM SSL · auto-renewing) └── ECS Fargate Cluster — whetstone-cluster └── ECS Service — whetstone-app (desired: 1, rolling deploy) └── Flask App (Docker container via ECR) ├── Cognito — USER_PASSWORD_AUTH flow ├── Google Sheets API v4 — 7 buyer sheets · 381 products └── Secrets Manager — all credentials at runtime // NETWORK LAYER — Terraform VPC (us-east-2) ├── Public Subnets us-east-2a · us-east-2b │ └── ALB └── Private Subnets us-east-2a · us-east-2b ├── ECS Tasks (NAT Gateway for outbound) └── No direct internet route inbound // SERVERLESS BACKEND — 5 Lambda Functions Stripe Webhook ──▶ API Gateway ──▶ Lambda (whetstone-stripe-webhook) ├── payment_succeeded → Cognito: create user → DynamoDB: write tenant → Brevo: welcome email ├── invoice_payment_failed → Brevo/SMS: operator alert → Stripe dunning: 4 retries └── customer_subscription_deleted → Cognito: revoke access → DynamoDB: update status EventBridge (every 30 min) ──▶ Lambda (whetstone-price-monitor) ├── Pulls all 7 buyer sheets via parsers ├── Compares against DynamoDB price snapshots ├── Detects price changes and accepting/not-accepting flips ├── Respects per-operator quiet hours and buyer toggle settings └── Delivers alerts via Brevo email + SNS SMS EventBridge (daily, 13:00 UTC) ──▶ Lambda (whetstone-fda-recall) ├── Polls openFDA device/recall API for 5 manufacturers ├── Deduplicates against whetstone-fda-recalls DynamoDB table └── Immediate alert to all active operators bypassing quiet hours EventBridge (daily, noon UTC) ──▶ Lambda (whetstone-parser-health) ├── Canary check on all 7 buyer parsers ├── Price bounds validation └── Admin alert on any parser failure EventBridge (hourly) ──▶ Lambda (whetstone-crm-followup-reminders) ├── Checks pending follow-ups per operator ├── Respects operator timezone and quiet hours └── Delivers via existing SNS/Brevo stack // CRM DATA LAYER — DynamoDB whetstone-crm-contacts (PK: contact_id, GSI: operator_email-index) whetstone-crm-interactions (PK: interaction_id, GSI: contact_id-index) whetstone-crm-followups (PK: followup_id, GSI: operator_email-index) // CI/CD PIPELINE — GitHub Actions git push (main) └── GitHub Actions workflow ├── AWS auth (OIDC-equivalent — dedicated IAM user, least-privilege) ├── ECR login → docker build → docker push (tagged: latest) ├── Register new ECS task definition └── Update ECS service → rolling deployment (zero downtime)
Nothing in the console. Every AWS resource — VPC, subnets, NAT gateway, ALB, ECS cluster, ECR, Secrets Manager, CloudWatch, Route 53, IAM roles — is provisioned and managed with Terraform. The entire infrastructure lifecycle runs from a single terraform apply or terraform destroy. Nothing was clicked into existence manually.
No credentials anywhere they shouldn't be. The ECS task reads all credentials from Secrets Manager at runtime via an IAM task role. The Flask app reads Google credentials from an environment variable injected by ECS — the same get_credentials() helper works whether the source is a file path (local dev) or a JSON string (ECS). Nothing sensitive touches the codebase or sits on disk.
Fully automated tenant provisioning. When a subscriber pays through Stripe, a webhook fires to API Gateway, Lambda processes the event, creates a Cognito user, writes a tenant record to DynamoDB, and sends a welcome email — all without human intervention. Payment failure and cancellation events revoke access automatically on the same path.
02

What I Built

01
Flask Application Pricing aggregator · Quote builder · Auth · Multi-tenant
A Flask web application that pulls live pricing from 7 buyer Google Sheets via the Sheets API v4, aggregates across 381 products in 23 categories, applies a margin formula, and renders an interactive quote builder. Cognito handles authentication. Session cookies are scoped correctly for production deployment behind an ALB. The app is containerized with Docker and deployed to ECS Fargate — no servers to manage.
python flask cognito google sheets api v4 docker ecs fargate
02
Terraform Infrastructure Full AWS stack · IaC from day one · Clean lifecycle
Terraform manages the complete AWS environment: VPC with public and private subnets across two AZs, NAT gateway, Application Load Balancer with ACM SSL certificate, ECS Fargate cluster and service, ECR repository with lifecycle policies, Secrets Manager secrets, CloudWatch log group with retention, Route 53 alias records, and all IAM roles with least-privilege policies. One terraform apply builds everything. One terraform destroy tears it all down cleanly.
terraform vpc alb ecs ecr route 53 acm iam
03
GitHub Actions CI/CD Push to live in under 4 minutes · Zero downtime
Every push to main triggers a GitHub Actions workflow: AWS auth, ECR login, Docker build, image push tagged as latest, new ECS task definition registration, and ECS service update with a rolling deployment strategy. The old task keeps serving traffic until the new one is healthy. End to end under four minutes. No manual deploy steps. A dedicated whetstone-cicd IAM user with least-privilege permissions handles all AWS operations.
github actions docker ecr ecs rolling deploy iam least-privilege
04
Serverless Onboarding Pipeline Stripe → Lambda → Cognito → DynamoDB → Email
Stripe webhooks fire to API Gateway on every subscription event. A Lambda function processes the event type: payment_succeeded creates a Cognito user, writes a tenant record to DynamoDB, and sends a welcome email via Brevo SMTP. invoice_payment_failed disables the Cognito account and notifies the admin. customer_subscription_deleted removes access and updates the tenant record. The entire customer lifecycle — signup to cancellation — runs without human intervention.
lambda api gateway stripe webhooks cognito dynamodb brevo smtp
05
Secrets Management Nothing hardcoded · Nothing on disk · Runtime retrieval
All credentials live in AWS Secrets Manager under a whetstone/* prefix: Flask secret key, Google service account JSON, Cognito config, buyer sheet IDs, Stripe keys, and Brevo SMTP credentials. The ECS task role grants the container permission to retrieve them at startup — no access keys stored in environment variables, no credentials in the codebase, no secrets in version control. The Lambda function accesses the same Secrets Manager entries via its own least-privilege execution role.
secrets manager iam task role iam execution role zero hardcoded credentials
06
Notification & Monitoring System Price monitor · FDA recalls · Parser health · EventBridge scheduling
A price monitoring Lambda runs on a 30-minute EventBridge schedule, pulling all 7 buyer sheets through the parser stack, comparing results against DynamoDB snapshots, and generating plain-English alerts when prices change or a buyer stops accepting a product. Per-operator settings control which buyers to watch, notification method (email, SMS, or both), quiet hours with timezone awareness, and margin thresholds. A separate FDA recall Lambda polls the openFDA device/recall API daily for 5 CGM and insulin pump manufacturers, deduplicates against a DynamoDB recall log, and fires immediate alerts to all active operators bypassing quiet hours. A parser health Lambda runs daily canary checks across all 7 buyer parsers with price bounds validation and admin alerting on failure.
lambda eventbridge dynamodb sns brevo smtp openfda api
07
Operator CRM Contacts · Interaction history · Follow-up reminders · Multi-tenant
A full contact management system built into the platform. Operators log seller contacts, interaction history, and follow-up reminders without leaving the tool. Three DynamoDB tables (contacts, interactions, followups) with GSIs for operator-scoped queries. A Save to CRM button in the Fast Quote drawer passes quote data through a prefill flow directly into a new contact and interaction record. A follow-up reminder Lambda fires hourly, checks each operator's pending reminders against their local timezone and quiet hours, and delivers via the existing SNS/Brevo stack. Archived contacts preserve full interaction history and can be restored. Bulk contact import via CSV accepts name, phone, email, address, notes, meetup location, preferred payment, and source columns with case-insensitive header matching and skips rows without a name.
dynamodb lambda eventbridge crm multi-tenant timezone-aware csv import address field
08
Operator Self-Service & Billing Settings · Stripe portal · Cancel flow · Forgot password · Dunning
Operators manage their own account without any admin intervention. Settings page covers notification preferences (independent email/SMS flags), up to 5 labeled phone numbers for SMS, quiet hours with timezone selection, per-buyer toggles, and margin thresholds. Stripe Customer Portal handles payment method updates. Self-service cancel with cancel_at_period_end and a confirmation modal to prevent accidents. Custom forgot-password flow using DynamoDB tokens with 1-hour TTL and Brevo delivery — completely replacing the original Cognito/SES dependency that was permanently rejected for this AWS account. Payment failure handling fires operator notifications on invoice.payment_failed with Stripe dunning configured for 4 retries before subscription cancellation.
stripe dynamodb cognito brevo smtp self-service dunning
09
Lead Generation Site WordPress · Full on-page SEO · Google Search Console
A companion WordPress site with 18 fully SEO-optimized pages targeting local organic search traffic — keyword-targeted content, internal linking structure, image optimization, schema metadata via AIOSEO, Google Search Console integration with sitemap submitted, and Google Business Profile configured for local search visibility. The business model is simple: motivated sellers are already searching online. Build the best-optimized site in the niche and let inbound traffic do the work.
wordpress aioseo google search console google business profile on-page seo
10
Platform-Wide Design System Shared stylesheet · CSS custom properties · Zero duplicated styles
As the platform grew from a single pricing tool to a multi-feature SaaS with CRM, follow-ups, settings, and admin pages, each template had accumulated its own inline CSS with hardcoded hex values and its own font stack — the same styles copy-pasted across ten files with no single source of truth. Extracted all shared styles into a single static/css/whetstone.css stylesheet with CSS custom properties for the full color palette and typography scale. Removed approximately 1,100 lines of duplicated inline CSS across ten templates. All authenticated pages now pull from the shared stylesheet, making platform-wide design changes a single-file edit.
css custom properties design system flask / jinja2 refactor
11
Operator Help Center Video library · Collapsible FAQs · Extracted to shared nav partial
Replaced a single embedded YouTube video with a structured help system available on every authenticated page. The Help Center panel contains a video playlist section with thumbnail cards that load YouTube embeds on selection, and four collapsible FAQ sections covering the full Base tier feature set — 18 questions organized into Fast Quote, Price Alerts and Notifications, Whetstone CRM, and Account and Billing. Originally embedded in the Fast Quote template, the entire Help Center was extracted to the shared nav partial so it renders on all authenticated pages without duplication. Three tutorial videos produced and published to YouTube — Fast Quote walkthrough, Whetstone CRM walkthrough, and a short Mobile Install guide.
ux jinja2 partials youtube api video production
03

Problems I Solved

ISSUE Login worked on desktop but redirected back to /login on mobile with no error. No Set-Cookie header visible in mobile browser network tab.
RESOLVED  Two separate bugs. First: session-writing lines were placed after a return statement — dead code, no syntax error, no warning, only caught by tracing the HTTP response. Second: SESSION_COOKIE_SAMESITE had an unclosed string quote, silently invalidating the config. Mobile browsers enforce cookie policies more strictly than desktop. Fixed both, set SESSION_COOKIE_DOMAIN = None so the cookie scopes to whatever domain serves the request, and set SESSION_COOKIE_SAMESITE = 'Lax' correctly.
ISSUE ECS health checks failing after containerizing with a slim Python image. Container would start, pass initial startup, then fail health checks and cycle continuously.
RESOLVED  The health check command used curl, which isn't present in slim Python images. Replaced the health check with a Python one-liner using urllib from the standard library — no additional dependencies required. The container started passing health checks immediately.
ISSUE Terraform rolled back the ECS task definition from :3 to :2 after a terraform apply. Site returned 502.
RESOLVED  The CI/CD pipeline had deployed a new task definition outside of Terraform, causing state drift. When terraform apply ran, it saw the newer task definition as drift and corrected it back to the version in state. Manually forced the service back to the correct task definition with aws ecs update-service --task-definition whetstone-app:3. Lesson: when CI/CD deploys new task definitions outside of Terraform, running terraform apply will roll back to the state's version. Manage task definition updates through Terraform or accept that the pipeline owns that resource.
ISSUE 502 on desktop after DNS cutover. Site working correctly on phone. DNS flush, Winsock reset, browser cache clear — all failed to resolve it.
RESOLVED  The Windows hosts file had a hardcoded entry pointing whetstoneplatform.com at the old EC2 IP from a previous development session. The hosts file overrides everything — DNS flush, ISP cache, Google DNS, all of it. Removed the stale entry from C:\Windows\System32\drivers\etc\hosts. Site resolved correctly immediately. The hosts file is the first thing to check when DNS changes aren't taking effect on a specific machine.
ISSUE Stripe checkout button returning {"error":"You did not provide an API key"} on the deployed ECS service.
RESOLVED  The new Stripe secrets were added to Secrets Manager but never registered in the ECS task definition. The app read them at startup via get_secret() but the task definition only referenced the original Phase 2 secrets — the Stripe keys weren't in scope for the running container. Registered a new task definition revision with the Stripe secrets added and updated the ECS service to use it.
ISSUE Amazon SES production access request denied with no specific reason. New account needed for transactional email delivery.
RESOLVED  New AWS accounts without usage history can be denied SES production access by automated trust scoring regardless of use case quality. Resubmitted appeal referencing active production infrastructure (ECS Fargate, Lambda, DynamoDB, API Gateway, Route 53, ACM) with a billing console screenshot as proof of legitimate use. While the appeal was pending, switched Lambda email delivery to Brevo SMTP — domain verified with DKIM and DMARC, credentials stored in Secrets Manager under whetstone/brevo-smtp. Brevo free tier (300 emails/day) covers low-volume transactional email without any configuration issues.
ISSUE Route 53 terraform apply errored with "Tried to create resource record set but it already exists" during DNS cutover from EC2 to ECS.
RESOLVED  Terraform tried to create the new ALB alias records before the old EC2 A records finished deleting — a create/delete race condition. Added allow_overwrite = true to both aws_route53_record resources in the cutover Terraform config. Applied cleanly on the next run.
ISSUE DynamoDB table primary key mismatch causing silent auth failures. The first-login intro modal never fired despite the flag being set correctly in the console.
RESOLVED  The whetstone-operators table is keyed by operator_id (UUID), not email. The /app route was calling get_item with Key={'email': email}, which raised a ClientError on key schema mismatch. A broad except block swallowed the error and left show_intro = False. Same bad key pattern was in the cancel-subscription route, meaning self-service cancel always returned "Account not found." Fix: moved the has_seen_intro flag to whetstone-operator-prefs (keyed by email), updated both read and write paths. Lesson: DynamoDB will not warn you about a wrong key at write time — only on get_item or update_item. Know which table uses which key before you write the route.
ISSUE Lambda health timestamps in the admin dashboard showing stale dates or "Unknown" despite Lambdas running successfully on schedule.
RESOLVED  filter_log_events without a stream name searches all streams but hits the oldest stream first within the result limit. Lambda creates a new log stream per execution context, so recent runs were in newer streams the query never reached. Fix: call describe_log_streams with orderBy=LastEventTime and descending=True first, then pass the most recent stream name explicitly to filter_log_events. Lesson: never query CloudWatch logs across all streams for a Lambda that has accumulated multiple execution contexts — always identify the most recent stream first.
ISSUE DynamoDB UpdateItem throwing ValidationException in production on a contact edit route. Error: "Attribute name is a reserved keyword: source."
RESOLVED  DynamoDB treats source as a reserved keyword. Using it directly in an UpdateExpression causes a runtime ValidationException — no error at write time, only on update. Fix: added #src to ExpressionAttributeNames mapping to source, updated UpdateExpression to use #src. Same pattern already used for name → #n in the same call. Lesson: DynamoDB reserved keyword errors are runtime errors, not syntax errors. Would have been caught immediately with local dev testing before deploy.
ISSUE Stripe SDK objects don't support .get() calls — admin dashboard showing 0 active subscribers and $0 MRR despite subscribers existing in DynamoDB.
RESOLVED  The Stripe Python SDK wraps API responses in custom object types. Calling sub.get('field_name') throws a KeyError because the SDK intercepts attribute access and treats 'get' as a key lookup. Fields like trial_end, current_period_end, and discount were all throwing silently inside an except block, preventing counters from incrementing. Fix: replaced all .get() calls with individual try/except blocks using direct attribute access. Lesson: never use .get() on Stripe SDK objects — the SDK's __getattr__ intercepts everything.
ISSUE In-memory cache populated on first search but every subsequent request still showed a cache miss. Search taking 7–8 seconds on every request in production despite 52ms locally.
RESOLVED  Gunicorn was configured with 2 workers. Each worker is a separate OS process with its own memory space — cache written in worker A is invisible to worker B. Requests round-robin between workers so cache hits were essentially impossible. Fix: reduced Gunicorn to 1 worker so all requests share the same process memory. Lesson: in-process caching only works with a single worker. If you need both caching and parallelism, the cache has to live outside the application process — Redis or ElastiCache.
ISSUE Search taking 40–50 seconds intermittently in production. Assumed cold start but persisted after the container warmed up. CloudWatch showed 429 errors from sheets.googleapis.com.
RESOLVED  Each search request was fetching all 7 buyer sheets fresh with no caching. Rapid back-to-back searches during testing burned through the Google Sheets API 60 reads/minute quota. Subsequent requests had to wait for the quota window to reset. Fix: added a 10-minute in-memory cache to the aggregator. Raw unfiltered results cached at module level. Operator-specific filtering applied after cache lookup. Cache miss fetches all sheets once; all requests within the TTL window serve from memory. Result: 39ms on cache hit vs 8–12 seconds on cold fetch. Lesson: any route that hits an external API on every request will eventually hit rate limits under real load. Cache aggressively, fetch lazily.
ISSUE Purge button onclick not firing for contacts with spaces in their name. btn.onclick returned null in the browser console even though getAttribute('onclick') showed the string was present.
RESOLVED  The onclick attribute used double quotes as the HTML delimiter. The Jinja tojson filter wraps string values in double quotes. A contact name like "John Smith" produced onclick="showPurgeModal('id', "John Smith")" — the browser parsed the attribute as ending at the second double quote before the name, leaving the handler malformed and unbound. Fix: replaced inline onclick with data attributes (data-contact-id, data-contact-name using Jinja's | e filter for HTML encoding) and a delegated event listener on document. Lesson: never mix tojson output inside an HTML attribute delimited by double quotes — use data attributes and event delegation instead.
ISSUE GitHub Actions pipeline warning — "Node.js 20 actions are deprecated. actions/checkout@v4 and aws-actions/configure-aws-credentials@v4 may not work as expected." Hard deadline: Node.js 20 forced to Node.js 24 on June 16, 2026.
RESOLVED  No Node.js 24 compatible versions existed for these specific actions at time of fix. Added FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true to the env block in deploy.yml — opts the entire workflow into Node.js 24 without requiring version bumps on individual actions. Lesson: when action version bumps aren't available yet, FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 is the clean interim fix. Check for updated action versions periodically and bump them properly when they're released.
ISSUE Platform-wide analytics on /admin/analytics showing inflated quote counts — test account activity was being included in operator-facing metrics despite being filtered correctly on per-operator views.
RESOLVED  The TEST_ACCOUNT_EMAILS filter was applied to per-operator quote breakdowns but not to the platform-wide aggregation queries that drove the summary tiles. Two separate code paths — one filtered, one not — both contributing to the same displayed metrics. Fix: added FilterExpression=not_test_account to all DynamoDB scan and query operations on the analytics route. Lesson: when the same data is read through multiple code paths, each path needs its own filter. A filter applied in one place doesn't automatically apply everywhere that touches the same table.
ISSUE Creating a top-level Python directory named platform/ flagged as a stdlib shadowing risk before any code was written.
RESOLVED  The repo root is on sys.path. Any top-level directory named after a stdlib module becomes a local package that shadows the real one. Flask, Gunicorn, and boto3 all use Python's platform module internally for OS and version detection — shadowing it causes obscure import failures that are very difficult to diagnose. Named the platform layer directory whetstone/ instead. lambda/platform/ and templates/platform/ are safe — they are not Python packages and are not on sys.path. Lesson: never name a top-level Python package after a stdlib module. Verify with python3 -c "import <name>" before choosing a directory name.
ISSUE ECR repository needed to be renamed from whetstone-pricing to whetstone-platform. ECR repository names are immutable — no in-place rename exists in AWS or Terraform.
RESOLVED  Executed a create-migrate-delete sequence entirely within Terraform and GitHub Actions. Added a new app_v2 ECR resource alongside the existing one and applied — AWS created the new repo. Updated the ECS task definition image reference to the new repo URL and merged to main — the CI/CD pipeline pushed the image to the new repo and ECS deployed from it. Verified production was serving correctly, then deleted the old repo via AWS CLI and cleaned up Terraform state with terraform state rm and terraform state mv to rename app_v2 back to app. Final terraform apply showed zero changes. Lesson: for any immutable AWS resource rename, the pattern is always create-new, migrate traffic, delete old, then reconcile Terraform state. State surgery is safe and reversible — it only touches the state file, not AWS.
ISSUE Stripe coupon code writing as null to DynamoDB on checkout completion despite the promotion code being present in the Stripe dashboard. Standard dict and attribute access patterns all returned None.
RESOLVED  stripe.PromotionCode.retrieve() returns a StripeObject, not a plain dict. Calling .get('code'), ['code'], or getattr(obj, 'code', None) are all unreliable on these objects. str(promo_obj) produces valid JSON with all fields present. Fix: json.loads(str(promo_obj)).get('code'). Additional finding: when a checkout total is $0 due to a 100% coupon, Stripe sends an empty discounts array on the session object — the discount is on the subscription object instead, requiring a fallback retrieval path. Lesson: never use .get() or getattr() directly on Stripe SDK objects. Use json.loads(str(obj)) to get a plain dict with reliable field access.
ISSUE Parser health check email reporting "2 issue(s) detected" in the subject line but only showing one actual issue in the body. First Class Medical canary check failing despite the parser returning 77 products.
RESOLVED  Two separate bugs. First: the canary keyword 'dexcom g7' never matched FCM's product names because FCM uses 'G7 1 Pk...' without a Dexcom prefix — verified by running parse() locally and filtering output. Changed keyword to 'g7'. Second: the sheet URL was being appended to all_issues as its own list item, causing len(all_issues) to count it as an additional issue. Refactored to append the sheet URL inline to the last real issue entry instead. Lesson: canary keywords must match parser output exactly — check parse() output locally before writing canary strings. And when a counter drives user-visible output like an email subject line, audit every item that gets added to the list it counts.
04

Skills Demonstrated

// Cloud & Infrastructure
  • AWS ECS Fargate (production)
  • Application Load Balancer + ACM
  • VPC with public/private subnets
  • NAT Gateway
  • Amazon ECR + lifecycle policies
  • Route 53 alias records
  • CloudWatch logs + retention
  • AWS Secrets Manager
  • Amazon SNS
// Serverless & Backend
  • AWS Lambda (event-driven)
  • Amazon API Gateway
  • Amazon DynamoDB
  • Amazon Cognito (auth + access control)
  • Amazon SNS
  • Stripe webhook automation
  • Google Sheets API v4
  • Brevo SMTP integration
  • EventBridge scheduling
  • openFDA API
  • Multi-tenant CRM (DynamoDB)
// DevOps & Automation
  • Terraform IaC (full stack)
  • GitHub Actions CI/CD pipeline
  • Docker containerization
  • Rolling ECS deployments
  • IAM least-privilege design
  • DNS cutover management
  • Production incident documentation
  • Python / Flask application development
  • CloudWatch log stream pagination
  • Operator self-service flows