App Setup Guide

From zero to running beta in ~2 hours. Local dev + AWS beta environment (Lambda + CloudFront, ~$30-35/mo). Frontend on All-Inkl.

Beta architecture overview

LayerWhereCost
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 internetEC2 t4g.nano NAT instance (VPC Lambdas → Anthropic/Google OAuth/Stripe/Sentry)~$7-9/mo
DatabaseAWS RDS PostgreSQL 17, db.t4g.micro, private subnet~$15/mo
Async queueSQS main + DLQ → Worker Lambda ARM~$1/mo
SecretsAWS Secrets Manager + KMS~$3/mo
LogsCloudWatch Log Groups (30d retention)~$1/mo
RegistryAWS ECR (worker image)~$1/mo
Budget alertsAWS Budgets + Cost Anomaly Detectionfree
AI callsAnthropic API direct (<20 beta users)~$2/mo
Total beta~$30-35/mo
Production upgrade path

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.

Phase 0 — Accounts & API keys

0.1AWS account
  1. Create AWS account at aws.amazon.com
  2. Enable MFA on root account immediately
  3. Create IAM user (or use AWS SSO) for daily use — don't use root
  4. Install AWS CLI: brew install awscliaws configure (region: eu-central-1)
0.2Google OAuth client (already implemented — 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.

  1. Google Cloud Console → APIs & Services → Credentials → Create OAuth client ID → type "Web application"
  2. Authorized redirect URI: https://api.emealia.eu/auth/google/callback (and http://localhost:8000/auth/google/callback for local dev)
  3. Note: Client ID, Client Secret → needed in Step 3.2
  4. Apple Sign-In (story 7.1b) is not yet built — skip it for beta
0.3Anthropic API key + spend cap
  1. console.anthropic.com → API Keys → Create key → name: "emealia-beta"
  2. Immediately set a spend cap: Settings → Billing → Spend Limit → $50/month
  3. Sign DPA (Data Processing Agreement) — required before any EU user data is processed
Critical — do this before the key goes live

An infinite loop in the AI call path can burn $1,000+ overnight. Spend cap is non-negotiable.

0.4Stripe account
  1. dashboard.stripe.com → Create account → enable test mode
  2. Developers → API Keys → note Secret Key (sk_test_…)
  3. Install Stripe CLI: brew install stripe/stripe-cli/stripe
  4. Stripe Tax: Settings → Tax → Enable (needed for EU VAT + US sales tax)
0.5Sentry (optional but recommended)
  1. sentry.io → New Project → Python (backend) + Vue (frontend) → note DSN
  2. Free tier: 5k errors/month — sufficient for beta

Phase 1 — Local development

1.1Prerequisites
brew 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
1.2Clone & configure
git clone https://github.com/enikolae/emealia-app.git
cd emealia-app
cp .env.example .env   # edit with real dev values (see table below)
VariableDev value
DATABASE_URLpostgresql+psycopg://emealia:emealia@localhost:5432/emealia
ANTHROPIC_API_KEYyour sk-ant-… key from Step 0.3
GOOGLE_OAUTH_CLIENT_IDfrom Google Cloud Console (Step 0.2)
GOOGLE_OAUTH_CLIENT_SECRETfrom Google Cloud Console (Step 0.2)
SESSION_SIGNING_KEYrandom 32+ byte secret — signs the JwtSessionCodec (HS256) session cookie
STRIPE_SECRET_KEYsk_test_… from Stripe (Step 0.4)
STRIPE_WEBHOOK_SECRETget via stripe listen --print-secret
SENTRY_DSNfrom Sentry (Step 0.5) or leave empty
ENVdevelopment
1.3Start backend
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"}
1.4Start frontend
cd frontend
npm install
npm run dev    # → http://localhost:5173
1.5Run tests
cd backend && pytest                    # all unit tests (boundary fakes, no real calls)
cd frontend && npm run test             # Vitest
Local dev done

You're working. All future feature development happens here locally. AWS is only for the deployed beta.

Phase 2 — AWS bootstrap (run once)

2.1Request ACM certificate

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)
2.2Create Terraform state bucket + lock table
cd infra/aws/bootstrap
terraform init
terraform apply   # creates S3 bucket "emealia-tf-state" + DynamoDB "emealia-tf-lock"
2.3Create beta.tfvars
cd infra/aws/envs/beta
cp beta.tfvars.example beta.tfvars
# Edit beta.tfvars — fill in:
#   db_password_initial = "..."  (generate: openssl rand -base64 24)
beta.tfvars is in .gitignore — never commit it

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).

Phase 3 — Terraform apply

3.1Init + plan + apply
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.

3.2Note the outputs
terraform output   # shows all important values
OutputWhat to do with it
ecr_backend_urlUse in next step (docker push)
api_function_nameAdd to GitHub secret LAMBDA_API_NAME
worker_function_nameAdd to GitHub secret LAMBDA_WORKER_NAME
deploy_role_arnAdd to GitHub secret AWS_DEPLOY_ROLE_ARN
migrate_function_nameAdd to GitHub secret LAMBDA_MIGRATE_NAME
api_function_urlUse as the CloudFront /api/* origin when the frontend CDN is configured
db_endpointBuild DATABASE_URL for Secrets Manager (Step 3.3)
secret_arnsFill these secrets in AWS Console (Step 3.3)
3.3Fill in secrets (AWS Console)

Go to AWS Console → Secrets Manager. For each secret, click "Retrieve secret value" → "Edit" → set value:

SecretValue 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://..."}

Phase 4 — First Docker image + Lambda deploy

4.1Verify Dockerfile has Lambda Web Adapter

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"]
Zero app changes

The adapter intercepts Lambda invocation events and translates them to HTTP requests for Uvicorn. FastAPI sees normal HTTP — no handler rewrites needed.

4.2Build + push image to ECR
# 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
4.3Deploy Lambda functions
# 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
4.4Run migrations
# 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
Always migrate before API update

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.

Phase 5 — GitHub Actions CI/CD

5.1Add GitHub secrets

GitHub → repo → Settings → Secrets → Actions → add:

Secret nameValue
AWS_DEPLOY_ROLE_ARNfrom terraform output deploy_role_arn
AWS_REGIONeu-central-1
ECR_URLfrom terraform output ecr_backend_url
LAMBDA_API_NAMEfrom terraform output api_function_name (e.g. emealia-beta-api)
LAMBDA_WORKER_NAMEfrom terraform output worker_function_name
LAMBDA_MIGRATE_NAMEfrom terraform output migrate_function_name
FRONTEND_BUCKETfrom terraform output frontend_bucket_name
CLOUDFRONT_DISTRIBUTION_IDfrom terraform output cloudfront_distribution_id
SENTRY_AUTH_TOKENfrom 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.

5.2Frontend deploy to All-Inkl
# 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.

Phase 6 — DNS wiring

6.1Add DNS records
Record typeNameValue
CNAMEapi.emealia.euCloudFront 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.

Phase 7 — Verify

7.1Smoke test
# 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
Beta is live

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.

Teardown (if needed)

# 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

What to skip for now

FeatureWhen to addHow
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 instanceAdd 6 endpoints (ECR, SQS, SM, CW, STS, S3 gateway) to network module; remove NAT instance
Multi-AZ RDSFirst paying usersmulti_az=true in beta.tfvars → apply
RDS ProxyWhen Lambda concurrency causes DB connection churn (>30 concurrent)Add aws_db_proxy resource; remove reserved_concurrency cap
WAFBefore production launchAdd WAF module in front of CloudFront distribution
Deletion protection on RDSFirst paying userdeletion_protection=true in tfvars → apply

Troubleshooting

SymptomCheck
Lambda returns 500 / function erroraws logs tail /emealia/emealia-beta/api --follow — usually secrets not injected or DB unreachable; confirm State: Active via get-function-configuration
CloudFront returns 502 / 504Lambda 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/PUTOAC 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 loadingLambda execution role must have GetSecretValue on KMS key AND the secret ARN — check CloudWatch for AccessDeniedException
DB connection refused / pool exhaustedRDS 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 AnthropicWorker 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 existsS3 bucket names are global — change bucket_name in bootstrap/main.tf and backend.tf
ACM cert stays PENDINGCert 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 browserAdd CORS middleware in FastAPI with allow_origins=["https://beta.emealia.eu"]