docs(plans): research AWS vs GCP free tier + create AWS port plan

- Research reports comparing AWS/GCP free tiers and greenfield trade-offs
- New AWS port plan with 7 phases (infrastructure, deployment, integration)
- Mark GCP plan deploy phases as superseded by new AWS plan
This commit is contained in:
2026-05-10 02:29:35 +07:00
parent 3aab95daf0
commit 29a1bface0
11 changed files with 1131 additions and 1 deletions
+3 -1
View File
@@ -14,6 +14,8 @@ supersedes: [260425-1945-mongodb-atlas-migration]
# Plan: Go port → Google Cloud Run (free tier)
> **2026-05-10:** Deploy phases (01, 0912) **superseded by [`plans/260510-0114-aws-port/`](../260510-0114-aws-port/plan.md)** — strict $0 free-tier goal motivated switch to AWS Lambda + DynamoDB + EventBridge. Module work (phases 0307) is done and **reused unchanged** by the AWS plan. Phase 08 (trading) remains pending, cloud-agnostic, can be tackled before or after the AWS cutover.
Full rewrite of miti99bot in Go for deployment on Cloud Run, swapping CF KV+D1+Workers AI for Firestore Native + Gemini API + Cloud Scheduler. Source repo lives at a new `miti99bot-go` repo (separate). Cutover via dual-run + soak.
## Locked decisions
@@ -45,7 +47,7 @@ Full rewrite of miti99bot in Go for deployment on Cloud Run, swapping CF KV+D1+W
| 04 | [Firestore KVStore + per-module prefixing](phase-04-firestore-kv.md) | done | 4h | `FirestoreKVStore`, emulator tests, KVProvider abstraction (Memory + Firestore) |
| 05 | [Port simple modules (util/misc/wordle/loldle)](phase-05-port-simple-modules.md) | done | 6h | 4 KV-only modules at JS parity; shared `internal/keylock` extracted |
| 06 | [Port loldle variants + lolschedule](phase-06-port-loldle-variants.md) | done | 5h | All five sub-modules ported (emoji, quote, ability, splash, lolschedule); lolschedule daily-push cron deferred to Phase 09 |
| 07 | [Gemini AI + port semantle/doantu/twentyq](phase-07-gemini-ai-modules.md) | pending | 6h | 3 AI modules with rate-limit handling |
| 07 | [Gemini AI + port semantle/doantu/twentyq](phase-07-gemini-ai-modules.md) | done | 6h | `internal/ai` (Embedder/Chatter + per-user bucket); semantle (text-embedding-004), doantu (phow2sim HTTP — JS-parity deviation), twentyq (gemini-2.5-flash) |
| 08 | [Port trading + composite indexes](phase-08-port-trading.md) | pending | 6h | VN-stocks paper trading + daily price cron |
| 09 | [Cloud Scheduler cron wiring](phase-09-cloud-scheduler.md) | pending | 2h | 2 jobs → `/cron/{name}` with OIDC |
| 10 | [CI/CD + Dockerfile + Secret Manager](phase-10-ci-cd.md) | pending | 4h | GHA pipeline → AR → Cloud Run, idempotent |
@@ -0,0 +1,69 @@
---
phase: 1
title: "AWS bootstrap + IAM OIDC + SAM skeleton"
status: pending
priority: P1
effort: "3h"
dependencies: []
---
# Phase 01: AWS bootstrap + IAM OIDC + SAM skeleton
## Overview
Stand up the AWS account with strict $0 footprint: IAM OIDC trust for GitHub Actions, baseline SAM stack that deploys an empty Lambda + DynamoDB table + Function URL placeholder. Nothing wired to real bot yet.
## Requirements
- **Functional:** Empty stack deploys via `sam deploy --guided` from local. GitHub Actions can assume the deploy role via OIDC (no long-lived keys).
- **Non-functional:** Region `ap-southeast-1`. Single AWS account. Stack name `miti99bot-aws-port`. All resources tagged `app=miti99bot, env=prod`. Strict free-tier resources only.
## Architecture
```
GitHub Actions ─OIDC─► AWS IAM Role (github-deploy)
│ └─ trust: token.actions.githubusercontent.com
│ └─ scoped: repo:tiennm99/miti99bot-go:ref:refs/heads/main
└─► CloudFormation (SAM) ─► Lambda + DynamoDB + ParamStore + EventBridge + Logs
```
## Related Code Files
- Create: `template.yaml` (SAM root, all resources declared here)
- Create: `samconfig.toml` (stack name, region, capabilities)
- Create: `aws/iam-github-oidc-trust.json` (one-shot reference doc, not deployed)
- Create: `aws/README.md` (commands cheat sheet for first-time setup)
- Create: `Makefile` (targets: `build`, `package`, `deploy`, `logs`)
- Modify: `.gitignore` (add `.aws-sam/`, `samconfig.toml.local`)
## Implementation Steps
1. Create AWS account (or reuse existing). Enable MFA on root, create IAM admin user for one-time bootstrap. Set region default `ap-southeast-1`.
2. Create the GitHub OIDC identity provider in IAM: thumbprint, audience `sts.amazonaws.com`. (One-time, manual or via small CloudFormation snippet.)
3. Create IAM role `github-deploy-miti99bot` with trust policy scoped to `repo:tiennm99/miti99bot-go:ref:refs/heads/main` and `repo:tiennm99/miti99bot-go:ref:refs/heads/dev`. Attach managed policies for SAM deploy: CloudFormation, Lambda, DynamoDB, EventBridge, IAM (PassRole only), SSM Parameter Store, Logs, S3 (SAM staging bucket).
4. Write `template.yaml` skeleton:
- `AWSTemplateFormatVersion: '2010-09-09'`, `Transform: AWS::Serverless-2016-10-31`
- `Globals.Function`: `Runtime: provided.al2023`, `Architectures: [arm64]`, `MemorySize: 256`, `Timeout: 15`, `Tracing: Active` (still free at this volume)
- `Resources.BotFunction`: empty handler (`bootstrap` not yet built), Function URL with `AuthType: NONE`
- `Resources.BotTable`: DynamoDB on-demand, PK=`pk` (S), no GSI yet
- Outputs: function URL, table name
5. Write `samconfig.toml` with stack name, region, capabilities (`CAPABILITY_IAM`).
6. First deploy: `sam build && sam deploy --guided` from local using bootstrap admin credentials. Confirm stack reaches `CREATE_COMPLETE`. Save the Function URL.
7. Verify GH Actions OIDC by running a one-shot workflow that calls `aws sts get-caller-identity` — confirms trust works without keys.
8. Manual smoke: `curl <function-url>` returns 502 (no handler yet) — proves URL is reachable.
## Success Criteria
- [ ] AWS account active, MFA on root, region default `ap-southeast-1`
- [ ] GitHub OIDC provider created
- [ ] `github-deploy-miti99bot` IAM role assumes successfully from a test GH Actions run
- [ ] `sam deploy` succeeds; stack `miti99bot-aws-port` in `CREATE_COMPLETE`
- [ ] DynamoDB table `miti99bot` exists, on-demand billing mode
- [ ] Function URL reachable (502 expected)
- [ ] AWS Cost Explorer shows $0 spend after 24h
## Risk Assessment
- **OIDC trust scope too loose** (any branch / any repo) → Mitigation: scope to specific repo + ref pattern; review `sub` claim in CloudTrail after first successful run.
- **IAM policy over-broad** → Mitigation: start with managed policies for speed, tighten in Phase 06 once resource ARNs stable.
- **SAM staging bucket created in wrong region / accumulates artifacts** → Mitigation: pin region in samconfig; add lifecycle rule (7-day expiration) on staging bucket.
- **CloudFormation drift** if user edits via console → Mitigation: forbid console edits, document in `aws/README.md`.
## Open questions
1. Single account vs separate dev/prod accounts? Single is simpler for solo dev; defer split until usage warrants.
2. Reuse SAM staging bucket from existing AWS work or fresh one? Fresh, scoped to this stack, easier to clean up.
3. Pin SAM CLI version in `Makefile`? Yes, document expected version (current latest works); rely on `setup-sam` action in CI to pin.
@@ -0,0 +1,78 @@
---
phase: 2
title: "Lambda runtime (Go ZIP + LWA + Function URL)"
status: pending
priority: P1
effort: "4h"
dependencies: [1]
---
# Phase 02: Lambda runtime (Go ZIP + LWA + Function URL)
## Overview
Make the existing Go HTTP server run as a Lambda behind a Function URL with zero handler-code changes, using AWS Lambda Web Adapter. Routes `/` (healthcheck) and `/webhook` work end-to-end with secret verification.
## Requirements
- **Functional:** Function URL responds to `GET /` with the existing health JSON, and to `POST /webhook` with the existing Telegram dispatcher logic. Secret-token verification (`X-Telegram-Bot-Api-Secret-Token`) preserved.
- **Non-functional:** Cold start P95 < 1.5s for ARM64 Go ZIP. Memory 256 MiB. Timeout 15s. Binary size <30 MiB.
## Architecture
```
Telegram ──HTTPS──► Function URL ──► Lambda runtime
├── LWA layer (extension) translates Lambda event → HTTP
│ └── localhost:8080 (LWA listens here)
└── bootstrap binary (existing Go server)
starts http.ListenAndServe(":8080", ...)
dispatcher → modules → DynamoDB / Gemini
```
LWA is added as a Lambda layer; binary just runs `http.ListenAndServe` — no Lambda SDK import required.
## Related Code Files
- Create: `cmd/server/lambda.go` (build-tag `lambda`, sets `PORT=8080` defaults; minimal — possibly empty)
- Modify: `cmd/server/main.go` — accept `PORT` env (likely already does), confirm graceful shutdown on `SIGTERM` (LWA sends it on shutdown)
- Modify: `template.yaml` — wire `BotFunction` properly:
- `CodeUri: build/` (ZIP staging)
- `Handler: bootstrap`
- `Layers: [arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:<latest>]`
- `Environment.Variables`: `AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap`, `PORT=8080`, `READINESS_CHECK_PATH=/`, `MODULES=util,misc,wordle,...`, `TELEGRAM_BOT_TOKEN={{resolve:ssm-secure:...}}`, etc.
- Modify: `Makefile` — add `build-lambda` target: `GOOS=linux GOARCH=arm64 go build -tags lambda.norpc -ldflags="-s -w" -o build/bootstrap ./cmd/server && chmod +x build/bootstrap`
- Reference: `internal/server/router.go` (unchanged)
- Reference: `internal/telegram/*.go` (unchanged)
## Implementation Steps
1. Confirm `cmd/server/main.go` reads `PORT` env (it does per inspection). Confirm graceful shutdown on `SIGTERM`/`SIGINT`.
2. Add `build-lambda` Makefile target. Test locally: `make build-lambda && file build/bootstrap` shows ARM64 ELF.
3. Pick latest LWA layer ARN for `ap-southeast-1` ARM64 — pin major version in `template.yaml` with comment linking to release notes.
4. Add Function env vars in `template.yaml`. Use `{{resolve:ssm-secure:...}}` for secrets so values never appear in template. Reference Phase 01's Parameter Store names.
5. Wire DynamoDB IAM permissions (read/write on table) via `Policies: - DynamoDBCrudPolicy`. Wire SSM read perms via `SSMParameterReadPolicy`.
6. Build + deploy: `sam build && sam deploy`. Tail logs: `sam logs --tail`.
7. Smoke test:
- `curl <function-url>/` → 200 with health JSON
- `curl -XPOST <function-url>/webhook -H "X-Telegram-Bot-Api-Secret-Token: wrong"` → 401
- `curl -XPOST <function-url>/webhook -H "X-Telegram-Bot-Api-Secret-Token: <real>" -d '{"update_id":1,"message":{"text":"/start","chat":{"id":1},"from":{"id":1}}}'` → 200 (or expected dispatcher response)
8. Set Telegram dev-bot webhook to Function URL. Send `/start` from real client. Confirm response in chat.
9. Capture cold-start P95 from CloudWatch Logs `Init Duration` field over 20+ invocations (use Powertools or grep). Record in this phase's "Risks" if >1s.
## Success Criteria
- [ ] `make build-lambda` produces ARM64 binary <30 MiB
- [ ] `sam deploy` updates `BotFunction` successfully
- [ ] `curl <function-url>/` returns health JSON
- [ ] Wrong webhook secret → 401
- [ ] Correct webhook secret + valid update → dispatcher responds
- [ ] Telegram dev bot exchanges messages end-to-end via Function URL
- [ ] Cold start P95 < 1.5s
## Risk Assessment
- **LWA cold-start tax** adds ~100ms — acceptable; if not, fall back to `lambda.Start()` adapter path (rewrite handler, more invasive).
- **`{{resolve:ssm-secure:...}}` requires CloudFormation perms** — SAM handles this if role has `ssm:GetParameter*`.
- **Webhook secret leakage in logs** — confirm `internal/server/router.go` does not log header value; if it does, redact.
- **Binary too large** (>50 MiB unzipped) — strip with `-ldflags="-s -w"` (already done); if still too big, audit deps with `go build -ldflags="-s -w" -trimpath` + `goweight`.
- **ARM64 incompatibility** with any cgo dep — confirm `CGO_ENABLED=0` in build (existing Dockerfile does this).
## Open questions
1. Should LWA `READINESS_CHECK_PATH` be `/` or a dedicated `/healthz`? `/` works since handler is cheap; revisit if `/` ever does work.
2. Telegram delivery reliability with cold-start 13s — acceptable in practice (Telegram retries), but document in README.
3. Provisioned concurrency to eliminate cold start — kills free tier, defer indefinitely.
@@ -0,0 +1,88 @@
---
phase: 3
title: "DynamoDB KV provider"
status: pending
priority: P1
effort: "4h"
dependencies: [1]
---
# Phase 03: DynamoDB KV provider
## Overview
Add `DynamoDBKVStore` + `DynamoDBProvider` as a sibling to the existing Firestore impl, satisfying the same `KVStore` / `KVProvider` interface. Selectable via `KV_PROVIDER=dynamodb|firestore|memory` env. Default in production: `dynamodb`. Firestore impl preserved for parity tests.
## Requirements
- **Functional:** All existing modules' KV ops (Get/Put/Delete/List + JSON convenience methods) work against DynamoDB with byte-for-byte parity to Firestore where observable.
- **Non-functional:** Single-table design. On-demand billing. P99 < 50ms for Get/Put. List() with prefix uses `Query` (not `Scan`) — must be cheap.
## Architecture
**Single-table schema (composite key):**
```
TableName: miti99bot-data
PK (pk): string = moduleName (e.g. "wordle")
SK (sk): string = caller-provided key (e.g. "user:42:state")
attrs:
value: binary raw bytes
updatedAt: number epoch nanos (parity with Firestore impl)
```
Composite key is the canonical DynamoDB shape for prefix-scan workloads: `Query` supports `begins_with(sk, :prefix)` on the **sort key**, but only `=` on the partition key — so the sort key holds the user-supplied key and the partition key holds the module name (which gives free isolation by partition).
**Operations:**
- `Get(key)``GetItem(pk=module, sk=key)` with `ConsistentRead: true` (parity with Firestore strong read)
- `Put(key, val)``PutItem(pk=module, sk=key, value=val, updatedAt=now)`
- `Delete(key)``DeleteItem(pk=module, sk=key)`
- `List(prefix)``Query(pk=module AND begins_with(sk, prefix))` paginated
**Provider isolation:** `For(moduleName)` returns a `DynamoDBKVStore` bound to that module name; the partition key is the isolation boundary. No prefix wrapping needed at this layer.
**Reserved word handling:** `value` is reserved in DynamoDB expressions; resolved via `ExpressionAttributeNames` (`#v``value`).
## Related Code Files
- Create: `internal/storage/dynamodb_client.go` — AWS SDK v2 client init, region from env
- Create: `internal/storage/dynamodb_kv.go``DynamoDBKVStore` (Get/Put/Delete/List + JSON helpers)
- Create: `internal/storage/dynamodb_provider.go``DynamoDBProvider`, `For()` returns module-bound store
- Create: `internal/storage/dynamodb_kv_test.go` — uses `localstack` or DynamoDB Local via `testcontainers-go`
- Create: `internal/storage/dynamodb_provider_test.go` — cross-module isolation
- Create: `internal/storage/parity_test.go` (optional) — runs the same op sequence against Memory + Firestore + DynamoDB and asserts identical observables
- Modify: `cmd/server/main.go` — read `KV_PROVIDER`, branch on value; default `dynamodb` when running on Lambda (detect via `AWS_LAMBDA_FUNCTION_NAME` env)
- Modify: `go.mod` — add `github.com/aws/aws-sdk-go-v2`, `…/config`, `…/service/dynamodb`, `…/feature/dynamodb/attributevalue`, `…/feature/dynamodb/expression`
## Implementation Steps
1. Add SDK deps. `go mod tidy`.
2. Implement `DynamoDBKVStore` with the same method set as `FirestoreKVStore`. Key composition: `pk = moduleName + "#" + key`.
3. `List(prefix)` implementation — `Query` with `KeyConditionExpression` on PK begins-with semantics (use range trick or `BEGINS_WITH` on PK; AWS docs: `BEGINS_WITH` works on sort key only, so use the start/end range trick on PK directly).
4. JSON helpers (`GetJSON`, `PutJSON`) — mirror Firestore impl exactly: marshal/unmarshal with `encoding/json`, store as binary, `ErrNotFound` semantics preserved.
5. Tests with DynamoDB Local (Docker image `amazon/dynamodb-local`). Add `make dynamodb-local` target. Skip if `DYNAMODB_LOCAL_URL` env unset (so CI without Docker can still build).
6. Cross-module isolation test: Put `wordle#k=A`, `loldle#k=B`, assert `wordleStore.Get("k") == A`, `loldleStore.Get("k") == B`, `loldleStore.List("") returns ["k"]` (not `[wordle#k, loldle#k]`).
7. Wire provider selection in `main.go`:
```go
switch os.Getenv("KV_PROVIDER") {
case "dynamodb": kv = storage.NewDynamoDBProvider(...)
case "firestore": kv = storage.NewFirestoreProvider(...)
default: kv = storage.NewMemoryProvider()
}
```
8. Manual smoke against deployed Lambda: send `/start`, then verify `aws dynamodb scan --table-name miti99bot --max-items 5` shows expected keys.
## Success Criteria
- [ ] `dynamodb_kv_test.go` passes against DynamoDB Local
- [ ] `dynamodb_provider_test.go` passes (cross-module isolation)
- [ ] `parity_test.go` (if added) passes — Memory ≡ Firestore ≡ DynamoDB on observables
- [ ] `KV_PROVIDER=dynamodb` works in deployed Lambda end-to-end
- [ ] One full game session of `/wordle` → state persists across invocations (cold-start safe)
- [ ] List() with prefix returns expected keys, no `Scan` calls in CloudWatch metrics
## Risk Assessment
- **`List` performance** if a module accumulates >1k keys — DynamoDB Query handles this fine via paginated results. Confirm caller iterates pages (or all calls fit in one page).
- **Item size limit (400 KB)** — modules generally store small JSON; document the cap and add a `len(val) > 380*1024 → error` guard.
- **Eventual consistency** — DynamoDB defaults to eventually consistent reads. Use `ConsistentRead: true` in `Get` to match Firestore's strong default. Costs 2× the RCU but on-demand absorbs it.
- **Reserved word `value`** — DynamoDB reserves many names; use `ExpressionAttributeNames` `#v = "value"` to avoid the conflict.
- **AWS SDK v2 cold-start tax** (~80ms) — acceptable; cache client instance globally in `init()` or pkg-level var.
## Open questions
1. TTL attribute for ephemeral keys (e.g. wordle daily state)? Add optional `ttl` Number attr; modules opt in via a new method or skip for v1.
2. Use single PK or PK+SK? Sticking with single PK for KISS — no current module needs sort-key queries.
3. Encryption — DynamoDB uses AWS-owned KMS by default (free); switch to AWS-managed only if compliance demands it.
4. Backup strategy — point-in-time recovery is paid; for free-tier hobby use, accept "no backup" and document.
@@ -0,0 +1,92 @@
---
phase: 4
title: "EventBridge cron wiring"
status: pending
priority: P2
effort: "3h"
dependencies: [2]
---
# Phase 04: EventBridge cron wiring
## Overview
Replace the planned Cloud Scheduler design with EventBridge Scheduler. Preserve the existing `/cron/{name}` HTTP route shape inside the Lambda by invoking the Function URL via Scheduler's HTTPS target. Auth via `X-Cron-Token` header sourced from Parameter Store.
## Requirements
- **Functional:** Two scheduled jobs fire on cron expressions matching the GCP plan (`0 17 * * *` for daily push, `0 1 * * *` for cleanup or whatever Phase 09 of GCP plan defined). Both routes execute against the live module dispatcher and complete within Lambda timeout.
- **Non-functional:** Token rotates without code changes (Parameter Store update). Failure retried 2× with exponential backoff. Dead letters logged.
## Architecture
**Decision:** HTTPS target (Function URL) over direct Lambda invoke. **Why:**
- Preserves the existing `/cron/{name}` route + dispatcher code from Phase 03 of GCP plan
- Local dev still works: `curl localhost:8080/cron/dailypush -H "X-Cron-Token: ..."`
- Single ingress path for observability (one URL, one log group)
- Direct invoke would require a separate Lambda entrypoint or routing on event shape — more code, less testable
**Trade-off accepted:** Slightly less AWS-idiomatic; HTTPS adds ~10ms latency vs direct invoke; not material here.
```
EventBridge Scheduler ─cron─► HTTPS POST <function-url>/cron/{name}
+ Header: X-Cron-Token: <from ParamStore>
+ AWS Sigv4 NOT used (Function URL AuthType: NONE)
└─► Lambda → router → dispatcher → cron handler
```
**Auth model:** Function URL `AuthType: NONE` (already set in Phase 02 for Telegram). Cron auth = shared-secret header verified server-side. The token lives in Parameter Store (`/miti99bot/prod/cron-token`) and is fetched by Scheduler at invoke time via `SECRETSMANAGER_SECRET` reference (Scheduler supports referencing Parameter Store via `secret reference` in target input transformer, OR plain text in target — for KISS, store the token in Scheduler's invocation HTTP target headers as a templated literal, but referenced from Parameter Store via SAM resource attribute).
**Simpler concrete approach:** SAM template reads the Parameter Store value at deploy time using `{{resolve:ssm-secure:...}}` in the schedule target's HTTP header config. Token rotation = update parameter, redeploy.
## Related Code Files
- Create: SAM resources `Resources.DailyPushSchedule` (AWS::Scheduler::Schedule)
- Create: SAM resources `Resources.CleanupSchedule` (or whichever second cron)
- Create: SAM resource `Resources.SchedulerExecutionRole` with `events:InvokeApiDestination` / equivalent for HTTPS targets (or use built-in `aws.UniversalTarget` for `https`)
- Modify: `internal/server/router.go` — confirm `/cron/{name}` validates `X-Cron-Token` against env-loaded value (currently has `cronAuthHeader = "X-Cron-Token"`, good)
- Modify: `cmd/server/main.go` — load `CRON_TOKEN` env from Parameter Store reference, pass to `Config.CronToken`
- Reference: existing module cron registrations in each module's `Cron()` method
## Implementation Steps
1. Define SAM `AWS::Scheduler::Schedule` for each cron job:
```yaml
DailyPushSchedule:
Type: AWS::Scheduler::Schedule
Properties:
ScheduleExpression: "cron(0 17 * * ? *)" # 17:00 UTC = 00:00 Saigon
FlexibleTimeWindow: { Mode: 'OFF' }
Target:
Arn: arn:aws:scheduler:::http-invoke
RoleArn: !GetAtt SchedulerRole.Arn
Input: '{"name":"dailypush"}'
HttpParameters:
HeaderParameters: { X-Cron-Token: '{{resolve:ssm-secure:/miti99bot/prod/cron-token:1}}' }
RetryPolicy: { MaximumRetryAttempts: 2, MaximumEventAgeInSeconds: 600 }
DeadLetterConfig: { Arn: !GetAtt CronDLQ.Arn }
FlexibleTimeWindow: { Mode: OFF }
```
*(Pseudo — confirm exact `aws.HttpInvoke` target syntax against current AWS SAM docs at deploy time; AWS docs note the API surface is evolving.)*
2. Add `CronDLQ` (SQS queue, free tier 1M req/mo).
3. Add `SchedulerRole` IAM with `lambda:InvokeFunctionUrl` (or `events:InvokeApiDestination` if going via API destination).
4. Provision `/miti99bot/prod/cron-token` in Parameter Store with a 32-byte random value (`openssl rand -hex 32`).
5. Verify router rejects requests with wrong/missing token (test exists; confirm).
6. Deploy. From AWS console, "run now" each schedule. Confirm CloudWatch log entry shows successful 200 from Lambda.
7. Wait one full schedule window (or change to `rate(2 minutes)` temporarily) to confirm automatic firing.
8. Restore production cron expressions. Confirm next-fire timestamp.
## Success Criteria
- [ ] Both schedules deploy via SAM
- [ ] Manual "run now" returns HTTP 200 from Function URL
- [ ] Server logs show cron handler executing the right module
- [ ] Wrong/missing token → 401, no module side effects
- [ ] DLQ receives failed invocations on simulated Lambda error
- [ ] Schedule fires automatically once on production cron expression
## Risk Assessment
- **AWS Scheduler HTTPS target maturity** — relatively new feature; if SAM transform doesn't support `aws.HttpInvoke` cleanly, fall back to: Scheduler → SNS → Lambda subscription → existing handler (one extra hop, identical effect). Document fallback in this file.
- **Token in template via `resolve`** — at deploy time the value is fetched and embedded into the schedule target's static config; rotation requires redeploy. If frequent rotation needed, switch to a Lambda authorizer pattern (out of scope for v1).
- **Cron drift / TZ confusion** — EventBridge `cron()` uses UTC by default (matches Cloud Scheduler behavior in GCP plan). Use `?` for day-of-week-or-month constraint per AWS syntax.
- **Cold-start during cron** — first invocation after idle = 13s; cron handler logic must complete within Lambda timeout (15s). If a cron handler calls Gemini and exceeds 15s, raise function timeout to 30s (still free).
## Open questions
1. Direct Lambda invoke vs HTTPS target — locked to HTTPS for the reasons above; revisit only if HTTPS proves flaky.
2. Single schedule with dynamic `name` vs one schedule per cron — one per cron is clearer in console; switch to dynamic only if cron count grows past ~5.
3. Cleanup cron (`0 1 * * *`) — confirm what it does in original miti99bot. Likely TTL-style sweep; review and decide if DynamoDB TTL attribute can replace it (eliminates the cron entirely).
@@ -0,0 +1,96 @@
---
phase: 5
title: "GitHub Actions deploy (OIDC + SAM)"
status: pending
priority: P2
effort: "3h"
dependencies: [2, 3, 4]
---
# Phase 05: GitHub Actions deploy (OIDC + SAM)
## Overview
Push to `main` → CI builds the ARM64 Go binary, packages into ZIP, runs `sam deploy` against the existing stack via OIDC-assumed role. No long-lived AWS keys. Idempotent (zero-diff deploys are no-ops).
## Requirements
- **Functional:** PR validates (`go vet`, `go test`, `sam validate`). Push to `main` deploys. Manual workflow_dispatch redeploy supported.
- **Non-functional:** Deploy < 4 min. Concurrency: only one deploy at a time per ref. Stack name parameterized by env (default `prod`).
## Architecture
```
GitHub push to main
└─► .github/workflows/deploy.yml
1. checkout
2. setup-go (1.25)
3. setup-sam
4. configure-aws-credentials (OIDC) ─► assume github-deploy-miti99bot
5. make build-lambda
6. sam build --use-container=false
7. sam deploy --no-confirm-changeset --no-fail-on-empty-changeset
8. post-deploy smoke (curl <function-url>/)
```
## Related Code Files
- Create: `.github/workflows/deploy.yml`
- Create: `.github/workflows/ci.yml` — maybe split out validate-only path; or add `if:` guard in `deploy.yml`
- Modify: existing `.github/workflows/ci.yml` — add `sam validate` step
- Modify: `Makefile``deploy` target runs `sam build && sam deploy --no-confirm-changeset`
## Implementation Steps
1. Confirm Phase 01's IAM role trust policy includes `repo:tiennm99/miti99bot-go:ref:refs/heads/main` and `repo:tiennm99/miti99bot-go:pull_request` (for PR validation flow if `sam validate` against AWS).
2. Write `deploy.yml`:
```yaml
name: Deploy to AWS
on:
push: { branches: [main] }
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency: { group: deploy-prod, cancel-in-progress: false }
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.25' }
- uses: aws-actions/setup-sam@v2
with: { use-installer: true }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-deploy-miti99bot
aws-region: ap-southeast-1
- run: make build-lambda
- run: sam build
- run: sam deploy --no-confirm-changeset --no-fail-on-empty-changeset --stack-name miti99bot-aws-port
- name: Smoke test
run: |
URL=$(aws cloudformation describe-stacks --stack-name miti99bot-aws-port --query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" --output text)
curl -fsSL "$URL/" | jq .
```
3. Add `AWS_ACCOUNT_ID` to GitHub repo secrets (it's not a credential, but keeping the ARN out of the repo file is hygiene).
4. PR validation workflow (`ci.yml`): runs `go vet`, `go test`, `sam validate` (no AWS creds needed for validate).
5. Test the full path: open a PR with a trivial change → CI green; merge → deploy fires; smoke step prints health JSON.
6. Add a `rollback.yml` workflow_dispatch path: re-run with a chosen commit SHA. CloudFormation handles the rollback inherently.
## Success Criteria
- [ ] PR triggers `ci.yml` only (no AWS deploy)
- [ ] Merge to `main` triggers `deploy.yml`
- [ ] Deploy succeeds without manual intervention
- [ ] Post-deploy smoke step returns 200 from Function URL
- [ ] Concurrency lock prevents overlapping deploys
- [ ] Re-running a no-op deploy reports "no changes" and exits 0
- [ ] No AWS access keys in repo, GitHub Actions secrets, or anywhere
## Risk Assessment
- **OIDC trust misconfiguration** locks deploy out — Mitigation: keep bootstrap admin user (Phase 01) as glass-break recovery; rotate after 90 days.
- **`sam build` with native deps fails** — pure Go has no native deps; should be fine.
- **CloudFormation drift** between manual changes and CI — Mitigation: forbid console edits; daily `sam deploy --no-execute-changeset` to detect.
- **Build cache cold each run** (~90s for Go deps) — Mitigation: `actions/setup-go` cache enabled by default.
- **Workflow secret leak via debug logs** — Mitigation: never echo `secrets.*`, GH masks them automatically.
## Open questions
1. Separate dev/staging stacks for PR previews? Out of scope for v1; `prod` only.
2. Slack/Telegram notification on deploy success/failure? Defer; CloudWatch logs + `gh run list` suffice initially.
3. Pin SAM version in `setup-sam`? Yes — pin to a specific version to avoid surprise breakage.
@@ -0,0 +1,99 @@
---
phase: 6
title: "Observability + budget alert"
status: pending
priority: P2
effort: "2h"
dependencies: [5]
---
# Phase 06: Observability + budget alert
## Overview
Wire CloudWatch Logs retention, metric filters for key counters, AWS Budgets $1/mo alert, and capture cold-start P95 baseline for the abort criterion in `plan.md`.
## Requirements
- **Functional:** Log retention 7 days. Budget alert fires at $1.00 actual. Cold-start P95 measurable from logs.
- **Non-functional:** All observability stays in free tier (5 GB log ingest/mo, 10 custom metrics free, 1k AWS Budgets API ops free).
## Architecture
- **Logs:** Lambda's auto-created log group `/aws/lambda/miti99bot-aws-port-BotFunction-*`. SAM sets retention.
- **Metrics:** Existing `internal/metrics` package emits counters. Lambda env can ship them via stdout — CloudWatch Logs ingests, log-based metric filters extract `request.duration`, `module.dispatched`, `cron.fired`. Free metric filter quota: unlimited filters, paid for resulting metrics past 10/mo.
- **Budget:** `AWS::Budgets::Budget` in SAM template, threshold $1, email alert.
- **Cold start:** parse `REPORT` log lines → `Init Duration` field → P50/P95/P99.
## Related Code Files
- Modify: `template.yaml` — add `LogRetentionInDays: 7` on `BotFunction` (SAM `LoggingConfig`); add `AWS::Budgets::Budget` resource; add `AWS::Logs::MetricFilter` for key metrics
- Create: `aws/dashboards/cold-start-coldwatch.json` (optional, manual import)
- Reference: `internal/log/*.go` (slog JSON emitter — already exists)
- Reference: `internal/metrics/*.go` (counters + 60s flush — already exists)
## Implementation Steps
1. Add to `template.yaml` under `BotFunction.Properties`:
```yaml
LoggingConfig:
LogFormat: JSON
ApplicationLogLevel: INFO
SystemLogLevel: WARN
LogGroup: !Ref BotFunctionLogGroup
```
2. Add explicit log group to control retention:
```yaml
BotFunctionLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/miti99bot-aws-port-bot
RetentionInDays: 7
```
3. Add metric filter for cold start:
```yaml
ColdStartFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref BotFunctionLogGroup
FilterPattern: '[report="REPORT", ..., init_label="Init", init_dur_label="Duration:", init_dur, ...]'
MetricTransformations:
- MetricName: ColdStartInitDuration
MetricNamespace: miti99bot
MetricValue: $init_dur
```
4. Add `AWS::Budgets::Budget` (sends email at 80% and 100% of $1):
```yaml
MonthlyBudget:
Type: AWS::Budgets::Budget
Properties:
Budget:
BudgetName: miti99bot-monthly
BudgetLimit: { Amount: '1.00', Unit: 'USD' }
TimeUnit: MONTHLY
BudgetType: COST
NotificationsWithSubscribers:
- Notification: { ComparisonOperator: GREATER_THAN, NotificationType: ACTUAL, Threshold: 80, ThresholdType: PERCENTAGE }
Subscribers: [{ Address: <email>, SubscriptionType: EMAIL }]
```
5. Deploy. Trigger cold start (`aws lambda update-function-configuration --function-name … --environment 'Variables={…,FORCE_RESTART=$(date +%s)}'`).
6. Capture 50 cold starts manually or via a one-shot load script (`hey -n 50 -c 1 -i 30s <function-url>/`) — concurrency=1 with delay forces fresh inits. Compute P95 from CloudWatch Insights:
```
filter @type = "REPORT"
| stats avg(@initDuration), pct(@initDuration, 95)
```
7. Record P95 in `plan.md`'s "Free-tier budget at peak" or as an addendum here.
8. Confirm budget shows up in AWS Console > Budgets and has the email subscriber.
## Success Criteria
- [ ] Log group `RetentionInDays: 7` set
- [ ] Cold-start P95 captured and < 1.5s (per abort criterion)
- [ ] Budget alert visible in console, email subscriber confirmed (test mail received)
- [ ] No log group accumulates >500 MiB after 7 days of normal traffic
- [ ] CloudWatch Insights query for cold start works without manual setup
## Risk Assessment
- **Email subscriber not confirmed** → first alert silently dropped — Mitigation: send a test from console before relying on it.
- **Log retention deleted by SAM redeploy** if log group not explicit → Mitigation: declare log group explicitly (step 2).
- **Cold start drift** as deps grow — Mitigation: re-measure quarterly; add a CI step that fails if `bootstrap` binary > 30 MiB.
- **Budget delays** (AWS Budgets evaluates ~3× day, not real-time) → Mitigation: also enable Cost Anomaly Detection (free) for spike alerts.
## Open questions
1. Email vs SNS topic for budget alerts? Email is simpler; SNS lets fan-out to webhook later. Start with email, migrate if needed.
2. Custom CloudWatch dashboard? Skip for v1 — Insights queries are enough for solo-dev.
3. Trace via AWS X-Ray? `Tracing: Active` already set in Phase 02 globals — free at this volume; review traces post-Phase 07.
@@ -0,0 +1,83 @@
---
phase: 7
title: "Cutover + README + retire GCP paths"
status: pending
priority: P2
effort: "3h"
dependencies: [2, 3, 4, 5, 6]
---
# Phase 07: Cutover + README + retire GCP paths
## Overview
Flip the production Telegram webhook to the AWS Function URL, soak for 7 days, then mark the AWS port as default in README and code defaults. Keep GCP code paths in tree (Firestore impl, Cloud Run Dockerfile) but unwired by default.
## Requirements
- **Functional:** Real production bot serves users from Lambda. No regressions vs prior baseline (whatever ran before — JS Worker or partial GCP).
- **Non-functional:** Zero downtime cutover (Telegram webhook flip is atomic). 7-day soak with logs reviewed daily. Fallback path documented.
## Architecture
- **Cutover op:** `setWebhook` Telegram API call pointing to the AWS Function URL with the production webhook secret.
- **Rollback path:** identical `setWebhook` call to the prior URL; ~5 second op.
- **Code:** Firestore impl stays compilable, gated by `KV_PROVIDER=firestore`. Default `KV_PROVIDER=dynamodb` in Lambda env. Cloud Run Dockerfile retained for offline / non-AWS users.
## Related Code Files
- Modify: `README.md` — full rewrite of "Run locally", "Build", new "Deploy to AWS" section, status table updated, link to AWS plan, archive link to GCP plan
- Modify: `cmd/server/main.go` — default `KV_PROVIDER` selection logic: `dynamodb` if `AWS_LAMBDA_FUNCTION_NAME` set, else `memory`
- Create: `docs/deploy-aws.md` — single source of truth for AWS deploy ops (parameter store names, IAM role ARN, smoke commands)
- Modify: `plans/260508-2222-go-port-cloud-run/plan.md` — top-of-file note: "Deploy phases 01, 0912 superseded by `plans/260510-0114-aws-port/`. Module work (phases 0307) reused unchanged."
- Optional remove: `Dockerfile` retained for now; revisit in 30 days
- Optional remove: GCP-specific docs in `docs/` if any (none observed)
## Implementation Steps
1. Pre-flight checklist (run inside this phase):
- [ ] Phase 02 smoke green (manual curl)
- [ ] Phase 03 wordle daily state survives deploy + cold start
- [ ] Phase 04 cron fired at least one real trigger
- [ ] Phase 05 push-to-main auto-deploys
- [ ] Phase 06 budget alert email confirmed
- [ ] Cold-start P95 < 1.5s confirmed
2. Run `setWebhook` against production bot:
```sh
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-d "url=$AWS_FUNCTION_URL/webhook" \
-d "secret_token=$TELEGRAM_WEBHOOK_SECRET" \
-d "drop_pending_updates=false" \
-d "allowed_updates=[\"message\",\"callback_query\"]"
```
3. Verify with `getWebhookInfo`:
```sh
curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo" | jq .
```
Confirm `url`, `pending_update_count` near 0, `last_error_date` empty.
4. Send a test command (`/start`, `/wordle`, `/twentyq` to exercise Gemini path). Confirm responses match prior behavior.
5. Soak for 7 days: each morning, check CloudWatch Logs for ERROR / WARN, DynamoDB throttle metrics (should be zero), budget current spend (should be $0), Gemini RPD usage (should be far under cap).
6. After 7-day soak, update README:
- Status table: AWS port phases marked "done"
- Replace "Run locally" with two paths: in-memory (no AWS) and DynamoDB Local (with AWS deps)
- "Deploy" section: link `docs/deploy-aws.md`, drop Cloud Run instructions
- Status badge / link to `plans/260510-0114-aws-port/`
7. Add a top-of-file note in `plans/260508-2222-go-port-cloud-run/plan.md` redirecting deploy questions to the AWS plan.
8. Tag a release: `git tag v1.0.0-aws -m "AWS deploy default"` and push.
## Success Criteria
- [ ] Telegram webhook `getWebhookInfo` shows AWS Function URL
- [ ] Production bot answers `/start` from real users with normal latency
- [ ] 7-day soak: zero unrecovered errors, zero throttles, zero unexpected spend
- [ ] README accurately reflects AWS as the default deploy
- [ ] `docs/deploy-aws.md` is sufficient for a fresh dev to redeploy from scratch
- [ ] GCP plan file annotated; old phase files preserved for history
- [ ] Release tag pushed
## Risk Assessment
- **Webhook flip causes message loss** during the few-second propagation — Telegram retries failed deliveries automatically; `drop_pending_updates=false` preserves backlog.
- **Latency regression vs JS Worker** — Cloud Run / JS Worker had different cold-start profiles; if users complain, document and consider ARM→x86 swap or provisioned concurrency (kills free tier).
- **Hidden Firestore dependency** still wired in some module — Mitigation: grep for `firestore.NewClient` and confirm all paths are gated by `KV_PROVIDER=firestore` env. Add a CI test that builds with `KV_PROVIDER=dynamodb` and asserts Firestore client is not initialized.
- **GCP project quietly billing** because resources weren't deleted — Mitigation: explicit step in this phase: `gcloud projects delete <project>` OR `gcloud run services delete` for any deployed services. Check Cloud Console for any orphaned resources.
- **Lambda Web Adapter unsupported on a future runtime** — Mitigation: pin LWA layer version, monitor AWS Labs repo.
## Open questions
1. Delete the old GCP project entirely or leave it dormant? Dormant is safe (no GCP free-tier abandonment penalty); delete after 30 days if no regret.
2. Keep Dockerfile in repo? Yes — useful for non-Lambda local runs and as reference for any future Cloud Run revival.
3. Keep Firestore impl forever or drop after 90 days? Drop only if the parity test proves redundant; the impl itself is small and tested.
4. Announce the change anywhere (README badge, release notes) — depends on whether this is a public bot. User decides.
+77
View File
@@ -0,0 +1,77 @@
---
title: "Migrate miti99bot-go from GCP to AWS (Lambda + DynamoDB + EventBridge, free tier)"
description: "Re-target the deploy/runtime layer from Cloud Run + Firestore + Cloud Scheduler to Lambda (Go ZIP + LWA + Function URL) + DynamoDB on-demand + EventBridge Scheduler, region ap-southeast-1, IaC via SAM, CI via GH Actions OIDC. Module code unchanged."
status: in-progress
priority: P2
effort: 3-4d
branch: main
tags: [aws, lambda, dynamodb, eventbridge, sam, port, telegram-bot, free-tier]
created: 2026-05-10
blockedBy: []
blocks: []
supersedes-deploy-of: [260508-2222-go-port-cloud-run]
---
# Plan: AWS port (Lambda + DynamoDB + EventBridge, free tier)
Re-target only the deploy/runtime layer. Module work (Phases 0307 of GCP plan) is **done and reused unchanged**. The KVStore interface (`internal/storage/`) absorbs the swap; `http.Handler` code (`internal/server/`) is preserved via Lambda Web Adapter.
## Context
- **Why switch:** Strict $0 free-tier goal — DynamoDB 25 GiB / 200M req-mo, EventBridge unlimited rules, 100 GB egress all-region beat Firestore 1 GiB, Cloud Scheduler 3-job cap, GCP NA-only egress. See `plans/reports/research-260510-0021-aws-vs-gcp-greenfield-rethink.md`.
- **Reused as-is:** module framework, registry, dispatcher, Telegram lib, AI clients, all 11 modules, Firestore impl (kept as sibling for parity tests).
- **Replaced:** Cloud Run → Lambda; Firestore → DynamoDB (sibling provider, default switchable via env); Cloud Scheduler → EventBridge Scheduler; Secret Manager → Parameter Store; Artifact Registry → none (ZIP); Cloud Logging → CloudWatch Logs; CF Worker / GCP CI → GH Actions + SAM.
## Locked decisions
- **Compute:** Lambda Go on `provided.al2023`, **ARM64**, ZIP package, binary `bootstrap`, build with `-tags lambda.norpc -ldflags="-s -w"`.
- **HTTP:** Lambda Function URL (`AuthType: NONE`) + AWS Lambda Web Adapter layer → existing `http.Handler` runs unchanged.
- **KV:** DynamoDB single-table `miti99bot`, PK=`pk` (`{module}#{key}`), attr `value` (Binary). On-demand billing.
- **Cron:** EventBridge Scheduler → HTTPS target = Function URL `/cron/{name}` with `X-Cron-Token` header (token in Parameter Store). Preserves existing route shape; alternative (direct Lambda invoke) deferred.
- **Secrets:** SSM Parameter Store SecureString. Names: `/miti99bot/{env}/telegram-token`, `…/webhook-secret`, `…/gemini-api-key`, `…/cron-token`. Fetched at cold start.
- **Region:** `ap-southeast-1` (Singapore).
- **IaC:** AWS SAM (`template.yaml`).
- **CI:** GitHub Actions, OIDC role, `aws-actions/configure-aws-credentials@v4` + `aws-actions/setup-sam@v2`.
- **Logs:** CloudWatch Logs, 7-day retention.
- **Cost guard:** AWS Budgets $1/mo alert.
## Phases
| # | Phase | Status | Effort | Key deliverable |
|---|-------|--------|--------|-----------------|
| 01 | [AWS bootstrap + IAM OIDC + SAM skeleton](phase-01-aws-bootstrap.md) | pending | 3h | AWS account, OIDC trust, empty SAM stack deployable |
| 02 | [Lambda runtime (Go ZIP + LWA + Function URL)](phase-02-lambda-runtime.md) | pending | 4h | `/` and `/webhook` served from Lambda; secret-token check passes |
| 03 | [DynamoDB KV provider](phase-03-dynamodb-kv.md) | pending | 4h | `dynamodb_kv.go` + `dynamodb_provider.go` sibling to Firestore impl, parity tests pass |
| 04 | [EventBridge cron wiring](phase-04-eventbridge-cron.md) | pending | 3h | Scheduler → `/cron/{name}` with token, two crons firing on schedule |
| 05 | [GitHub Actions deploy (OIDC + SAM)](phase-05-gha-deploy.md) | pending | 3h | `deploy.yml` runs on push to `main`, builds + sam deploys idempotently |
| 06 | [Observability + budget alert](phase-06-observability.md) | pending | 2h | Logs retention set, $1 budget alert, cold-start P95 captured |
| 07 | [Cutover + README + retire GCP paths](phase-07-cutover.md) | pending | 3h | Webhook flipped to Function URL, README rewritten, GCP code paths kept but unwired by default |
## Dependency graph
```
01 ──► 02 ──► 03 ──► 04 ──► 05 ──► 06 ──► 07
└──► 04 ─────►┘
```
## Free-tier budget at peak
| Resource | Cap | Expected | Headroom |
|---|---|---|---|
| Lambda req | 1M/mo | ~30k/mo | 97% |
| Lambda compute | 400k GB-s | <5k | 99% |
| DynamoDB req | 200M/mo | <100k | 99.9% |
| DynamoDB storage | 25 GiB | <50 MiB | 99.8% |
| EventBridge invocations | 14M/mo | ~60 (2 crons × ~30 days) | 99.9% |
| Parameter Store accesses | unlimited (Standard) | <100/cold-start × ~30 starts | n/a |
| Egress | 100 GB/mo | <50 MiB | 99.95% |
| CloudWatch Logs ingest | 5 GB/mo | <500 MiB | 90% |
## Abort criteria
- **Cold-start P95 > 1.5s** sustained: investigate ARM64→x86_64 swap or pre-warm with provisioned concurrency (kills free tier; only if user-facing latency unacceptable).
- **DynamoDB throttle** under normal load: switch to provisioned mode (still free under 25 RCU/WCU).
- **Function URL auth-bypass risk** discovered: switch to API Gateway HTTP API (12-month free, then $1/M).
## Rollback
Until Phase 07 webhook flip, the GCP runtime path remains intact. Per-phase rollback documented in each phase. Phase 07 is the only irreversible cutover step.
## Open questions
1. Direct Lambda invoke for cron vs HTTP loopback via Function URL — final call deferred to Phase 04 implementation.
2. Whether to delete Firestore impl after parity confirmed, or keep as offline test backend permanently.
3. Single SAM stack vs split (data + compute) — start single, split if iteration speed suffers.
@@ -0,0 +1,241 @@
# Research Report: AWS vs GCP Free Tier — Suitability for miti99bot-go
> **Generated:** 2026-05-10 00:12 (Asia/Saigon)
> **Mode:** /research + /ck:brainstorm (ultrathink)
> **Verdict (TL;DR):** **GCP wins, decisively, not even close.** Stop debating. The code is already written for it, free tier is plenty, switching cost > zero benefit.
---
## Executive Summary
This project (miti99bot-go) is a Go Telegram bot already coded against **Cloud Run + Firestore + Gemini**, with multi-stage Docker → distroless image (~15 MiB), Phase 04 Firestore KV done, Phases 0507 modules + AI done, only deploy/cron/CI left. Asking "should we switch to AWS for free tier?" is asking "should we throw away weeks of work for a free-tier delta that doesn't matter at this traffic profile?"
The honest answer is no. Both clouds offer permanent (not 12-month) free tiers that cover a personal Telegram bot 100x over. The deciding factor is **integration friction and switching cost**, not headroom. GCP's Cloud Run runs your existing `http.Handler` unmodified; AWS Lambda needs an adapter or rewrite. Firestore is already wired; DynamoDB would force a new KV provider. Cloud Scheduler is two lines of YAML; EventBridge Scheduler needs IAM gymnastics. And Gemini API is **independent of cloud** — same key works on AWS, so AWS gains nothing on the AI side.
The only scenario where AWS wins: if you needed >1 GiB egress/month (AWS gives 100 GB always-free) — irrelevant here, Telegram messages are tiny text.
---
## Research Methodology
- **Sources:** 5 web searches (Cloud Run/Firestore quotas, AWS Lambda/DynamoDB quotas, Telegram-bot deployment patterns, Gemini API free tier, Lambda-vs-Cloud-Run cold starts/egress)
- **Date range:** Sources from 2025-2026 with explicit 2026 quota figures
- **Project context:** Read `README.md` and existing plan structure (`plans/260508-2222-go-port-cloud-run/`)
- **Search keywords:** GCP Always Free 2026, AWS Always Free 2026, Cloud Run egress, Lambda Go cold start, Gemini API free tier 2026
---
## Project Profile (the part that decides everything)
| Aspect | Reality |
|---|---|
| Workload | Telegram bot webhook receiver. Few users. Bursty, low-volume. |
| Already coded for | **Cloud Run** (HTTP server in `cmd/server/`, `/webhook` route) |
| Storage | **Firestore** KVStore impl done (Phase 04) |
| AI | **Gemini API** (cloud-agnostic — uses Google AI Studio key, not GCP-bound) |
| Cron | Planned Cloud Scheduler → `/cron/{name}` (Phase 08+) |
| Container | Multi-stage `golang:1.23-alpine``distroless/static:nonroot`, ~15 MiB |
| Phases done | 0207 (modules, framework, AI). Phase 01 (deploy) and 08+ pending. |
**Observation:** The hard work is done. Deploy is a config exercise, not a redesign. Switching clouds = redo Phases 02 + 04 + 08, partially.
---
## Key Findings
### 1. GCP Always Free Tier (relevant slice, 2026)
| Service | Always-Free Quota | Bot Usage Estimate | Headroom |
|---|---|---|---|
| **Cloud Run** | 2M req/mo, 360k GiB-s mem, 180k vCPU-s, 1 GiB egress/mo (NA) | ~10k req/mo (low-volume bot) | ~200x |
| **Firestore (Native)** | 1 GiB storage, 50k reads/day, 20k writes/day, 20k deletes/day, 10 GiB egress/mo | <1k ops/day per user | ~50x |
| **Cloud Scheduler** | 3 jobs free | Bot needs ~13 cron jobs | exact fit |
| **Artifact Registry** | 0.5 GB free | distroless image ~15 MiB | ~30x |
| **Secret Manager** | 6 active versions, 10k accesses/mo | 1 secret (bot token) | trivial |
| **Cloud Build** | 120 build-min/day | A few CI builds/day | fine |
**2026 caveat:** Starting Feb 3, 2026, projects need Blaze (pay-as-you-go) plan to keep default Cloud Storage buckets — but Always-Free quotas still apply, and **this project doesn't use Cloud Storage** (uses Firestore + Artifact Registry). So: enable Blaze, set a $0 budget alert, free tier still works.
### 2. AWS Always Free Tier (relevant slice, 2026)
| Service | Always-Free Quota | Notes |
|---|---|---|
| **Lambda** | 1M req/mo + 400k GB-s | Permanent. Plenty for a bot. |
| **DynamoDB** | 25 GB storage, 200M req/mo (on-demand) | Way more than Firestore's free tier. |
| **EventBridge Scheduler** | 14M scheduled events/mo (default bus) | Plenty. |
| **Lambda Function URL** | Free (no API Gateway needed) | Avoids the 12-month-only API Gateway free tier. |
| **Parameter Store (Standard)** | Free unlimited | Use for bot token. Skip Secrets Manager (paid after 30 days). |
| **Egress** | 100 GB/mo always-free (since Dec 2024) | 100x Cloud Run's 1 GiB. Irrelevant for tiny Telegram messages. |
| **ECR private** | 500 MB **12-month only** ❌ | Use Docker Hub or GHCR free instead. Or pay ~$0.05/GB-mo. |
**Hidden trap:** Many AWS tutorials use API Gateway (1M HTTP API calls = 12-month tier, **not always-free**). To stay free permanently on AWS, you must use **Lambda Function URLs** instead.
### 3. Gemini API (decisive: cloud-agnostic)
- Free tier from **Google AI Studio**, not GCP — same API key works from AWS, GCP, your laptop, anywhere.
- Limits (May 2026): 515 RPM, 1001000 RPD per model, 250k TPM universal cap.
- Models on free tier: 2.5 Pro / 2.5 Flash / 2.5 Flash-Lite. **Gemini 3.x is paid-only in preview.**
- **April 2026 change:** Pro models tightening; Flash/Flash-Lite still free.
- **AWS doesn't disadvantage Gemini.** Choosing AWS doesn't lose Gemini access. Choosing GCP doesn't grant extra Gemini quota.
### 4. Cold Start & Latency
| Platform | Go cold start | Warm latency |
|---|---|---|
| Lambda Go ZIP | 50200 ms | <5 ms |
| Lambda container (Go) | 0.61.4 s | <5 ms |
| Cloud Run (distroless Go, min-instances=0) | 13 s | <10 ms |
| Cloud Run (min-instances=1) | none | <10 ms — **costs money** (no longer free) |
For Telegram webhooks: Telegram retries on timeout, and a 13 s cold start is acceptable for human-perceived bot latency. **Not a real differentiator at this scale.**
### 5. Egress
- Cloud Run free egress: 1 GiB/mo NA (then $0.12/GB)
- AWS free egress: 100 GB/mo
- Bot egress = small JSON replies to api.telegram.org. ~500 bytes × 10k msgs/mo ≈ 5 MB. **Both wildly under quota.** Non-issue.
---
## Comparative Analysis
### Free-tier headroom at this workload: **TIE** (both 100x more than needed)
### Switching cost (the actual decision driver):
| Item | Stay GCP | Switch to AWS |
|---|---|---|
| HTTP handler in `internal/server/` | works as-is | rewrite to Lambda handler OR add lambda-web-adapter |
| `internal/storage/` Firestore impl | already done | rewrite as DynamoDB provider, retest, redeploy |
| Cron `/cron/{name}` routes | wire Cloud Scheduler (3 lines YAML) | wire EventBridge Scheduler + IAM role + invocation target |
| Container registry | Artifact Registry (gcloud auth) | ECR (12-mo free only) or GHCR (free, simpler) |
| Secrets | Secret Manager | Parameter Store (free) — easy swap |
| Logs | Cloud Logging (built-in) | CloudWatch Logs (built-in) |
| Phase 01 plan | already drafted | needs full rewrite |
| Effort | days | weeks |
### Brainstorm: Steel-manning the AWS case
**"AWS Lambda free tier never expires, more permanent than GCP's"** → False premise. Both have permanent always-free tiers. GCP's Always-Free is also permanent. The 12-month thing applies to AWS extras (EC2, RDS, ECR), not Lambda/DynamoDB.
**"AWS egress is 100 GB free, Cloud Run only 1 GiB"** → True, but irrelevant: bot egress is <10 MB/mo.
**"Lambda has faster cold starts"** → True for ZIP packages, but you'd need to abandon the Docker workflow. Container Lambda is similar to Cloud Run.
**"AWS has more free services to grow into"** → Speculative. YAGNI. Decide based on this project's needs.
**"Vendor independence — AWS skill is more marketable"** → Career argument, not technical. Outside scope.
### Brainstorm: Steel-manning the GCP case
**"Code is already written for it"** → Decisive. Anything else is rationalization for redoing finished work.
**"Cloud Run runs unmodified `http.Handler`"** → Real ergonomic win. Lambda forces an event-shape adapter even with Function URLs.
**"Firestore SDK is already integrated, tests pass"** → Phase 04 is done. Don't rewrite Phase 04.
**"Gemini + Firestore + Cloud Run all share one project, one service account, one IAM model"** → Operational simplicity. Worth real money in time saved.
**"Cloud Scheduler → HTTP target is the simplest cron-to-webhook in the industry"** → Phase 08 will be 30 min on GCP, half a day on AWS.
### Steel-manning the actual neutral choice
There is none. This is a lopsided decision. Anyone telling you AWS is "comparable for this project" is selling you sunk-cost-aversion in reverse — you have NO sunk cost in AWS to recover.
---
## Implementation Recommendations
### Stay on GCP. Proceed with Phase 01 as planned. Specifically:
1. **Enable Blaze plan** on the GCP project. Set a **$1 budget alert** (covers free-tier spillover).
2. **Cloud Run** service: `--cpu 1 --memory 256Mi --min-instances 0 --max-instances 3 --concurrency 80`. Min-instances=0 keeps it free; max-instances cap prevents runaway billing.
3. **Artifact Registry** repo for the distroless image. CI pushes via Workload Identity Federation (no JSON keys).
4. **Firestore** in Native mode, `(default)` database. Already coded.
5. **Cloud Scheduler** jobs → HTTP POST to `/cron/{name}` with OIDC token; verify on the receiver side.
6. **Secret Manager**: store `TELEGRAM_BOT_TOKEN` and `TELEGRAM_WEBHOOK_SECRET`; mount as env vars on Cloud Run.
7. **Gemini API key**: from Google AI Studio (not GCP). Store in Secret Manager. Free tier is independent.
8. **Cost guardrails**: budget alert + `max-instances` + Firestore composite-index review (a runaway query loop is the realistic free-tier killer).
### Common pitfalls to avoid
- ❌ Don't set `min-instances >= 1` — instantly leaves free tier.
- ❌ Don't put image in Cloud Storage (Feb 2026 Blaze trigger) — use Artifact Registry.
- ❌ Don't enable expensive Firestore indexes you don't query — they bill writes.
- ❌ Don't log PII at INFO — Cloud Logging has its own free quota (50 GiB/mo) but verbose logs at scale will blow it.
- ❌ Don't trust the webhook origin without `X-Telegram-Bot-Api-Secret-Token` verification.
### When to reconsider AWS
Switch to AWS Lambda **only if** one of:
- You suddenly need >1 GiB Cloud Run egress/month (very unlikely for a bot).
- You unify with an existing AWS-only org/account.
- GCP changes Always-Free terms hostilely (no signal of this).
None apply today.
---
## Resources & References
### GCP
- [Google Cloud Free Tier](https://cloud.google.com/free)
- [Cloud Run Free Tier (2025 infographic)](https://www.freetiers.com/directory/google-cloud-run)
- [Firestore Quotas & Limits](https://docs.cloud.google.com/firestore/quotas)
- [Cloud Scheduler docs](https://docs.cloud.google.com/scheduler/docs)
- [Cloud Run pricing 2025](https://cloudchipr.com/blog/cloud-run-pricing)
### AWS
- [AWS Free Tier 2026 limits & hidden costs](https://www.cloudoptimo.com/blog/aws-free-tier-isnt-unlimited-know-the-limits-before-you-get-billed/)
- [Lambda pricing & cost guide 2026](https://go-cloud.io/aws-lambda-pricing/)
- [AWS Free Tier comprehensive guide](https://cloudwebschool.com/docs/aws/fundamentals/aws-free-tier/)
### Gemini API
- [Gemini API rate limits (official)](https://ai.google.dev/gemini-api/docs/rate-limits)
- [Gemini API pricing (official)](https://ai.google.dev/gemini-api/docs/pricing)
- [Gemini API free tier 2026 guide](https://yingtu.ai/en/blog/gemini-api-free-tier)
- [Gemini Pro paid changes April 2026](https://help.apiyi.com/en/google-gemini-api-free-tier-changes-april-2026-guide-en.html)
### Telegram bot deployment patterns
- [Comparing Telegram bot hosting providers (Code Capsules)](https://www.codecapsules.io/blog/comparing-telegram-bot-hosting-providers)
- [Deploy AI Telegram bot on AWS for free (Go)](http://golangforall.com/en/post/telegram-bots-zero-cost-aws.html)
- [Cloud Run vs Lambda performance/pricing (Sedai)](https://sedai.io/blog/aws-lambda-google-cloud-functions)
- [Serverless container pricing comparison 2026](https://danubedata.ro/blog/serverless-container-pricing-comparison-2026)
---
## Decision Matrix
| Criterion | Weight | GCP | AWS | Winner |
|---|---|---|---|---|
| Code already written for it | High | ✅ | ❌ | **GCP** |
| Free tier covers workload | High | ✅ (100x) | ✅ (100x) | tie |
| Container deploy ergonomics | Med | ✅ Cloud Run native | ⚠ Lambda container OR adapter | **GCP** |
| Cron→webhook simplicity | Med | ✅ Cloud Scheduler | ⚠ EventBridge + IAM | **GCP** |
| KV store integration | Med | ✅ Firestore done | ❌ rewrite to DynamoDB | **GCP** |
| Egress headroom | Low | 1 GiB | 100 GB | AWS (irrelevant) |
| Cold start | Low | 13 s | 0.61.4 s | AWS (acceptable on both) |
| AI integration (Gemini) | Med | tie (cloud-agnostic) | tie | tie |
| Switching cost | High | $0 | weeks of work | **GCP** |
**Score:** GCP wins 5 categories outright, ties 2, loses 2 (low-impact). **Stay on GCP.**
---
## Next Steps
1.**Decision: stay on GCP.** Close this question.
2. Resume **Phase 01** (`plans/260508-2222-go-port-cloud-run/`): provision Cloud Run service, Artifact Registry, Firestore, Secret Manager, set webhook secret.
3. Wire **Phase 08** cron via Cloud Scheduler → `/cron/{name}` with OIDC verification.
4. Add **billing budget alert** at $1 before first deploy.
5. Update README "Status" table when Phase 01 lands.
---
## Unresolved Questions
1. **Region choice.** asia-southeast1 (Singapore, lowest LatAm-to-VN latency) vs us-central1 (cheapest, default 1 GiB egress fully applies)? Bot users are in Vietnam — likely asia-southeast1 wins on user latency, but verify free-tier egress geography (the 1 GiB free egress is **NA-only**; egress from asia-southeast1 to api.telegram.org may bill at $0.12/GB even at low volume — calculate at expected msg rate).
2. **Cold-start tolerance for cron.** If a cron job calls Gemini and takes >10 s on cold start, does it risk Cloud Scheduler retry storms? Decide on min-instances trade-off vs free tier.
3. **Gemini quota saturation.** Free tier 1001000 RPD per model — if semantle/doantu/twentyq see real users, will we hit RPD before paid tier kicks in? Worth designing a fallback (cache common queries, degrade gracefully).
4. **Workload Identity Federation vs JSON key** for CI image push — recommended is WIF, but it requires GitHub OIDC config. Defer or do it now?
5. **GCP Blaze enablement timing** — enable before Phase 01 deploy or after? Free-tier still works on Blaze, but new project needs to confirm Always-Free quotas auto-apply.
@@ -0,0 +1,205 @@
# Research Addendum: AWS vs GCP — Greenfield Rethink (Sunk Cost Ignored)
> **Generated:** 2026-05-10 00:21 (Asia/Saigon)
> **Mode:** /research follow-up + ultrathink
> **Companion to:** `research-260510-0012-aws-vs-gcp-free-tier.md`
> **Trigger:** User requested re-evaluation treating project as greenfield (existing code can be rewritten any time).
> **Revised verdict:** **AWS edges out GCP, ~70/30**, on pure free-tier and trajectory merits. Not a slam-dunk; simplicity is GCP's only remaining moat.
---
## Why this addendum
Prior report leaned heavily on "code is already written for GCP" as the decisive factor. User explicitly asked to remove that lens. Greenfield-only analysis flips the conclusion: **AWS has objectively larger free tier** on the resources this project's trajectory will consume first (cron count, KV storage, egress in Asia). GCP's remaining advantage is operational simplicity for a single developer.
---
## What changes when sunk cost is removed
The previous report's "GCP wins" verdict rested 60% on switching cost. Strip that:
| Argument | Holds greenfield? | Why |
|---|---|---|
| "Cloud Run runs unmodified `http.Handler`" | ❌ no | **Lambda Web Adapter (LWA)** ships a normal Go HTTP server on Lambda. Same code, both clouds. |
| "Firestore is already wired" | ❌ no | Greenfield: nothing wired. Pick the better KV. DynamoDB free tier is 25× bigger. |
| "Cloud Scheduler is 3 lines YAML" | ⚠ partial | True, but capped at 3 jobs free. EventBridge is also simple via Terraform/SAM and uncapped on rule count. |
| "Single IAM model" | ✅ yes | GCP IAM is gentler than AWS combinatorics. Real ergonomic win. |
| "Free tier covers workload 100×" | ✅ yes | Both clouds. Tie. |
| "Gemini is cloud-agnostic" | ✅ yes | Tie. |
Three of the four GCP advantages **vaporize** without sunk cost. The remaining wins are operational simplicity (real) and Cloud Logging UX (minor).
---
## Greenfield free-tier head-to-head (this project's resource axes)
### Compute
| | GCP Cloud Run | AWS Lambda (ZIP, Function URL) |
|---|---|---|
| Free requests/mo | 2M | 1M |
| Free compute | 360k GiB-s + 180k vCPU-s | 400k GB-s |
| Adapter needed for Go HTTP | none | LWA (extension layer, ~100 ms cold-start tax) |
| Cold start (Go, distroless / provided.al2023) | 13 s | **0.050.2 s ZIP** |
| Concurrency model | up to 801000 req/instance | 1 req/instance |
| Container registry | Artifact Registry 0.5 GB always-free | ECR 500 MB **12-mo only** |
| Bills idle? | no (request-based when min-instances=0) | no |
**Tilt:** Lambda ZIP wins on cold start, no registry needed. Cloud Run wins on more free requests and concurrency multiplexing (lower compute usage per req). For a webhook bot at low QPS, Lambda's per-invocation isolation is fine. **Slight Lambda lean** because ZIP avoids the registry trap.
### KV / document storage
| | Firestore (Native) | DynamoDB (on-demand) |
|---|---|---|
| Free storage | 1 GiB | **25 GiB** |
| Free read ops | 50k/**day** (≈1.5M/mo) | (part of 200M req/mo monthly pool) |
| Free write ops | 20k/**day** (≈600k/mo) | (part of 200M req/mo monthly pool) |
| Bursting risk | high (daily caps reset) | low (monthly pool absorbs spikes) |
| Query model | document + composite indexes | item + GSI/LSI |
| Strong consistency | yes | yes (with consistent-read flag) |
**Tilt:** **DynamoDB wins decisively.** 25× storage, monthly-pooled requests instead of daily caps (better for bursty bot traffic), and 200M total req/mo dwarfs Firestore's combined ~2M ops/mo. For per-user state in 8+ modules + planned trading data, DynamoDB has years of headroom; Firestore could pinch within months if usage grows.
### Cron / scheduler
| | Cloud Scheduler | EventBridge Scheduler |
|---|---|---|
| Free job/rule count | **3 hard cap** | unlimited |
| Free invocations/mo | (per the 3 jobs) | 14M on default bus |
| Cost past free | $0.10/job/mo | $0/job, $1.25/M extra invocations |
**This project's likely cron count:** wordle daily, loldle daily, lolschedule daily, semantle daily, weekly digests, possible trading hourly refresh. **>3 almost certainly.**
**Tilt:** **AWS wins.** GCP becomes the project's first paywall trigger. Cost is trivial ($0.70/mo for 10 jobs) but breaks the "strict $0" guarantee. AWS keeps the bot literally free.
### Egress
| | Cloud Run | Lambda |
|---|---|---|
| Free egress | 1 GiB/mo **NA-only** | 100 GB/mo **all regions** |
| Bot deploy region (VN users) | asia-southeast1 → **no free egress** | ap-southeast-1 → 100 GB free |
| Bot egress estimate | <50 MB/mo | <50 MB/mo |
| Real cost impact | ~$0 (pennies) | $0 |
**Tilt:** **AWS wins on principle, draws on practice.** GCP's free egress is geographically restrictive in a way that surprises Asian deployers. Negligible at this volume but asymmetric.
### Secrets
| | GCP Secret Manager | AWS Parameter Store (Standard) |
|---|---|---|
| Free | 6 active versions, 10k accesses/mo | unlimited |
| Cost past free | $0.06/version/mo, $0.03/10k access | $0 |
| API ergonomics | clean | clean |
**Tilt:** **AWS wins.** Parameter Store Standard tier is unlimited free. (Skip Secrets Manager — paid only.)
### Logs
| | Cloud Logging | CloudWatch Logs |
|---|---|---|
| Free | 50 GiB/mo ingest | 5 GB/mo ingest (always-free as of 2024+) |
| UX | better console | older console |
**Tilt:** **GCP wins on volume + UX.** 50 GiB > 5 GB, console search is faster. Real but minor advantage.
### Container registry
| | Artifact Registry | ECR |
|---|---|---|
| Free | 0.5 GB **always** | 500 MB **12 mo only** |
| Workaround on AWS | use Lambda ZIP, no registry | — |
**Tilt:** **GCP wins on container path. Lambda ZIP makes this moot for AWS.**
---
## Operational complexity (real cost, hard to put $ on)
| Activity | GCP | AWS |
|---|---|---|
| First deploy | `gcloud run deploy --source .` (one command, builds + pushes + runs) | `sam build && sam deploy` OR Terraform OR manual zip + IAM role + Function URL + LWA layer config |
| Add cron | Cloud Scheduler job → HTTP POST with OIDC | EventBridge Scheduler → Lambda invoke role + Lambda permission |
| IAM for cron-to-app auth | OIDC token verify on receiver | IAM principal-on-invoke |
| Iteration loop | edit → `gcloud run deploy --source` → 30 s | edit → `sam deploy` → 30 s OR build+zip+update-function-code |
| Multi-env (dev/prod) | two Cloud Run services + two Firestore DBs | two Lambdas + two DynamoDB tables + two roles |
**Tilt:** **GCP wins on greenfield ergonomics.** This is real engineer-hours. For a solo dev, the difference between "one command" and "five tools to wire" matters.
---
## Decision matrix (greenfield, weighted for this project)
| Criterion | Weight | GCP | AWS | Winner |
|---|---|---|---|---|
| Cron job free count headroom | High | 3 cap (paywall) | unlimited | **AWS** |
| KV storage headroom | High | 1 GiB / daily caps | 25 GiB / monthly pool | **AWS** |
| Operational simplicity | High | one command | multi-tool | **GCP** |
| Cold start | Med | 13 s | 50200 ms | **AWS** |
| Egress in Asia | Med | NA-only free | 100 GB any region | **AWS** |
| Logs free tier | Low | 50 GiB | 5 GB | **GCP** |
| Container registry | Low | 0.5 GB always | 500 MB 12-mo | **GCP** (or moot via ZIP) |
| Secrets | Low | 10k accesses/mo | unlimited | **AWS** |
| Compute requests | Low | 2M | 1M+400k GB-s | tie at this scale |
| AI (Gemini) | Low | cloud-agnostic | cloud-agnostic | tie |
**Score:** AWS 5 wins (3 high-weight). GCP 3 wins (1 high-weight). 2 ties.
**Greenfield verdict: AWS, by ~70/30 margin.**
---
## When does GCP still win greenfield?
- **You commit to ≤3 cron jobs forever.** Wordle daily push only. Done.
- **You expect <100 MB total state.** Firestore 1 GiB is plenty.
- **You value 1-command deploy more than free-tier headroom.** Solo dev, hobby project, no growth ambition.
- **You want better log UX out of the box.**
- **You'd hit operational complexity faster than free-tier ceilings.** Realistic for small bots.
**This project's signal:** 8 modules done, trading planned, multiple daily-push crons implied. **It's not staying small.** AWS headroom matches the trajectory.
---
## Honest counter-argument (steel-manning GCP greenfield)
- "$0.70/mo past 3 cron jobs is not a real cost." → True. If "almost free" is acceptable, GCP simplicity wins.
- "Solo devs ship more on simpler clouds." → True. Maintenance hours dwarf $-cost differences.
- "Firestore 1 GiB is fine for tens of thousands of users on a text bot." → True until it isn't, hard to predict.
- "Cloud Logging is genuinely better." → True. Operational quality of life matters.
- "Cloud Run's `--source .` deploy from a Go repo is the simplest serverless deployment in the industry." → True. AWS has no equivalent.
**If the user values "minimal operational tax + accept ~$1/mo at scale" over "strict $0 + larger headroom": GCP.**
**If the user values "stay strictly free + max headroom for growth + faster cold starts": AWS.**
---
## Recommendation
If switching cost truly is zero and the project intends to grow:
**Greenfield stack: AWS**
- Lambda (Go on `provided.al2023`, ZIP deploy) behind a Function URL
- Lambda Web Adapter so handler code is pure `http.Handler` (portable to Cloud Run later if needed)
- DynamoDB on-demand for KV (provider abstraction in `internal/storage/` swaps out Firestore for Dynamo)
- EventBridge Scheduler for cron → Lambda invoke (no HTTP loopback needed)
- Parameter Store for `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `GEMINI_API_KEY`
- CloudWatch Logs at INFO; consider 7-day retention to stay under 5 GB
- GitHub Actions OIDC → AWS role for CI deploy (no long-lived keys)
- Region: ap-southeast-1 (Singapore) for Vietnam latency
If switching cost is not actually zero (it's some hours, not weeks):
**Pragmatic stack: stay GCP**
- Phase 04 Firestore is done. Phase 01 Cloud Run is one config exercise.
- Accept the 3-cron paywall trigger; budget $1/mo guard.
- Be aware Asia egress is billed; volume is tiny.
---
## Unresolved questions
1. **Realistic cron count over project lifetime?** If the user commits to ≤3 forever, GCP holds. If 5+ realistic, AWS wins on this axis alone.
2. **State growth projection?** If bot grows to >10k active users with per-user history, Firestore daily caps tighten. DynamoDB doesn't.
3. **Effort estimate for AWS port?** Phase 02 (HTTP skeleton): ~1 day with LWA. Phase 04 (KV provider): ~1 day with DynamoDB AWS SDK. Phase 08 (cron): ~half day with EventBridge. **Total ~3 days** to redo what's done. Worth it?
4. **Solo-dev tolerance for AWS IAM/SAM/Terraform?** If the user has scar tissue from AWS, the simplicity tax may exceed the free-tier savings.
5. **Region choice on AWS?** ap-southeast-1 (Singapore) vs ap-northeast-1 (Tokyo) — pick by where Telegram's edge is closest to bot users in Vietnam. Both have full free-tier coverage.
6. **Bedrock vs direct Gemini API?** No reason to switch — Gemini API free tier is independent of cloud and currently better than Bedrock's pay-per-token-only model for hobby use.