From c2dd56d6c79065d3b9672cb6af905a5221775a82 Mon Sep 17 00:00:00 2001 From: MarcoManduca Date: Wed, 8 Jul 2026 16:07:00 +0200 Subject: [PATCH 1/8] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20back=20con?= =?UTF-8?q?tact=20rate=20limiting=20with=20a=20DynamoDB=20TTL=20table=20in?= =?UTF-8?q?stead=20of=20in-memory=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 3 +- backend/src/config.py | 1 + backend/src/utils/rate_limit.py | 98 +++++++++++++-------- backend/tests/conftest.py | 7 ++ backend/tests/unit/utils/test_rate_limit.py | 54 +++++++++--- 5 files changed, 109 insertions(+), 54 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 1d94160..c5a66f2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,7 @@ AWS_REGION=eu-west-1 PROJECTS_TABLE_NAME=portfolio-projects LEARNING_TABLE_NAME=portfolio-learning TECHNOLOGIES_TABLE_NAME=portfolio-technologies +RATELIMIT_TABLE_NAME=portfolio-ratelimit # Optional: point boto3 at DynamoDB Local for development # DYNAMODB_ENDPOINT_URL=http://localhost:8001 @@ -27,6 +28,6 @@ SES_RECIPIENT_EMAIL=owner@marcomanduca.dev # CORS (comma-separated origins) CORS_ORIGINS=http://localhost:5173,https://marcomanduca.dev -# Contact form rate limit (per IP, per instance) +# Contact form rate limit (per IP, fixed window; state in the ratelimit table) CONTACT_RATE_LIMIT_MAX_REQUESTS=5 CONTACT_RATE_LIMIT_WINDOW_SECONDS=900 diff --git a/backend/src/config.py b/backend/src/config.py index ffe0772..27461c2 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -21,6 +21,7 @@ class Settings(BaseSettings): projects_table_name: str = "portfolio-projects" learning_table_name: str = "portfolio-learning" technologies_table_name: str = "portfolio-technologies" + ratelimit_table_name: str = "portfolio-ratelimit" media_bucket_name: str = "marcomanduca-dev-media" presign_expiration_seconds: int = 900 diff --git a/backend/src/utils/rate_limit.py b/backend/src/utils/rate_limit.py index 9eab92e..87eb347 100644 --- a/backend/src/utils/rate_limit.py +++ b/backend/src/utils/rate_limit.py @@ -1,81 +1,101 @@ -"""In-memory sliding-window rate limiting. - -The limiter is intentionally per-instance: state lives in process memory -and is not shared across replicas. This is acceptable for a low-traffic -portfolio backend; production hardening can move the limit to API -Gateway throttling or AWS WAF rate-based rules. +"""DynamoDB-backed fixed-window rate limiting. + +State lives in a small DynamoDB table with a TTL attribute instead of in +process memory, so the limit is shared across every Lambda execution +environment and survives cold starts. Each request atomically increments a +per-key, per-window counter; DynamoDB drops the item automatically once its +window has elapsed (TTL). This replaces the earlier per-instance in-memory +limiter, which reset on every cold start and could not be shared. """ import time -from collections import defaultdict, deque +from typing import Any +from botocore.exceptions import BotoCoreError, ClientError from fastapi import HTTPException, Request, status from src.config import get_settings +from src.models.base import get_dynamodb_resource + +# Small grace added to the TTL so an item never expires mid-window. +_TTL_GRACE_SECONDS = 60 -class SlidingWindowRateLimiter: - """Sliding-window request counter keyed by an arbitrary string. +class DynamoRateLimiter: + """Fixed-window request counter stored in DynamoDB. Parameters ---------- + table : Any + A boto3 DynamoDB ``Table`` resource with a string hash key ``pk`` + and a numeric TTL attribute ``expires_at``. max_requests : int - Maximum number of requests allowed inside the window. - window_seconds : float - Length of the sliding window in seconds. + Maximum number of requests allowed inside a single window. + window_seconds : int + Length of the fixed window in seconds. """ - def __init__(self, max_requests: int, window_seconds: float) -> None: + def __init__(self, table: Any, max_requests: int, window_seconds: int) -> None: + self._table = table self._max_requests = max_requests self._window_seconds = window_seconds - self._hits: dict[str, deque[float]] = defaultdict(deque) def is_allowed(self, key: str, now: float | None = None) -> bool: """Record a hit for ``key`` and report whether it is allowed. + Fails open: if DynamoDB is unreachable the request is allowed, so a + transient backend fault never blocks a genuine visitor (the honeypot + remains the second line of defence against spam). + Parameters ---------- key : str Identifier of the caller (typically a client IP). now : float, optional - Monotonic timestamp override, used by tests to control the - clock. Defaults to ``time.monotonic()``. + Epoch timestamp override, used by tests to control the clock. + Defaults to ``time.time()``. Returns ------- bool - ``True`` if the request fits in the window, ``False`` if - the caller exceeded the limit. + ``True`` if the request fits in the window, ``False`` otherwise. """ - current = time.monotonic() if now is None else now - bucket = self._hits[key] - while bucket and current - bucket[0] >= self._window_seconds: - bucket.popleft() - if len(bucket) >= self._max_requests: - return False - bucket.append(current) - return True - - def reset(self) -> None: - """Drop all recorded hits.""" - self._hits.clear() - - -_contact_limiter: SlidingWindowRateLimiter | None = None - - -def get_contact_limiter() -> SlidingWindowRateLimiter: + current = time.time() if now is None else now + window_start = int(current // self._window_seconds) * self._window_seconds + try: + response = self._table.update_item( + Key={"pk": f"{key}#{window_start}"}, + UpdateExpression=( + "ADD hits :one SET expires_at = if_not_exists(expires_at, :ttl)" + ), + ExpressionAttributeValues={ + ":one": 1, + ":ttl": window_start + self._window_seconds + _TTL_GRACE_SECONDS, + }, + ReturnValues="UPDATED_NEW", + ) + except (ClientError, BotoCoreError): + return True + return int(response["Attributes"]["hits"]) <= self._max_requests + + +_contact_limiter: DynamoRateLimiter | None = None + + +def get_contact_limiter() -> DynamoRateLimiter: """Return the lazily-built limiter for the contact endpoint. Returns ------- - SlidingWindowRateLimiter - Process-wide limiter configured from settings. + DynamoRateLimiter + Limiter bound to the rate-limit table and configured from settings. """ global _contact_limiter if _contact_limiter is None: settings = get_settings() - _contact_limiter = SlidingWindowRateLimiter( + table = get_dynamodb_resource().Table(settings.ratelimit_table_name) + _contact_limiter = DynamoRateLimiter( + table=table, max_requests=settings.contact_rate_limit_max_requests, window_seconds=settings.contact_rate_limit_window_seconds, ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 31a5b9d..8614e7d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -25,6 +25,7 @@ "PROJECTS_TABLE_NAME": "test-projects", "LEARNING_TABLE_NAME": "test-learning", "TECHNOLOGIES_TABLE_NAME": "test-technologies", + "RATELIMIT_TABLE_NAME": "test-ratelimit", "MEDIA_BUCKET_NAME": "test-media-bucket", "COGNITO_USER_POOL_ID": "eu-west-1_testpool", "COGNITO_CLIENT_ID": "test-client-id", @@ -168,6 +169,12 @@ def _create_tables() -> None: AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], BillingMode="PAY_PER_REQUEST", ) + dynamodb.create_table( + TableName=os.environ["RATELIMIT_TABLE_NAME"], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) def _create_bucket() -> None: diff --git a/backend/tests/unit/utils/test_rate_limit.py b/backend/tests/unit/utils/test_rate_limit.py index cb0d937..982a15d 100644 --- a/backend/tests/unit/utils/test_rate_limit.py +++ b/backend/tests/unit/utils/test_rate_limit.py @@ -1,11 +1,32 @@ -"""Unit tests for the sliding-window rate limiter.""" +"""Unit tests for the DynamoDB-backed rate limiter.""" -from src.utils.rate_limit import SlidingWindowRateLimiter +from typing import Any +import boto3 +from moto import mock_aws +from src.utils.rate_limit import DynamoRateLimiter + +_REGION = "eu-west-1" +_TABLE = "test-ratelimit" + + +def _make_table() -> Any: + """Create the rate-limit table in moto and return the Table resource.""" + dynamodb = boto3.resource("dynamodb", region_name=_REGION) + dynamodb.create_table( + TableName=_TABLE, + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + return dynamodb.Table(_TABLE) + + +@mock_aws def test_is_allowed_accepts_requests_under_the_limit() -> None: # Arrange - limiter = SlidingWindowRateLimiter(max_requests=2, window_seconds=60) + limiter = DynamoRateLimiter(_make_table(), max_requests=2, window_seconds=60) # Act first = limiter.is_allowed("1.2.3.4", now=0.0) @@ -16,9 +37,10 @@ def test_is_allowed_accepts_requests_under_the_limit() -> None: assert second is True +@mock_aws def test_is_allowed_blocks_requests_over_the_limit() -> None: # Arrange - limiter = SlidingWindowRateLimiter(max_requests=2, window_seconds=60) + limiter = DynamoRateLimiter(_make_table(), max_requests=2, window_seconds=60) limiter.is_allowed("1.2.3.4", now=0.0) limiter.is_allowed("1.2.3.4", now=1.0) @@ -29,9 +51,10 @@ def test_is_allowed_blocks_requests_over_the_limit() -> None: assert third is False -def test_is_allowed_accepts_again_after_window_expiry() -> None: +@mock_aws +def test_is_allowed_accepts_again_in_a_new_window() -> None: # Arrange - limiter = SlidingWindowRateLimiter(max_requests=1, window_seconds=60) + limiter = DynamoRateLimiter(_make_table(), max_requests=1, window_seconds=60) limiter.is_allowed("1.2.3.4", now=0.0) # Act @@ -41,9 +64,10 @@ def test_is_allowed_accepts_again_after_window_expiry() -> None: assert after_window is True +@mock_aws def test_is_allowed_tracks_keys_independently() -> None: # Arrange - limiter = SlidingWindowRateLimiter(max_requests=1, window_seconds=60) + limiter = DynamoRateLimiter(_make_table(), max_requests=1, window_seconds=60) limiter.is_allowed("1.1.1.1", now=0.0) # Act @@ -53,14 +77,16 @@ def test_is_allowed_tracks_keys_independently() -> None: assert other_key is True -def test_reset_clears_recorded_hits() -> None: - # Arrange - limiter = SlidingWindowRateLimiter(max_requests=1, window_seconds=60) - limiter.is_allowed("1.2.3.4", now=0.0) +@mock_aws +def test_is_allowed_fails_open_when_dynamodb_errors() -> None: + # Arrange: the table is never created, so update_item raises a ClientError. + dynamodb = boto3.resource("dynamodb", region_name=_REGION) + limiter = DynamoRateLimiter( + dynamodb.Table("missing-table"), max_requests=1, window_seconds=60 + ) # Act - limiter.reset() - after_reset = limiter.is_allowed("1.2.3.4", now=1.0) + allowed = limiter.is_allowed("1.2.3.4", now=0.0) # Assert - assert after_reset is True + assert allowed is True From 8e1f528bd2c324871079f532aee79b1b62637d7d Mon Sep 17 00:00:00 2001 From: MarcoManduca Date: Wed, 8 Jul 2026 16:07:09 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=F0=9F=90=B3=20bundle=20the=20Lambd?= =?UTF-8?q?a=20Web=20Adapter=20and=20build=20the=20arm64=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/Dockerfile b/backend/Dockerfile index 0744af3..3fedbc2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -11,6 +11,15 @@ RUN pip install --no-cache-dir --prefix=/install . # --- Runtime stage: slim image, non-root user, no build tooling -------------- FROM python:3.12-slim AS runtime +# AWS Lambda Web Adapter: lets the unmodified Uvicorn server run on Lambda +# behind a Function URL (translates the Lambda event to a local HTTP request). +# It is a Lambda extension and is inert outside Lambda, so the exact same +# image still runs locally and in docker-compose. AWS_LWA_PORT must match the +# port Uvicorn binds; the readiness path gates cold-start traffic on /health. +COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter +ENV AWS_LWA_PORT=8000 +ENV AWS_LWA_READINESS_CHECK_PATH=/api/v1/health + RUN addgroup --system app && adduser --system --ingroup app app WORKDIR /app From 409e34478aaf153549b2645d5a65c4a9f6b43410 Mon Sep 17 00:00:00 2001 From: MarcoManduca Date: Wed, 8 Jul 2026 16:07:21 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=F0=9F=97=83=EF=B8=8F=20add=20the?= =?UTF-8?q?=20ratelimit=20DynamoDB=20table=20(TTL,=20no=20PITR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/modules/database/main.tf | 20 ++++++++++++++++++++ infra/terraform/modules/database/outputs.tf | 2 ++ 2 files changed, 22 insertions(+) diff --git a/infra/terraform/modules/database/main.tf b/infra/terraform/modules/database/main.tf index 68b7f4d..7411a2a 100644 --- a/infra/terraform/modules/database/main.tf +++ b/infra/terraform/modules/database/main.tf @@ -54,3 +54,23 @@ resource "aws_dynamodb_table" "learning" { enabled = true } } + +# Contact-form rate limiting. Holds one counter item per client+window; the +# backend reads/increments it atomically (see backend/src/utils/rate_limit.py). +# TTL lets DynamoDB purge expired windows for free, so the table stays tiny. +# No PITR: the data is ephemeral and worthless to back up. +resource "aws_dynamodb_table" "ratelimit" { + name = "${var.project_name}-ratelimit" + billing_mode = "PAY_PER_REQUEST" + hash_key = "pk" + + attribute { + name = "pk" + type = "S" + } + + ttl { + attribute_name = "expires_at" + enabled = true + } +} diff --git a/infra/terraform/modules/database/outputs.tf b/infra/terraform/modules/database/outputs.tf index 062d084..60adb70 100644 --- a/infra/terraform/modules/database/outputs.tf +++ b/infra/terraform/modules/database/outputs.tf @@ -4,6 +4,7 @@ output "table_names" { projects = aws_dynamodb_table.simple["projects"].name technologies = aws_dynamodb_table.simple["technologies"].name learning = aws_dynamodb_table.learning.name + ratelimit = aws_dynamodb_table.ratelimit.name } } @@ -13,5 +14,6 @@ output "table_arns" { aws_dynamodb_table.simple["projects"].arn, aws_dynamodb_table.simple["technologies"].arn, aws_dynamodb_table.learning.arn, + aws_dynamodb_table.ratelimit.arn, ] } From 9d50142524a7916693dcfd4d7ea3672cbb8da124 Mon Sep 17 00:00:00 2001 From: MarcoManduca Date: Wed, 8 Jul 2026 16:07:29 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=E2=9A=A1=20run=20the=20backend=20o?= =?UTF-8?q?n=20Lambda=20+=20Function=20URL=20instead=20of=20ECS=20Fargate?= =?UTF-8?q?=20behind=20an=20ALB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/modules/backend/alb.tf | 131 ------------------- infra/terraform/modules/backend/ecs.tf | 104 --------------- infra/terraform/modules/backend/iam.tf | 46 +++---- infra/terraform/modules/backend/main.tf | 79 ++++++----- infra/terraform/modules/backend/outputs.tf | 28 +--- infra/terraform/modules/backend/variables.tf | 55 ++------ 6 files changed, 88 insertions(+), 355 deletions(-) delete mode 100644 infra/terraform/modules/backend/alb.tf delete mode 100644 infra/terraform/modules/backend/ecs.tf diff --git a/infra/terraform/modules/backend/alb.tf b/infra/terraform/modules/backend/alb.tf deleted file mode 100644 index d24f1dc..0000000 --- a/infra/terraform/modules/backend/alb.tf +++ /dev/null @@ -1,131 +0,0 @@ -# ALB exposed to CloudFront only, over HTTPS. - -# Regional certificate for the origin hostname (CloudFront certs live in -# us-east-1, but an ALB certificate must be in the ALB's own region). -resource "aws_acm_certificate" "origin" { - domain_name = var.api_origin_domain - validation_method = "DNS" - - lifecycle { - create_before_destroy = true - } -} - -resource "aws_route53_record" "origin_cert_validation" { - for_each = { - for dvo in aws_acm_certificate.origin.domain_validation_options : dvo.domain_name => { - name = dvo.resource_record_name - type = dvo.resource_record_type - record = dvo.resource_record_value - } - } - - zone_id = var.zone_id - name = each.value.name - type = each.value.type - records = [each.value.record] - ttl = 300 - allow_overwrite = true -} - -resource "aws_acm_certificate_validation" "origin" { - certificate_arn = aws_acm_certificate.origin.arn - validation_record_fqdns = [for record in aws_route53_record.origin_cert_validation : record.fqdn] -} - -# Ingress restricted to CloudFront's origin-facing IP ranges. Anything else -# cannot even open a TCP connection to the ALB. -resource "aws_security_group" "alb" { - name = "${var.project_name}-alb" - description = "ALB ingress from CloudFront only" - vpc_id = data.aws_vpc.default.id - - ingress { - description = "HTTPS from CloudFront origin-facing ranges" - from_port = 443 - to_port = 443 - protocol = "tcp" - prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront.id] - } - - egress { - description = "Forward to backend tasks" - from_port = var.container_port - to_port = var.container_port - protocol = "tcp" - cidr_blocks = [data.aws_vpc.default.cidr_block] - } -} - -resource "aws_lb" "this" { - name = "${var.project_name}-alb" - load_balancer_type = "application" - security_groups = [aws_security_group.alb.id] - subnets = data.aws_subnets.default.ids -} - -resource "aws_lb_target_group" "backend" { - name = "${var.project_name}-backend" - port = var.container_port - protocol = "HTTP" - target_type = "ip" - vpc_id = data.aws_vpc.default.id - - health_check { - path = var.health_check_path - matcher = "200" - interval = 30 - healthy_threshold = 2 - unhealthy_threshold = 3 - } -} - -# Default action is 403: only requests carrying the CloudFront secret -# header (see the listener rule below) reach the backend. -resource "aws_lb_listener" "https" { - load_balancer_arn = aws_lb.this.arn - port = 443 - protocol = "HTTPS" - ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" - certificate_arn = aws_acm_certificate_validation.origin.certificate_arn - - default_action { - type = "fixed-response" - - fixed_response { - content_type = "application/json" - message_body = "{\"detail\":\"Forbidden\"}" - status_code = "403" - } - } -} - -resource "aws_lb_listener_rule" "from_cloudfront" { - listener_arn = aws_lb_listener.https.arn - priority = 1 - - condition { - http_header { - http_header_name = "X-Origin-Verify" - values = [random_password.origin_verify.result] - } - } - - action { - type = "forward" - target_group_arn = aws_lb_target_group.backend.arn - } -} - -# Origin hostname CloudFront connects to (must match the certificate). -resource "aws_route53_record" "api_origin" { - zone_id = var.zone_id - name = var.api_origin_domain - type = "A" - - alias { - name = aws_lb.this.dns_name - zone_id = aws_lb.this.zone_id - evaluate_target_health = true - } -} diff --git a/infra/terraform/modules/backend/ecs.tf b/infra/terraform/modules/backend/ecs.tf deleted file mode 100644 index 363cffd..0000000 --- a/infra/terraform/modules/backend/ecs.tf +++ /dev/null @@ -1,104 +0,0 @@ -# ECS cluster, task definition and service. - -resource "aws_ecs_cluster" "this" { - name = var.project_name - - setting { - name = "containerInsights" - value = "disabled" # paid feature, not worth it for a personal site - } -} - -resource "aws_cloudwatch_log_group" "backend" { - name = "/ecs/${var.project_name}-backend" - retention_in_days = 30 -} - -# Tasks accept traffic exclusively from the ALB security group. -resource "aws_security_group" "service" { - name = "${var.project_name}-backend" - description = "Backend tasks: ingress from ALB only" - vpc_id = data.aws_vpc.default.id - - ingress { - description = "App port from the ALB" - from_port = var.container_port - to_port = var.container_port - protocol = "tcp" - security_groups = [aws_security_group.alb.id] - } - - egress { - # Outbound to AWS APIs (DynamoDB, S3, SES, Cognito JWKS) and ECR pulls. - description = "All outbound" - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } -} - -resource "aws_ecs_task_definition" "backend" { - family = "${var.project_name}-backend" - requires_compatibilities = ["FARGATE"] - network_mode = "awsvpc" - cpu = var.cpu - memory = var.memory - execution_role_arn = aws_iam_role.execution.arn - task_role_arn = aws_iam_role.task.arn - - container_definitions = jsonencode([ - { - name = "backend" - image = "${aws_ecr_repository.backend.repository_url}:${var.image_tag}" - essential = true - - portMappings = [ - { - containerPort = var.container_port - protocol = "tcp" - } - ] - - environment = [ - for key, value in var.container_environment : { - name = key - value = value - } - ] - - logConfiguration = { - logDriver = "awslogs" - options = { - awslogs-group = aws_cloudwatch_log_group.backend.name - awslogs-region = var.aws_region - awslogs-stream-prefix = "backend" - } - } - } - ]) -} - -resource "aws_ecs_service" "backend" { - name = "${var.project_name}-backend" - cluster = aws_ecs_cluster.this.id - task_definition = aws_ecs_task_definition.backend.arn - desired_count = var.desired_count - launch_type = "FARGATE" - - network_configuration { - subnets = data.aws_subnets.default.ids - security_groups = [aws_security_group.service.id] - assign_public_ip = true # public subnets, no NAT gateway (see main.tf) - } - - load_balancer { - target_group_arn = aws_lb_target_group.backend.arn - container_name = "backend" - container_port = var.container_port - } - - # The first apply happens before any image is pushed to ECR: the service - # is created but tasks fail to start until deploy-backend.sh runs once. - depends_on = [aws_lb_listener_rule.from_cloudfront] -} diff --git a/infra/terraform/modules/backend/iam.tf b/infra/terraform/modules/backend/iam.tf index c6caa12..510538a 100644 --- a/infra/terraform/modules/backend/iam.tf +++ b/infra/terraform/modules/backend/iam.tf @@ -1,40 +1,35 @@ -# IAM roles for the Fargate task. +# IAM role for the backend Lambda. # -# execution role : used by the ECS agent to pull the image and ship logs. -# task role : used by the application code. Strictly scoped to the -# three DynamoDB tables, the media bucket and SES sending. +# One execution role, used by both the Lambda service (to ship logs) and the +# application code. Strictly scoped to the DynamoDB tables, the media bucket +# and SES sending. Container images are pulled by the Lambda service itself, +# so no ECR permissions are needed here. -data "aws_iam_policy_document" "ecs_assume" { +data "aws_iam_policy_document" "lambda_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" - identifiers = ["ecs-tasks.amazonaws.com"] + identifiers = ["lambda.amazonaws.com"] } } } -# --- Execution role ------------------------------------------------------- - -resource "aws_iam_role" "execution" { - name = "${var.project_name}-backend-execution" - assume_role_policy = data.aws_iam_policy_document.ecs_assume.json +resource "aws_iam_role" "backend" { + name = "${var.project_name}-backend" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json } -resource "aws_iam_role_policy_attachment" "execution" { - role = aws_iam_role.execution.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +# CloudWatch Logs (CreateLogStream / PutLogEvents on the function's group). +resource "aws_iam_role_policy_attachment" "logs" { + role = aws_iam_role.backend.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } -# --- Task role ------------------------------------------------------------ - -resource "aws_iam_role" "task" { - name = "${var.project_name}-backend-task" - assume_role_policy = data.aws_iam_policy_document.ecs_assume.json -} +# --- Application permissions ---------------------------------------------- -data "aws_iam_policy_document" "task" { +data "aws_iam_policy_document" "backend" { statement { sid = "DynamoDBCrud" actions = [ @@ -47,6 +42,7 @@ data "aws_iam_policy_document" "task" { "dynamodb:BatchGetItem", "dynamodb:BatchWriteItem", ] + # Includes the rate-limit table (passed in dynamodb_table_arns). resources = var.dynamodb_table_arns } @@ -83,8 +79,8 @@ data "aws_iam_policy_document" "task" { } } -resource "aws_iam_role_policy" "task" { - name = "${var.project_name}-backend-task" - role = aws_iam_role.task.id - policy = data.aws_iam_policy_document.task.json +resource "aws_iam_role_policy" "backend" { + name = "${var.project_name}-backend" + role = aws_iam_role.backend.id + policy = data.aws_iam_policy_document.backend.json } diff --git a/infra/terraform/modules/backend/main.tf b/infra/terraform/modules/backend/main.tf index bf0719a..3302a87 100644 --- a/infra/terraform/modules/backend/main.tf +++ b/infra/terraform/modules/backend/main.tf @@ -1,46 +1,61 @@ -# Backend runtime: FastAPI container on ECS Fargate behind an ALB. +# Backend runtime: the FastAPI container running on AWS Lambda. # # Traffic path: -# CloudFront /api/* ──HTTPS──> ALB (api-origin.) ──> Fargate task +# CloudFront /api/* ──(OAC, sigv4)──> Lambda Function URL ──> handler +# +# Why Lambda instead of ECS Fargate + ALB: +# - Scale-to-zero, pay-per-request. At personal-site traffic the compute +# falls inside the perpetual Lambda free tier (~$0), versus an always-on +# Fargate task + ALB + public IPv4 addresses (~$35/month combined). +# - The same image runs unchanged: the AWS Lambda Web Adapter (baked into +# the image, see backend/Dockerfile) bridges the Lambda runtime to the +# Uvicorn server, so there is no ALB, no VPC, no NAT and no public IP. # # Security model: -# - The ALB only accepts traffic from CloudFront's origin-facing IP -# ranges (AWS-managed prefix list) AND requires the X-Origin-Verify -# header, whose random value only CloudFront knows. Direct hits on the -# ALB get a 403. -# - CloudFront -> ALB is HTTPS: a dedicated regional ACM certificate is -# issued for api-origin. (TLS everywhere, no plaintext leg). -# - Tasks run in the default-VPC public subnets with a public IP instead -# of private subnets + NAT gateway: a NAT gateway alone costs ~32 USD a -# month, far more than this whole site. The task security group accepts -# ingress exclusively from the ALB security group. +# - The Function URL uses IAM auth. CloudFront reaches it through an Origin +# Access Control that sigv4-signs every request; the resource policy that +# allows only this distribution to invoke lives in the cdn module (it owns +# the distribution ARN). Direct hits on the Function URL get a 403. # # Files in this module: -# main.tf — networking data sources, origin-verify secret -# ecr.tf — container registry -# alb.tf — origin certificate, load balancer, listener, DNS record -# ecs.tf — cluster, task definition, service, logs -# iam.tf — execution role + least-privilege task role +# main.tf — Lambda function, Function URL, log group +# ecr.tf — container registry +# iam.tf — Lambda execution role (least-privilege) +# +# Bootstrap note: a container-image Lambda cannot be created until the image +# exists in ECR. On a fresh account, apply the repository first, push once, +# then apply the rest: +# terraform apply -target=module.backend.aws_ecr_repository.backend +# ./infra/scripts/deploy-backend.sh +# terraform apply -data "aws_vpc" "default" { - default = true +resource "aws_cloudwatch_log_group" "backend" { + name = "/aws/lambda/${var.project_name}-backend" + retention_in_days = 14 } -data "aws_subnets" "default" { - filter { - name = "vpc-id" - values = [data.aws_vpc.default.id] +resource "aws_lambda_function" "backend" { + function_name = "${var.project_name}-backend" + role = aws_iam_role.backend.arn + package_type = "Image" + image_uri = "${aws_ecr_repository.backend.repository_url}:${var.image_tag}" + architectures = [var.architecture] + memory_size = var.memory_mb + timeout = var.timeout_s + + environment { + variables = var.container_environment + } + + logging_config { + log_format = "Text" + log_group = aws_cloudwatch_log_group.backend.name } -} -# CloudFront origin-facing IP ranges, maintained by AWS. -data "aws_ec2_managed_prefix_list" "cloudfront" { - name = "com.amazonaws.global.cloudfront.origin-facing" + depends_on = [aws_iam_role_policy_attachment.logs] } -# Shared secret between CloudFront and the ALB. Rotating it is a plain -# `terraform apply` after tainting: terraform apply -replace=module.backend.random_password.origin_verify -resource "random_password" "origin_verify" { - length = 32 - special = false +resource "aws_lambda_function_url" "backend" { + function_name = aws_lambda_function.backend.function_name + authorization_type = "AWS_IAM" } diff --git a/infra/terraform/modules/backend/outputs.tf b/infra/terraform/modules/backend/outputs.tf index 0debd3e..1c8ad83 100644 --- a/infra/terraform/modules/backend/outputs.tf +++ b/infra/terraform/modules/backend/outputs.tf @@ -3,28 +3,12 @@ output "ecr_repository_url" { value = aws_ecr_repository.backend.repository_url } -output "cluster_name" { - description = "ECS cluster name." - value = aws_ecs_cluster.this.name +output "function_name" { + description = "Lambda function name, used by deploy-backend.sh and the CloudFront invoke permission." + value = aws_lambda_function.backend.function_name } -output "service_name" { - description = "ECS service name." - value = aws_ecs_service.backend.name -} - -output "alb_dns_name" { - description = "ALB DNS name." - value = aws_lb.this.dns_name -} - -output "api_origin_domain" { - description = "Hostname CloudFront must use as the /api/* origin." - value = aws_route53_record.api_origin.fqdn -} - -output "origin_verify_secret_value" { - description = "Shared secret CloudFront must send in the X-Origin-Verify header." - value = random_password.origin_verify.result - sensitive = true +output "function_url_host" { + description = "Hostname of the Lambda Function URL, used as the CloudFront /api/* origin." + value = trimsuffix(trimprefix(aws_lambda_function_url.backend.function_url, "https://"), "/") } diff --git a/infra/terraform/modules/backend/variables.tf b/infra/terraform/modules/backend/variables.tf index c1c7cf4..318c24b 100644 --- a/infra/terraform/modules/backend/variables.tf +++ b/infra/terraform/modules/backend/variables.tf @@ -3,74 +3,47 @@ variable "project_name" { type = string } -variable "aws_region" { - description = "Region, needed by the awslogs driver configuration." - type = string -} - -variable "zone_id" { - description = "Route 53 hosted zone id (origin certificate validation + origin record)." - type = string -} - -variable "api_origin_domain" { - description = "Hostname CloudFront uses to reach the ALB (e.g. api-origin.marcomanduca.dev)." - type = string -} - variable "image_tag" { - description = "ECR image tag used by the task definition." + description = "ECR image tag the Lambda function points to." type = string default = "latest" } -variable "container_port" { - description = "Port the FastAPI container listens on." - type = number - default = 8000 -} - -variable "cpu" { - description = "Fargate task CPU units." - type = number - default = 256 +variable "architecture" { + description = "Lambda instruction set. arm64 (Graviton) is cheaper and faster." + type = string + default = "arm64" } -variable "memory" { - description = "Fargate task memory in MiB." +variable "memory_mb" { + description = "Lambda memory in MiB (also scales CPU proportionally)." type = number default = 512 } -variable "desired_count" { - description = "Number of running tasks." +variable "timeout_s" { + description = "Lambda timeout in seconds." type = number - default = 1 -} - -variable "health_check_path" { - description = "ALB target group health check path." - type = string - default = "/api/v1/health" + default = 30 } variable "dynamodb_table_arns" { - description = "ARNs of the DynamoDB tables the task role may access." + description = "ARNs of the DynamoDB tables the function may access." type = list(string) } variable "media_bucket_arn" { - description = "ARN of the media bucket the task role may access." + description = "ARN of the media bucket the function may access." type = string } variable "ses_identity_arn" { - description = "ARN of the SES identity the task role may send from." + description = "ARN of the SES identity the function may send from." type = string } variable "container_environment" { - description = "Plain (non-secret) environment variables for the container." + description = "Plain (non-secret) environment variables for the function." type = map(string) default = {} } From 9e6490e8a7665690e9ded3be4e36eb5d58444d56 Mon Sep 17 00:00:00 2001 From: MarcoManduca Date: Wed, 8 Jul 2026 16:07:42 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=F0=9F=94=92=20point=20the=20CloudF?= =?UTF-8?q?ront=20/api=20origin=20at=20the=20Lambda=20Function=20URL=20via?= =?UTF-8?q?=20OAC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/modules/cdn/main.tf | 38 +++++++++++++++++------- infra/terraform/modules/cdn/variables.tf | 9 +++--- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/infra/terraform/modules/cdn/main.tf b/infra/terraform/modules/cdn/main.tf index 1936ec8..e6be20a 100644 --- a/infra/terraform/modules/cdn/main.tf +++ b/infra/terraform/modules/cdn/main.tf @@ -1,7 +1,7 @@ # CloudFront distribution serving the whole site: # # default behavior : SPA assets from the private frontend bucket (OAC) -# /api/* : FastAPI through the ALB (HTTPS + secret header) +# /api/* : FastAPI on the Lambda Function URL (OAC, sigv4) # # Extras: # - 403/404 from S3 are rewritten to /index.html so client-side routing @@ -80,6 +80,17 @@ resource "aws_cloudfront_origin_access_control" "frontend" { signing_protocol = "sigv4" } +# OAC for the backend Lambda Function URL: CloudFront sigv4-signs every +# /api/* request so the IAM-authed Function URL accepts it (and rejects +# anything that did not come through this distribution). +resource "aws_cloudfront_origin_access_control" "backend" { + name = "${var.project_name}-backend" + description = "OAC for the backend Lambda Function URL" + origin_access_control_origin_type = "lambda" + signing_behavior = "always" + signing_protocol = "sigv4" +} + # Redirect www -> apex at the edge (viewer-request). resource "aws_cloudfront_function" "www_redirect" { name = "${var.project_name}-www-redirect" @@ -119,8 +130,9 @@ resource "aws_cloudfront_distribution" "this" { } origin { - origin_id = "backend-alb" - domain_name = var.api_origin_domain + origin_id = "backend-lambda" + domain_name = var.backend_function_url_host + origin_access_control_id = aws_cloudfront_origin_access_control.backend.id custom_origin_config { http_port = 80 @@ -128,12 +140,6 @@ resource "aws_cloudfront_distribution" "this" { origin_protocol_policy = "https-only" origin_ssl_protocols = ["TLSv1.2"] } - - # Proves to the ALB that the request came through CloudFront. - custom_header { - name = "X-Origin-Verify" - value = var.origin_verify_secret_value - } } default_cache_behavior { @@ -153,7 +159,7 @@ resource "aws_cloudfront_distribution" "this" { ordered_cache_behavior { path_pattern = "/api/*" - target_origin_id = "backend-alb" + target_origin_id = "backend-lambda" viewer_protocol_policy = "redirect-to-https" allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] cached_methods = ["GET", "HEAD"] @@ -188,6 +194,18 @@ resource "aws_cloudfront_distribution" "this" { } } +# Allow only this distribution to invoke the backend Function URL. Lives here +# (not in the backend module) to avoid a dependency cycle: it needs both the +# function name and the distribution ARN. +resource "aws_lambda_permission" "cloudfront" { + statement_id = "AllowCloudFrontInvoke" + action = "lambda:InvokeFunctionUrl" + function_name = var.backend_function_name + principal = "cloudfront.amazonaws.com" + source_arn = aws_cloudfront_distribution.this.arn + function_url_auth_type = "AWS_IAM" +} + # Only this distribution may read the frontend bucket. data "aws_iam_policy_document" "frontend_bucket" { statement { diff --git a/infra/terraform/modules/cdn/variables.tf b/infra/terraform/modules/cdn/variables.tf index 50c5c22..abc68de 100644 --- a/infra/terraform/modules/cdn/variables.tf +++ b/infra/terraform/modules/cdn/variables.tf @@ -33,13 +33,12 @@ variable "frontend_bucket_regional_domain" { type = string } -variable "api_origin_domain" { - description = "Hostname of the ALB origin for /api/* (e.g. api-origin.marcomanduca.dev)." +variable "backend_function_url_host" { + description = "Hostname of the backend Lambda Function URL (origin for /api/*)." type = string } -variable "origin_verify_secret_value" { - description = "Shared secret sent to the ALB in the X-Origin-Verify header." +variable "backend_function_name" { + description = "Backend Lambda function name (for the CloudFront invoke permission)." type = string - sensitive = true } From 675480114c939bdda44616e252d0b9c5c7de24e0 Mon Sep 17 00:00:00 2001 From: MarcoManduca Date: Wed, 8 Jul 2026 16:07:54 +0200 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=F0=9F=94=A7=20wire=20the=20Lam?= =?UTF-8?q?bda=20backend=20into=20the=20root=20module=20and=20drop=20the?= =?UTF-8?q?=20random=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/main.tf | 26 ++++++++------------ infra/terraform/outputs.tf | 16 +++---------- infra/terraform/providers.tf | 4 ---- infra/terraform/terraform.tfvars.example | 7 +++--- infra/terraform/variables.tf | 30 +++++------------------- 5 files changed, 22 insertions(+), 61 deletions(-) diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 964776b..7577d49 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -3,9 +3,8 @@ # Dependency flow: # dns ──> acm (us-east-1 cert) ──> cdn # dns ──> email (SES DNS records) -# dns ──> backend (origin certificate validation + origin record) # storage / database / auth ──> backend (IAM scoping + env vars) -# storage + backend + acm ──> cdn (origins, certificate, aliases) +# storage + backend + acm ──> cdn (origins, certificate, aliases, invoke perm) module "dns" { source = "./modules/dns" @@ -56,19 +55,13 @@ module "email" { module "backend" { source = "./modules/backend" - project_name = var.project_name - aws_region = var.aws_region - zone_id = module.dns.zone_id - api_origin_domain = "api-origin.${var.domain_name}" + project_name = var.project_name - image_tag = var.backend_image_tag - container_port = var.backend_container_port - cpu = var.backend_cpu - memory = var.backend_memory - desired_count = var.backend_desired_count - health_check_path = var.backend_health_check_path + image_tag = var.backend_image_tag + memory_mb = var.backend_memory_mb + timeout_s = var.backend_timeout_s - # Least-privilege IAM scoping for the task role. + # Least-privilege IAM scoping for the execution role. dynamodb_table_arns = module.database.table_arns media_bucket_arn = module.storage.media_bucket_arn ses_identity_arn = module.email.identity_arn @@ -83,6 +76,7 @@ module "backend" { PROJECTS_TABLE_NAME = module.database.table_names["projects"] LEARNING_TABLE_NAME = module.database.table_names["learning"] TECHNOLOGIES_TABLE_NAME = module.database.table_names["technologies"] + RATELIMIT_TABLE_NAME = module.database.table_names["ratelimit"] MEDIA_BUCKET_NAME = module.storage.media_bucket_name COGNITO_USER_POOL_ID = module.auth.user_pool_id COGNITO_CLIENT_ID = module.auth.client_id @@ -105,7 +99,7 @@ module "cdn" { frontend_bucket_arn = module.storage.frontend_bucket_arn frontend_bucket_regional_domain = module.storage.frontend_bucket_regional_domain - # /api/* origin: ALB over HTTPS, guarded by a shared secret header. - api_origin_domain = module.backend.api_origin_domain - origin_verify_secret_value = module.backend.origin_verify_secret_value + # /api/* origin: the backend Lambda Function URL, reached via OAC (sigv4). + backend_function_url_host = module.backend.function_url_host + backend_function_name = module.backend.function_name } diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 41ab0ba..8caadba 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -36,19 +36,9 @@ output "ecr_repository_url" { value = module.backend.ecr_repository_url } -output "ecs_cluster_name" { - description = "ECS cluster name, used by deploy-backend.sh." - value = module.backend.cluster_name -} - -output "ecs_service_name" { - description = "ECS service name, used by deploy-backend.sh." - value = module.backend.service_name -} - -output "alb_dns_name" { - description = "ALB DNS name (internal detail; public traffic goes through CloudFront)." - value = module.backend.alb_dns_name +output "backend_function_name" { + description = "Backend Lambda function name, used by deploy-backend.sh." + value = module.backend.function_name } output "cognito_user_pool_id" { diff --git a/infra/terraform/providers.tf b/infra/terraform/providers.tf index 152aaec..7831ed5 100644 --- a/infra/terraform/providers.tf +++ b/infra/terraform/providers.tf @@ -13,10 +13,6 @@ terraform { source = "hashicorp/aws" version = "~> 5.0" } - random = { - source = "hashicorp/random" - version = "~> 3.6" - } } # Remote state backend (recommended). diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index fde8867..96c22de 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -18,7 +18,6 @@ cognito_domain_prefix = "marcomanduca-dev-auth" # In the SES sandbox this address must be a verified identity. contact_email = "you@example.com" -# Backend sizing — defaults are the cheapest Fargate combination. -backend_cpu = 256 -backend_memory = 512 -backend_desired_count = 1 +# Backend sizing — the FastAPI app runs on Lambda (container image). +backend_memory_mb = 512 +backend_timeout_s = 30 diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 72a6a2e..38e82d3 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -43,39 +43,21 @@ variable "cognito_domain_prefix" { } variable "backend_image_tag" { - description = "Tag of the backend image in ECR used by the ECS task definition. The deploy script pushes immutable git-sha tags and moves 'latest'." + description = "Tag of the backend image in ECR used by the Lambda function. The deploy script pushes immutable git-sha tags and moves 'latest'." type = string default = "latest" } -variable "backend_container_port" { - description = "Port the FastAPI container listens on." - type = number - default = 8000 -} - -variable "backend_cpu" { - description = "Fargate task CPU units (256 = 0.25 vCPU)." - type = number - default = 256 -} - -variable "backend_memory" { - description = "Fargate task memory in MiB." +variable "backend_memory_mb" { + description = "Lambda memory in MiB (also scales CPU). 512 is plenty for this API." type = number default = 512 } -variable "backend_desired_count" { - description = "Number of backend tasks to run. 1 is enough for a low-traffic personal site." +variable "backend_timeout_s" { + description = "Lambda timeout in seconds." type = number - default = 1 -} - -variable "backend_health_check_path" { - description = "HTTP path used by the ALB target group health check." - type = string - default = "/api/v1/health" + default = 30 } variable "contact_email" { From 9ba2de296700b8a965c1ad4043ed7326c3a0b494 Mon Sep 17 00:00:00 2001 From: MarcoManduca Date: Wed, 8 Jul 2026 16:08:01 +0200 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=F0=9F=9A=80=20deploy=20the=20backe?= =?UTF-8?q?nd=20via=20lambda=20update-function-code=20and=20seed=20the=20l?= =?UTF-8?q?ocal=20ratelimit=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 ++ infra/scripts/create-local-tables.sh | 7 ++++++ infra/scripts/deploy-backend.sh | 33 ++++++++++++++-------------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a64cf3d..dcc1c03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: PROJECTS_TABLE_NAME: portfolio-projects LEARNING_TABLE_NAME: portfolio-learning TECHNOLOGIES_TABLE_NAME: portfolio-technologies + RATELIMIT_TABLE_NAME: portfolio-ratelimit depends_on: - dynamodb-local @@ -59,6 +60,7 @@ services: PROJECTS_TABLE_NAME: portfolio-projects LEARNING_TABLE_NAME: portfolio-learning TECHNOLOGIES_TABLE_NAME: portfolio-technologies + RATELIMIT_TABLE_NAME: portfolio-ratelimit volumes: - ./infra/scripts:/scripts:ro depends_on: diff --git a/infra/scripts/create-local-tables.sh b/infra/scripts/create-local-tables.sh index b90b432..df46a8f 100755 --- a/infra/scripts/create-local-tables.sh +++ b/infra/scripts/create-local-tables.sh @@ -20,6 +20,7 @@ ENDPOINT_URL="${DYNAMODB_ENDPOINT_URL:-http://dynamodb-local:8000}" PROJECTS_TABLE="${PROJECTS_TABLE_NAME:-portfolio-projects}" LEARNING_TABLE="${LEARNING_TABLE_NAME:-portfolio-learning}" TECHNOLOGIES_TABLE="${TECHNOLOGIES_TABLE_NAME:-portfolio-technologies}" +RATELIMIT_TABLE="${RATELIMIT_TABLE_NAME:-portfolio-ratelimit}" # DynamoDB Local accepts any credentials, but the AWS CLI requires them. export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-local}" @@ -66,4 +67,10 @@ create_table "${TECHNOLOGIES_TABLE}" \ "AttributeName=id,KeyType=HASH" \ "AttributeName=id,AttributeType=S" +# contact-form rate limiting: one counter item per client+window (pk). +# TTL is a no-op on DynamoDB Local but the schema stays identical to AWS. +create_table "${RATELIMIT_TABLE}" \ + "AttributeName=pk,KeyType=HASH" \ + "AttributeName=pk,AttributeType=S" + echo "All local tables ready." diff --git a/infra/scripts/deploy-backend.sh b/infra/scripts/deploy-backend.sh index 944dbd1..86a2b78 100755 --- a/infra/scripts/deploy-backend.sh +++ b/infra/scripts/deploy-backend.sh @@ -1,26 +1,25 @@ #!/usr/bin/env bash # -# Build the backend image, push it to ECR and roll the ECS service. +# Build the backend image, push it to ECR and update the Lambda function. # # Tags pushed: the current git short SHA (immutable, for rollbacks) and -# "latest" (what the task definition points to). +# "latest". The function is then pointed at the immutable SHA tag so each +# deploy is traceable and rollbacks are a one-liner. # # Configuration (override via environment, values come from terraform output): # ECR_REPOSITORY_URL terraform output -raw ecr_repository_url -# ECS_CLUSTER terraform output -raw ecs_cluster_name -# ECS_SERVICE terraform output -raw ecs_service_name +# FUNCTION_NAME terraform output -raw backend_function_name # AWS_REGION deployment region # # Usage: # ECR_REPOSITORY_URL=123.dkr.ecr.eu-west-1.amazonaws.com/marcomanduca-dev-backend \ -# ECS_CLUSTER=marcomanduca-dev ECS_SERVICE=marcomanduca-dev-backend ./deploy-backend.sh +# FUNCTION_NAME=marcomanduca-dev-backend ./deploy-backend.sh set -euo pipefail # --- Configuration --------------------------------------------------------- ECR_REPOSITORY_URL="${ECR_REPOSITORY_URL:?Set ECR_REPOSITORY_URL (terraform output -raw ecr_repository_url)}" -ECS_CLUSTER="${ECS_CLUSTER:?Set ECS_CLUSTER (terraform output -raw ecs_cluster_name)}" -ECS_SERVICE="${ECS_SERVICE:?Set ECS_SERVICE (terraform output -raw ecs_service_name)}" +FUNCTION_NAME="${FUNCTION_NAME:?Set FUNCTION_NAME (terraform output -raw backend_function_name)}" AWS_REGION="${AWS_REGION:-eu-west-1}" # --------------------------------------------------------------------------- @@ -32,9 +31,9 @@ echo "Logging in to ECR (${ECR_REGISTRY})..." aws ecr get-login-password --region "${AWS_REGION}" \ | docker login --username AWS --password-stdin "${ECR_REGISTRY}" -echo "Building image (linux/amd64 for Fargate)..." +echo "Building image (linux/arm64 for Lambda Graviton)..." docker build \ - --platform linux/amd64 \ + --platform linux/arm64 \ --tag "${ECR_REPOSITORY_URL}:${GIT_SHA}" \ --tag "${ECR_REPOSITORY_URL}:latest" \ "${REPO_ROOT}/backend" @@ -43,12 +42,14 @@ echo "Pushing tags ${GIT_SHA} and latest..." docker push "${ECR_REPOSITORY_URL}:${GIT_SHA}" docker push "${ECR_REPOSITORY_URL}:latest" -echo "Forcing a new ECS deployment..." -aws ecs update-service \ - --cluster "${ECS_CLUSTER}" \ - --service "${ECS_SERVICE}" \ - --force-new-deployment \ +echo "Updating Lambda function ${FUNCTION_NAME} to image ${GIT_SHA}..." +aws lambda update-function-code \ + --function-name "${FUNCTION_NAME}" \ + --image-uri "${ECR_REPOSITORY_URL}:${GIT_SHA}" \ --region "${AWS_REGION}" > /dev/null -echo "Backend deployed (image ${GIT_SHA}). Watch the rollout with:" -echo " aws ecs describe-services --cluster ${ECS_CLUSTER} --services ${ECS_SERVICE} --query 'services[0].deployments'" +aws lambda wait function-updated \ + --function-name "${FUNCTION_NAME}" \ + --region "${AWS_REGION}" + +echo "Backend deployed (image ${GIT_SHA})." From 9ae924b9efc1a7e3b1d4d10405cf5ed6a89d5082 Mon Sep 17 00:00:00 2001 From: MarcoManduca Date: Wed, 8 Jul 2026 16:08:27 +0200 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20=F0=9F=93=9D=20document=20the=20ser?= =?UTF-8?q?verless=20backend=20and=20the=20AWS=20cost=20reduction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++--- backend/README.md | 15 ++++--- infra/README.md | 100 +++++++++++++++++++++++++++------------------- 3 files changed, 75 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 2ed7f7f..f23b251 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Public, bilingual (IT/EN), SEO-optimized, with a protected admin panel. ``` Browser → CloudFront → React (S3) ─┐ - ├→ FastAPI (ECS Fargate) → DynamoDB / S3 / Cognito / SES + ├→ FastAPI (Lambda) → DynamoDB / S3 / Cognito / SES Browser → CloudFront → /api ───────┘ ``` @@ -16,11 +16,11 @@ Browser → CloudFront → /api ───────┘ |----------|-------------------------------------------------| | Frontend | React + Vite + TypeScript + Tailwind CSS | | State | Redux Toolkit + RTK Query | -| Backend | FastAPI (Python 3.12) | +| Backend | FastAPI (Python 3.12) on AWS Lambda (container image) | | Database | AWS DynamoDB | | Storage | AWS S3 (project/learning images, downloadable CV PDF) | | Auth | AWS Cognito (Administrators group) | -| Hosting | AWS ECS Fargate (backend), S3 + CloudFront (SPA) | +| Hosting | AWS Lambda + Function URL (backend), S3 + CloudFront (SPA) | | DNS/TLS | Route 53 + ACM | | IaC | Terraform | @@ -28,7 +28,7 @@ Browser → CloudFront → /api ───────┘ ``` . -├── backend/ # FastAPI application (routers, services, schemas, models, utils) +├── backend/ # FastAPI application (routers, services, schemas, models, utils); runs on Lambda ├── frontend/ # React SPA (components, pages, hooks, services, store, i18n) ├── infra/ # Terraform modules + deployment guide ├── docker-compose.yml @@ -80,7 +80,7 @@ See [infra/README.md](infra/README.md) for the full AWS deployment guide, includ 1. Registering `marcomanduca.dev` on Route 53 2. Issuing the ACM certificate (us-east-1 for CloudFront) 3. Provisioning all resources with Terraform -4. Deploying backend (ECS) and frontend (S3 + CloudFront invalidation) +4. Deploying backend (Lambda) and frontend (S3 + CloudFront invalidation) ## Documentation diff --git a/backend/README.md b/backend/README.md index eac7a14..1668ae0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -8,12 +8,15 @@ belonging to the `Administrators` group. ## Architecture -- **API**: FastAPI under `/api/v1`, app factory in `src/main.py`. +- **API**: FastAPI under `/api/v1`, app factory in `src/main.py`. In + production it runs on AWS Lambda (container image + Lambda Web Adapter); the + same image runs locally via Uvicorn with no code changes. - **Storage**: DynamoDB (on-demand) for content, S3 for media. - **Auth**: Cognito JWT validation (JWKS, PyJWT) in `src/utils/auth.py`. - **Email**: AWS SES for contact form delivery. -- **Anti-spam**: honeypot field + in-memory per-IP sliding-window rate - limit (per instance; production can move this to API Gateway/WAF). +- **Anti-spam**: honeypot field + per-IP fixed-window rate limit backed by a + DynamoDB TTL table (`src/utils/rate_limit.py`), so the limit is shared + across Lambda invocations and survives cold starts. ## Endpoints @@ -51,6 +54,7 @@ See `.env.example` for the full annotated list. | `PROJECTS_TABLE_NAME` | DynamoDB Projects table | `portfolio-projects` | | `LEARNING_TABLE_NAME` | DynamoDB Learning table | `portfolio-learning` | | `TECHNOLOGIES_TABLE_NAME` | DynamoDB Technologies table | `portfolio-technologies` | +| `RATELIMIT_TABLE_NAME` | DynamoDB contact rate-limit table | `portfolio-ratelimit` | | `DYNAMODB_ENDPOINT_URL` | Optional DynamoDB Local endpoint | unset | | `MEDIA_BUCKET_NAME` | S3 bucket for media | `marcomanduca-dev-media` | | `PRESIGN_EXPIRATION_SECONDS` | Presigned URL validity | `900` | @@ -74,7 +78,7 @@ cp .env.example .env # adjust values # Optional: DynamoDB Local docker run -d -p 8001:8000 amazon/dynamodb-local # then set DYNAMODB_ENDPOINT_URL=http://localhost:8001 in .env -# and create the three tables (projects: slug / learning: slug+version / technologies: id). +# and create the tables (projects: slug / learning: slug+version / technologies: id / ratelimit: pk). uvicorn src.main:app --reload --port 8000 ``` @@ -125,7 +129,8 @@ Runtime (kept minimal on purpose): Notably avoided: `email-validator` (a lightweight regex is enough for a contact form; SES is the real gatekeeper) and any rate-limit library -(a ~40-line sliding window covers the need). +(a small DynamoDB fixed-window counter with TTL covers the need and works +across Lambda invocations). Dev only: **pytest**, **pytest-cov**, **pytest-asyncio**, **httpx** (ASGI test client), **moto** (AWS mocks), **ruff** (format + lint). diff --git a/infra/README.md b/infra/README.md index c40a558..4711aaf 100644 --- a/infra/README.md +++ b/infra/README.md @@ -9,10 +9,10 @@ development stack. ┌────────────────────────────────────────────┐ Browser ──HTTPS──> CloudFront (marcomanduca.dev + www) │ │ default ──OAC──> S3 frontend bucket (SPA) │ - │ /api/* ──HTTPS + secret header──> ALB │ + │ /api/* ──OAC/sigv4──> Lambda Function URL │ └───────────────────────┬────────────────────┘ │ - ECS Fargate (FastAPI container) + AWS Lambda (FastAPI container image) │ DynamoDB · S3 media · Cognito · SES ``` @@ -20,19 +20,22 @@ development stack. Design decisions: - **Single domain, single public certificate.** The API is served from the - same CloudFront distribution under `/api/*` (origin = ALB) instead of a - dedicated `api.` subdomain: one certificate, one DNS name, no CORS between - site and API. -- **TLS end to end.** CloudFront reaches the ALB over HTTPS via the internal - hostname `api-origin.marcomanduca.dev` (regional ACM certificate). The ALB - additionally requires a secret `X-Origin-Verify` header that only - CloudFront knows, and its security group only accepts CloudFront's - origin-facing IP ranges. The ALB is internet-facing (to pull from ECR - without a NAT gateway), so it is reachable at the TCP level from within - those ranges, but it answers 403 to any request missing the secret header. -- **No NAT gateway.** Fargate tasks run in the default-VPC public subnets - with a public IP (ingress locked to the ALB security group). A NAT gateway - would cost more than the rest of the site combined. + same CloudFront distribution under `/api/*` (origin = Lambda Function URL) + instead of a dedicated `api.` subdomain: one certificate, one DNS name, no + CORS between site and API. +- **Serverless backend.** The FastAPI container runs on Lambda (via the AWS + Lambda Web Adapter), not on an always-on Fargate task behind an ALB. This + removes the ALB (~17 USD/mo), the always-on task (~10 USD/mo) and the public + IPv4 charges (~11 USD/mo), and scales to zero. The trade-off is an occasional + ~1–2 s cold start, acceptable for a personal site. +- **Origin protection.** The Function URL uses IAM auth. CloudFront reaches it + through an Origin Access Control that sigv4-signs every request, and a + resource policy (in the `cdn` module) allows only this distribution to + invoke it. Direct hits on the Function URL get a 403 — no secret header to + manage or rotate. +- **No VPC.** The function talks only to public AWS APIs (DynamoDB, S3, SES, + Cognito JWKS), so it runs outside a VPC: no subnets, no NAT gateway, no + public IP to pay for. - **SPA routing caveat.** CloudFront rewrites 403/404 responses to `/index.html` with status 200 so deep links work. This applies to `/api/*` too: the backend should convey "not found" inside response bodies the SPA @@ -47,7 +50,7 @@ infra/ ├── scripts/ │ ├── create-local-tables.sh # DynamoDB Local table bootstrap │ ├── deploy-frontend.sh # build + s3 sync + CloudFront invalidation -│ └── deploy-backend.sh # docker build/push + ECS rollout +│ └── deploy-backend.sh # docker build/push + lambda update-function-code └── terraform/ ├── main.tf # module wiring ├── providers.tf # default region + us-east-1 alias (ACM/CloudFront) @@ -58,11 +61,11 @@ infra/ ├── dns/ # Route 53 hosted zone (create or look up) ├── acm/ # us-east-1 certificate + DNS validation ├── storage/ # S3 frontend + media buckets - ├── database/ # 3 DynamoDB tables (PAY_PER_REQUEST) + ├── database/ # 4 DynamoDB tables (PAY_PER_REQUEST) ├── auth/ # Cognito user pool, SPA client, hosted UI, group ├── email/ # SES domain identity + DKIM records - ├── backend/ # ECR, ECS Fargate, ALB, IAM, CloudWatch logs - └── cdn/ # CloudFront distribution + aliases + OAC policy + ├── backend/ # ECR, Lambda + Function URL, IAM, CloudWatch logs + └── cdn/ # CloudFront distribution + aliases + OAC + invoke perm ``` --- @@ -109,21 +112,39 @@ cd infra/terraform cp terraform.tfvars.example terraform.tfvars # then edit terraform init -terraform plan # review: ~60 resources +terraform plan # review: ~50 resources +``` + +A container-image Lambda cannot be created before its (arm64) image exists in +ECR, so the very first apply is two-phase: + +```bash +# 1. Create the ECR repository first... +terraform apply -target=module.backend.aws_ecr_repository.backend + +# 2. ...build and push an arm64 image into it (the deploy script's final +# update-function-code step can't run yet, so push directly here)... +ECR=$(terraform output -raw ecr_repository_url) +aws ecr get-login-password --region eu-west-1 \ + | docker login --username AWS --password-stdin "${ECR%%/*}" +docker build --platform linux/arm64 -t "$ECR:latest" ../../backend +docker push "$ECR:latest" + +# 3. ...then apply everything else. terraform apply ``` -The first apply takes ~10–15 minutes (CloudFront is the slow part). The ECS -service will report failing tasks until step 4 pushes the first image — -that is expected. +The full apply takes ~10–15 minutes (CloudFront is the slow part). On +subsequent deploys the Lambda already exists, so `deploy-backend.sh` alone +ships backend changes — no Terraform needed. ### 3. ACM certificate (automatic) Nothing manual. Terraform: 1. Requests a certificate in **us-east-1** for `marcomanduca.dev` + - `www.marcomanduca.dev` (CloudFront requirement) and a regional one for - `api-origin.marcomanduca.dev` (ALB). + `www.marcomanduca.dev` (CloudFront requirement). The backend needs no + certificate of its own — the Lambda Function URL is HTTPS out of the box. 2. Writes the DNS validation CNAMEs into the hosted zone. 3. Waits until ACM validates them (usually < 5 minutes). @@ -133,10 +154,9 @@ one actually attached to the registered domain (matching NS records). ### 4. First deploys ```bash -# Backend: build, push to ECR, roll the ECS service +# Backend: build, push to ECR, update the Lambda function ECR_REPOSITORY_URL=$(terraform -chdir=infra/terraform output -raw ecr_repository_url) \ -ECS_CLUSTER=$(terraform -chdir=infra/terraform output -raw ecs_cluster_name) \ -ECS_SERVICE=$(terraform -chdir=infra/terraform output -raw ecs_service_name) \ +FUNCTION_NAME=$(terraform -chdir=infra/terraform output -raw backend_function_name) \ ./infra/scripts/deploy-backend.sh # Frontend: build, sync to S3, invalidate CloudFront @@ -196,7 +216,7 @@ low volume"). Approval usually takes ~24 h. ### 7. Environment variable mapping -The ECS task definition already injects every backend variable below — +The Lambda function already injects every backend variable below — this table is for running the backend **outside** Docker or building the frontend `.env.production`. @@ -208,6 +228,7 @@ Backend variable names must match the `Settings` fields in | `dynamodb_table_names["projects"]` | `PROJECTS_TABLE_NAME` | — | | `dynamodb_table_names["learning"]` | `LEARNING_TABLE_NAME` | — | | `dynamodb_table_names["technologies"]` | `TECHNOLOGIES_TABLE_NAME` | — | +| `dynamodb_table_names["ratelimit"]` | `RATELIMIT_TABLE_NAME` | — | | `media_bucket_name` | `MEDIA_BUCKET_NAME` | — | | `cognito_user_pool_id` | `COGNITO_USER_POOL_ID` | `VITE_COGNITO_USER_POOL_ID` | | `cognito_client_id` | `COGNITO_CLIENT_ID` | `VITE_COGNITO_CLIENT_ID` | @@ -215,7 +236,7 @@ Backend variable names must match the `Settings` fields in | `noreply@` (convention) | `SES_SENDER_EMAIL` | — | | contact recipient (tfvars `contact_email`) | `SES_RECIPIENT_EMAIL` | — | | `https://` (convention) | `CORS_ORIGINS` | — | -| `/api/v1` (relative; CloudFront routes to ALB)| — | `VITE_API_BASE_URL` | +| `/api/v1` (relative; CloudFront routes to Lambda)| — | `VITE_API_BASE_URL` | | region (tfvars `aws_region`) | `AWS_REGION` | — | ### 8. Cost overview (low-traffic personal site, monthly) @@ -223,19 +244,19 @@ Backend variable names must match the `Settings` fields in | Service | Estimate (USD) | Notes | |--------------------------|---------------:|-----------------------------------------| | Route 53 | ~0.90 | hosted zone 0.50 + queries; +14/year domain | -| ECS Fargate (1 task, 0.25 vCPU / 512 MB) | ~10 | the main fixed cost | -| Application Load Balancer| ~17 | fixed hourly + minimal LCU | +| Lambda (backend) | ~0 | free tier: 1M requests + 400k GB-s/mo | | CloudFront | ~0–1 | free tier covers personal traffic | | S3 (2 buckets) | < 1 | a few GB of assets | -| DynamoDB (on-demand) | < 1 | pennies at this scale | +| DynamoDB (on-demand) | < 1 | pennies at this scale (incl. rate-limit table) | | Cognito | 0 | free tier: 10k MAU | | SES | ~0 | 0.10 per 1 000 emails | -| ECR + CloudWatch logs | < 1 | 10-image cap, 30-day log retention | -| **Total** | **~30** | ALB + Fargate dominate | +| ECR + CloudWatch logs | < 1 | 10-image cap, 14-day log retention | +| **Total** | **~1–2** | dominated by the Route 53 hosted zone | -Cheaper alternatives if ~30 USD/month is too much: replace ECS+ALB with -Lambda + API Gateway (near zero at this traffic), or App Runner. The current -setup was chosen for a standard, container-based workflow. +The backend used to run on ECS Fargate behind an ALB (~30 USD/mo once the +always-on task, the ALB and public IPv4 charges are added up). Moving it to a +Lambda container image (same code, Lambda Web Adapter) cut that to roughly the +cost of the hosted zone. The trade-off is an occasional ~1–2 s cold start. ### 9. Local development @@ -267,6 +288,5 @@ Notes: |----------------------------|--------------------------------------------------------------------| | Deploy backend | `./infra/scripts/deploy-backend.sh` (env vars from terraform output) | | Deploy frontend | `./infra/scripts/deploy-frontend.sh` (env vars from terraform output) | -| Tail backend logs | `aws logs tail /ecs/marcomanduca-dev-backend --follow` | -| Rotate origin secret | `terraform apply -replace=module.backend.random_password.origin_verify` | +| Tail backend logs | `aws logs tail /aws/lambda/marcomanduca-dev-backend --follow` | | Infrastructure change | edit Terraform → `terraform plan` → `terraform apply` |