From zero to running beta in ~2 hours. Local dev + AWS beta environment (Lambda + CloudFront, ~$30-35/mo). Frontend on All-Inkl.
| Layer | Where | Cost |
|---|---|---|
| Frontend (Vue PWA) | All-Inkl webspace — existing, no extra cost | €0 extra |
| API (FastAPI) | CloudFront /api/* → Lambda Function URL (IAM-auth, OAC) → Lambda ARM 512 MB, eu-central-1 | $0 (free tier) |
| Outbound internet | EC2 t4g.nano NAT instance (VPC Lambdas → Anthropic/Google OAuth/Stripe/Sentry) | ~$7-9/mo |
| Database | AWS RDS PostgreSQL 17, db.t4g.micro, private subnet | ~$15/mo |
| Async queue | SQS main + DLQ → Worker Lambda ARM | ~$1/mo |
| Secrets | AWS Secrets Manager + KMS | ~$3/mo |
| Logs | CloudWatch Log Groups (30d retention) | ~$1/mo |
| Registry | AWS ECR (worker image) | ~$1/mo |
| Budget alerts | AWS Budgets + Cost Anomaly Detection | free |
| AI calls | Anthropic API direct (<20 beta users) | ~$2/mo |
| Total beta | ~$30-35/mo | |
This setup graduates to production with: activate ecs-express Terraform module (adds ALB + ECS service), replace beta NAT with VPC endpoints for AWS APIs plus an explicit production egress plan, swap CloudFront /api/* origin from Lambda Function URL to ALB, update deploy workflow to ecs update-service. RDS, SQS, Secrets Manager and all app code stay unchanged.
brew install awscli → aws configure (region: eu-central-1)Before anything else: AWS Console → Billing → Budgets → create a $100/month budget alert. Prevents surprise bills.
GoogleAuthBoundary, story 7.1a)emealia does not use Auth0 or any password-based auth. Sign-in is social-only, delegated to the identity provider's OAuth 2.0 Authorization Code flow, exchanged server-side by GoogleAuthBoundary. Local dev/CI default to FakeAuthBoundary — no real credentials needed until a real beta user signs in.
https://api.emealia.eu/auth/google/callback (and http://localhost:8000/auth/google/callback for local dev)An infinite loop in the AI call path can burn $1,000+ overnight. Spend cap is non-negotiable.
sk_test_…)brew install stripe/stripe-cli/stripebrew install python@3.13 node@24 docker terraform git gh brew link python@3.13 --force python3 --version # must be 3.13.x node --version # must be 24.x
git clone https://github.com/enikolae/emealia-app.git
cd emealia-app
cp .env.example .env # edit with real dev values (see table below)
| Variable | Dev value |
|---|---|
DATABASE_URL | postgresql+psycopg://emealia:emealia@localhost:5432/emealia |
ANTHROPIC_API_KEY | your sk-ant-… key from Step 0.3 |
GOOGLE_OAUTH_CLIENT_ID | from Google Cloud Console (Step 0.2) |
GOOGLE_OAUTH_CLIENT_SECRET | from Google Cloud Console (Step 0.2) |
SESSION_SIGNING_KEY | random 32+ byte secret — signs the JwtSessionCodec (HS256) session cookie |
STRIPE_SECRET_KEY | sk_test_… from Stripe (Step 0.4) |
STRIPE_WEBHOOK_SECRET | get via stripe listen --print-secret |
SENTRY_DSN | from Sentry (Step 0.5) or leave empty |
ENV | development |
docker compose up -d db # PostgreSQL in Docker cd backend python3.13 -m venv .venv && source .venv/bin/activate pip install -e ".[dev]" alembic upgrade head # run migrations uvicorn app.main:app --reload # → http://localhost:8000 curl http://localhost:8000/health # should return {"status":"ok"}
cd frontend
npm install
npm run dev # → http://localhost:5173
cd backend && pytest # all unit tests (boundary fakes, no real calls) cd frontend && npm run test # Vitest
You're working. All future feature development happens here locally. AWS is only for the deployed beta.
The API CloudFront distribution needs an HTTPS certificate. CloudFront requires certs in us-east-1 regardless of app region. Request before Terraform (validation takes a few minutes).
# CloudFront certs MUST be in us-east-1 — not eu-central-1 aws acm request-certificate \ --domain-name api.emealia.eu \ --validation-method DNS \ --region us-east-1 # Note the CertificateArn from the output — needed in beta.tfvars # Then validate via DNS: add the CNAME record shown in ACM console to your DNS # Wait for status to show "ISSUED" (usually 5-10 min after DNS propagates)
ACM certs for CloudFront only work when created in us-east-1. The app and all data stay in eu-central-1 — only the cert lives in US East.
cd infra/aws/bootstrap
terraform init
terraform apply # creates S3 bucket "emealia-tf-state" + DynamoDB "emealia-tf-lock"
If emealia-tf-state is taken, edit bootstrap/main.tf → variable "bucket_name" and update envs/beta/backend.tf to match.
cd infra/aws/envs/beta cp beta.tfvars.example beta.tfvars # Edit beta.tfvars — fill in: # db_password_initial = "..." (generate: openssl rand -base64 24)
It contains the initial DB password. After first apply, change the password in AWS Console and it won't be in Terraform state anymore (lifecycle ignore_changes).
cd infra/aws/envs/beta terraform init terraform plan -var-file=beta.tfvars # review what will be created terraform apply -var-file=beta.tfvars # ~5-8 minutes
Terraform creates: VPC + public/private subnets, NAT instance t4g.nano (route table for private subnets), ECR, Secrets Manager secrets, IAM roles + OIDC provider, RDS PostgreSQL 17 (private subnet), SQS main queue + DLQ, API Lambda + Function URL (IAM-auth), migration Lambda, Worker Lambda + SQS event-source mapping, CloudWatch log groups, and AWS Budgets alert. CloudFront/S3 frontend wiring is the next module/deploy layer.
terraform output # shows all important values
| Output | What to do with it |
|---|---|
ecr_backend_url | Use in next step (docker push) |
api_function_name | Add to GitHub secret LAMBDA_API_NAME |
worker_function_name | Add to GitHub secret LAMBDA_WORKER_NAME |
deploy_role_arn | Add to GitHub secret AWS_DEPLOY_ROLE_ARN |
migrate_function_name | Add to GitHub secret LAMBDA_MIGRATE_NAME |
api_function_url | Use as the CloudFront /api/* origin when the frontend CDN is configured |
db_endpoint | Build DATABASE_URL for Secrets Manager (Step 3.3) |
secret_arns | Fill these secrets in AWS Console (Step 3.3) |
Go to AWS Console → Secrets Manager. For each secret, click "Retrieve secret value" → "Edit" → set value:
| Secret | Value format |
|---|---|
emealia-beta/anthropic | {"ANTHROPIC_API_KEY":"sk-ant-..."} |
emealia-beta/db | {"DATABASE_URL":"postgresql+psycopg://emealia:PASSWORD@HOST:5432/emealia?sslmode=require"} |
emealia-beta/google-oauth | {"GOOGLE_OAUTH_CLIENT_ID":"...","GOOGLE_OAUTH_CLIENT_SECRET":"...","SESSION_SIGNING_KEY":"..."} |
emealia-beta/stripe | {"STRIPE_SECRET_KEY":"sk_test_...","STRIPE_WEBHOOK_SECRET":"whsec_..."} |
emealia-beta/sentry | {"SENTRY_DSN":"https://..."} |
Build it from the RDS endpoint: postgresql+psycopg://emealia:YOUR_PASSWORD@db_endpoint_from_output:5432/emealia?sslmode=require
The backend Dockerfile needs one extra line to run FastAPI on Lambda. Confirm it's present before building:
# These lines must be in backend/Dockerfile (after pip install):
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.0 \
/lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
The adapter intercepts Lambda invocation events and translates them to HTTP requests for Uvicorn. FastAPI sees normal HTTP — no handler rewrites needed.
# Login (replace ACCOUNT_ID) aws ecr get-login-password --region eu-central-1 \ | docker login --username AWS \ --password-stdin ACCOUNT_ID.dkr.ecr.eu-central-1.amazonaws.com # Build for ARM (Lambda runs on Graviton) + push docker buildx build --platform linux/arm64 \ -t ACCOUNT_ID.dkr.ecr.eu-central-1.amazonaws.com/emealia-beta-backend:latest \ --push ./backend
# Update API Lambda with the new image aws lambda update-function-code \ --function-name emealia-beta-api \ --image-uri ACCOUNT_ID.dkr.ecr.eu-central-1.amazonaws.com/emealia-beta-backend:latest \ --region eu-central-1 aws lambda wait function-updated \ --function-name emealia-beta-api --region eu-central-1 # Update migration Lambda (same image, different CMD set in Terraform) aws lambda update-function-code \ --function-name emealia-beta-migrate \ --image-uri ACCOUNT_ID.dkr.ecr.eu-central-1.amazonaws.com/emealia-beta-backend:latest \ --region eu-central-1 aws lambda wait function-updated \ --function-name emealia-beta-migrate --region eu-central-1 # Update worker Lambda (if using same image) aws lambda update-function-code \ --function-name emealia-beta-worker \ --image-uri ACCOUNT_ID.dkr.ecr.eu-central-1.amazonaws.com/emealia-beta-backend:latest \ --region eu-central-1 aws lambda wait function-updated \ --function-name emealia-beta-worker --region eu-central-1
# Invoke the migration Lambda once — same image, CMD runs alembic upgrade head aws lambda invoke \ --function-name emealia-beta-migrate \ --region eu-central-1 \ /tmp/migrate-response.json cat /tmp/migrate-response.json # should show {"statusCode": 200} # Check migration logs in CloudWatch → /emealia/emealia-beta/migrate aws logs tail /emealia/emealia-beta/migrate --region eu-central-1
In the CI/CD workflow (Step 5), migrations run before the API Lambda is updated — same pattern as the ECS Alembic task. Keeps schema and code in sync during deploys.
GitHub → repo → Settings → Secrets → Actions → add:
| Secret name | Value |
|---|---|
AWS_DEPLOY_ROLE_ARN | from terraform output deploy_role_arn |
AWS_REGION | eu-central-1 |
ECR_URL | from terraform output ecr_backend_url |
LAMBDA_API_NAME | from terraform output api_function_name (e.g. emealia-beta-api) |
LAMBDA_WORKER_NAME | from terraform output worker_function_name |
LAMBDA_MIGRATE_NAME | from terraform output migrate_function_name |
FRONTEND_BUCKET | from terraform output frontend_bucket_name |
CLOUDFRONT_DISTRIBUTION_ID | from terraform output cloudfront_distribution_id |
SENTRY_AUTH_TOKEN | from Sentry → Settings → Auth Tokens |
No ANTHROPIC_API_KEY GitHub secret — the per-PR AI reviewer workflows were removed on 2026-07-04 (not cost-efficient). The runtime meal-parsing key lives in AWS Secrets Manager (emealia-beta/anthropic), never in GitHub Actions.
# Build Vue PWA cd frontend && npm run build # → frontend/dist/ # Add .htaccess to dist/ for SPA routing cat > frontend/dist/.htaccess <<'EOF' <IfModule mod_rewrite.c> RewriteEngine On RewriteBase / RewriteRule ^index\.html$ - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] </IfModule> EOF # Upload to All-Inkl via FTP (same pattern as deploy.py) # Target: /app/ or /beta/ directory in your webspace
Point your frontend domain (e.g. beta.emealia.eu) to the All-Inkl directory in the KAS control panel.
| Record type | Name | Value |
|---|---|---|
| CNAME | api.emealia.eu | CloudFront distribution domain once the frontend/API CDN is configured. Terraform currently exposes api_function_url as the origin. |
| CNAME (ACM validation) | shown in ACM console (us-east-1) | shown in ACM console |
Add both records in your DNS provider (KAS or wherever emealia.eu is managed). There is no ALB DNS name in the beta setup. CloudFront should use api_function_url as its /api/* origin and sign requests with SigV4/OAC. ACM validation CNAME must be present for the cert to stay ISSUED.
# API health check through CloudFront → Lambda (should return {"status":"ok"}) curl https://api.emealia.eu/health # Check Lambda function state aws lambda get-function-configuration \ --function-name emealia-beta-api \ --region eu-central-1 \ --query '{State:State,LastStatus:LastUpdateStatus,LastError:LastUpdateStatusReason}' # Watch live API logs aws logs tail /emealia/emealia-beta/api --follow --region eu-central-1
If /health returns 200 and Lambda shows State: Active, the beta environment is running. From here on, every push to main triggers CI → ECR push → lambda update-function-code automatically. First request after ~15 min idle may be 1–3 s slower (cold start) — expected at beta volume.
# Destroys all beta AWS resources (Lambda, RDS, NAT instance, ECR, etc.) # RDS: skip_final_snapshot=true in beta — data is lost cd infra/aws/envs/beta terraform destroy -var-file=beta.tfvars
Bootstrap resources (S3 state bucket, DynamoDB lock table) are outside this env. Delete manually via AWS Console if you want a full cleanup.
| Feature | When to add | How |
|---|---|---|
| Lambda → ECS Express upgrade | ~3,000–5,000 active users (≥10M API req/mo) | Activate ecs-express module, add VPC endpoints for AWS APIs plus explicit third-party egress, swap CloudFront /api/* origin to ALB — see upgrade callout above |
| VPC Interface Endpoints (production) | With ECS upgrade — replaces NAT instance | Add 6 endpoints (ECR, SQS, SM, CW, STS, S3 gateway) to network module; remove NAT instance |
| Multi-AZ RDS | First paying users | multi_az=true in beta.tfvars → apply |
| RDS Proxy | When Lambda concurrency causes DB connection churn (>30 concurrent) | Add aws_db_proxy resource; remove reserved_concurrency cap |
| WAF | Before production launch | Add WAF module in front of CloudFront distribution |
| Deletion protection on RDS | First paying user | deletion_protection=true in tfvars → apply |
| Symptom | Check |
|---|---|
| Lambda returns 500 / function error | aws logs tail /emealia/emealia-beta/api --follow — usually secrets not injected or DB unreachable; confirm State: Active via get-function-configuration |
| CloudFront returns 502 / 504 | Lambda Function URL not reachable from CloudFront OAC — verify authorization_type = AWS_IAM on function URL and that CloudFront distribution has the OAC attached to the /api/* behavior |
| CloudFront returns 403 on POST/PUT | OAC must sign requests with SigV4; ensure signing_behavior = always and signing_protocol = sigv4 in the OAC config |
| Lambda cold start slow (>3 s) | Expected on first request after ~15 min idle — normal at beta volume; add provisioned concurrency only if SLA demands it |
| Secrets not loading | Lambda execution role must have GetSecretValue on KMS key AND the secret ARN — check CloudWatch for AccessDeniedException |
| DB connection refused / pool exhausted | RDS SG only allows Lambda SG — verify SG IDs match; confirm pool_size=1, max_overflow=1 and reserved_concurrency=10 are set; max 20 connections to db.t4g.micro |
| Worker Lambda can't reach Anthropic | Worker must be in VPC with NAT instance route — confirm route table for private subnets has 0.0.0.0/0 → nat-instance-id and NAT instance has source_dest_check=false |
| Terraform: bucket already exists | S3 bucket names are global — change bucket_name in bootstrap/main.tf and backend.tf |
| ACM cert stays PENDING | Cert must be in us-east-1 for CloudFront — check region; add DNS CNAME validation record and wait for propagation: dig CNAME _your-record.api.emealia.eu |
| CORS errors in browser | Add CORS middleware in FastAPI with allow_origins=["https://beta.emealia.eu"] |