diff --git a/README.md b/README.md
index 460ce38..ff92dfb 100644
--- a/README.md
+++ b/README.md
@@ -19,3 +19,4 @@ This is an example repository to showcase some IaC usage with different cloud pr
- [aws-ec2+rds](html-db-website/aws-ec2+rds)
- [aws-ecs+rds](html-db-website/aws-ecs+rds)
- [aws-eks](html-db-website/aws-eks)
+ - [aws-s3+lambda+rds](html-db-website/aws-s3+lambda+rds)
diff --git a/html-db-website/aws-s3+lambda+rds/README.md b/html-db-website/aws-s3+lambda+rds/README.md
new file mode 100644
index 0000000..7232331
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/README.md
@@ -0,0 +1,89 @@
+# AWS-S3+Lambda+RDS
+
+This is an example repository containing Terraform code. It contains the code to deploy a basic application (html web page + relational database) with S3, Lambda and RDS.
+We are using Api gateway and Cloudfront (mostly to avoid CORS issues) to expose the application.
+
+## Tree
+```
+.
+├── app
+│ ├── app.py
+│ ├── lambda.zip
+│ ├── requirements.txt
+│ └── zip.sh # Helper script to run to generate lambda.zip
+├── misc
+│ └── architecture.dot.png # Generated with https://github.com/patrickchugh/terravision.
+├── README.md
+└── terraform
+ ├── iam.tf
+ ├── main.tf
+ ├── network.tf
+ ├── outputs.tf
+ ├── provider.tf
+ ├── s3.tf
+ ├── security_group.tf
+ ├── templates
+ │ └── index.html.tftpl
+ └── variables.tf
+```
+
+## Architecture diagram
+
+
+
+## Infracost
+
+```shell
+ Name Monthly Qty Unit Monthly Cost
+
+ aws_db_instance.postgres
+ ├─ Database instance (on-demand, Single-AZ, db.t3.micro) 730 hours $13.14
+ └─ Storage (general purpose SSD, gp2) 20 GB $2.30
+
+ aws_apigatewayv2_api.http
+ └─ Requests (first 300M) Monthly cost depends on usage: $1.00 per 1M requests
+
+ aws_cloudfront_distribution.s3_distribution
+ ├─ Invalidation requests (first 1k) Monthly cost depends on usage: $0.00 per paths
+ └─ US, Mexico, Canada
+ ├─ Data transfer out to internet (first 10TB) Monthly cost depends on usage: $0.085 per GB
+ ├─ Data transfer out to origin Monthly cost depends on usage: $0.02 per GB
+ ├─ HTTP requests Monthly cost depends on usage: $0.0075 per 10k requests
+ └─ HTTPS requests Monthly cost depends on usage: $0.01 per 10k requests
+
+ aws_lambda_function.this
+ ├─ Requests Monthly cost depends on usage: $0.20 per 1M requests
+ ├─ Ephemeral storage Monthly cost depends on usage: $0.0000000309 per GB-seconds
+ └─ Duration (first 6B) Monthly cost depends on usage: $0.0000166667 per GB-seconds
+
+ aws_s3_bucket.frontend
+ └─ Standard
+ ├─ Storage Monthly cost depends on usage: $0.023 per GB
+ ├─ PUT, COPY, POST, LIST requests Monthly cost depends on usage: $0.005 per 1k requests
+ ├─ GET, SELECT, and all other requests Monthly cost depends on usage: $0.0004 per 1k requests
+ ├─ Select data scanned Monthly cost depends on usage: $0.002 per GB
+ └─ Select data returned Monthly cost depends on usage: $0.0007 per GB
+
+ OVERALL TOTAL $15.44
+
+*Usage costs can be estimated by updating Infracost Cloud settings, see docs for other options.
+
+──────────────────────────────────
+22 cloud resources were detected:
+∙ 5 were estimated
+∙ 17 were free
+
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
+┃ Project ┃ Baseline cost ┃ Usage cost* ┃ Total cost ┃
+┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━━━━━━━━┫
+┃ terraform ┃ $15 ┃ - ┃ $15 ┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━━━━━━━━┛
+```
+
+## Helpful informations
+
+Must export database username and password before usage.
+```shell
+export TF_VAR_db_username=
+export TF_VAR_db_password=
+```
diff --git a/html-db-website/aws-s3+lambda+rds/app/app.py b/html-db-website/aws-s3+lambda+rds/app/app.py
new file mode 100644
index 0000000..cf34b65
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/app/app.py
@@ -0,0 +1,83 @@
+import os
+import ssl
+from flask import Flask, request, jsonify
+from flask_sqlalchemy import SQLAlchemy
+from io import BytesIO
+
+app = Flask(__name__)
+
+ssl_context = ssl.create_default_context()
+ssl_context.check_hostname = False
+ssl_context.verify_mode = ssl.CERT_NONE
+
+db_url = os.environ.get('DATABASE_URL')
+app.config['SQLALCHEMY_DATABASE_URI'] = db_url
+app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
+ 'connect_args': {'ssl_context': ssl_context}
+}
+db = SQLAlchemy(app)
+
+class Task(db.Model):
+ id = db.Column(db.Integer, primary_key=True)
+ content = db.Column(db.String(200), nullable=False)
+
+@app.route('/tasks', methods=['GET', 'POST'])
+def tasks():
+ with app.app_context():
+ db.create_all()
+ if request.method == 'POST':
+ data = request.get_json()
+ new_task = Task(content=data['content'])
+ db.session.add(new_task)
+ db.session.commit()
+ return jsonify({"message": "Task added"}), 201
+ all_tasks = Task.query.all()
+ return jsonify([{"id": t.id, "content": t.content} for t in all_tasks])
+
+def handler(event, context):
+ method = event.get('requestContext', {}).get('http', {}).get('method', 'GET')
+ path = event.get('rawPath', '/')
+ query = event.get('rawQueryString', '')
+ headers = event.get('headers', {})
+ body = event.get('body', '') or ''
+ if event.get('isBase64Encoded'):
+ import base64
+ body = base64.b64decode(body)
+ else:
+ body = body.encode('utf-8')
+
+ environ = {
+ 'REQUEST_METHOD': method,
+ 'PATH_INFO': path,
+ 'QUERY_STRING': query,
+ 'CONTENT_LENGTH': str(len(body)),
+ 'CONTENT_TYPE': headers.get('content-type', ''),
+ 'SERVER_NAME': 'lambda',
+ 'SERVER_PORT': '443',
+ 'wsgi.input': BytesIO(body),
+ 'wsgi.errors': BytesIO(),
+ 'wsgi.url_scheme': 'https',
+ 'wsgi.multithread': False,
+ 'wsgi.multiprocess': False,
+ 'wsgi.run_once': False,
+ }
+ for k, v in headers.items():
+ key = 'HTTP_' + k.upper().replace('-', '_')
+ environ[key] = v
+
+ response_started = {}
+ response_body = []
+
+ def start_response(status, response_headers, exc_info=None):
+ response_started['status'] = int(status.split(' ', 1)[0])
+ response_started['headers'] = dict(response_headers)
+
+ result = app(environ, start_response)
+ for chunk in result:
+ response_body.append(chunk)
+
+ return {
+ 'statusCode': response_started['status'],
+ 'headers': response_started['headers'],
+ 'body': b''.join(response_body).decode('utf-8'),
+ }
diff --git a/html-db-website/aws-s3+lambda+rds/app/lambda.zip b/html-db-website/aws-s3+lambda+rds/app/lambda.zip
new file mode 100644
index 0000000..02f8290
Binary files /dev/null and b/html-db-website/aws-s3+lambda+rds/app/lambda.zip differ
diff --git a/html-db-website/aws-s3+lambda+rds/app/requirements.txt b/html-db-website/aws-s3+lambda+rds/app/requirements.txt
new file mode 100644
index 0000000..275cb72
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/app/requirements.txt
@@ -0,0 +1,3 @@
+Flask==3.0.0
+Flask-SQLAlchemy==3.1.1
+pg8000==1.30.1
diff --git a/html-db-website/aws-s3+lambda+rds/app/zip.sh b/html-db-website/aws-s3+lambda+rds/app/zip.sh
new file mode 100755
index 0000000..e26b8aa
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/app/zip.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+
+set -euo pipefail
+
+rm -rf build lambda.zip
+mkdir build
+
+cp app.py build/
+
+# Build dependencies for Lambda using Docker
+docker run --rm \
+ -v "$(pwd)/build":/var/task \
+ -v "$(pwd)/requirements.txt":/var/requirements.txt \
+ --entrypoint "" \
+ public.ecr.aws/lambda/python:3.11 \
+ python3.11 -m pip install -r /var/requirements.txt -t /var/task
+
+cd build
+zip -r ../lambda.zip .
+cd ..
diff --git a/html-db-website/aws-s3+lambda+rds/misc/architecture.dot.png b/html-db-website/aws-s3+lambda+rds/misc/architecture.dot.png
new file mode 100644
index 0000000..2850889
Binary files /dev/null and b/html-db-website/aws-s3+lambda+rds/misc/architecture.dot.png differ
diff --git a/html-db-website/aws-s3+lambda+rds/terraform/iam.tf b/html-db-website/aws-s3+lambda+rds/terraform/iam.tf
new file mode 100644
index 0000000..27df334
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/terraform/iam.tf
@@ -0,0 +1,47 @@
+resource "aws_iam_role" "lambda" {
+ name = "${var.function_name}-role"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Principal = { Service = "lambda.amazonaws.com" }
+ Action = "sts:AssumeRole"
+ }]
+ })
+}
+
+resource "aws_iam_role_policy_attachment" "vpc_access" {
+ role = aws_iam_role.lambda.name
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
+}
+
+resource "aws_lambda_permission" "api" {
+ statement_id = "AllowAPIGatewayInvoke"
+ action = "lambda:InvokeFunction"
+ function_name = aws_lambda_function.this.function_name
+ principal = "apigateway.amazonaws.com"
+ source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
+}
+
+resource "aws_s3_bucket_public_access_block" "frontend" {
+ bucket = aws_s3_bucket.frontend.id
+
+ block_public_acls = false
+ block_public_policy = false
+ ignore_public_acls = false
+ restrict_public_buckets = false
+}
+
+resource "aws_s3_bucket_policy" "public_read" {
+ depends_on = [aws_s3_bucket_public_access_block.frontend]
+
+ bucket = aws_s3_bucket.frontend.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow", Principal = "*", Action = "s3:GetObject"
+ Resource = "${aws_s3_bucket.frontend.arn}/*"
+ }]
+ })
+}
diff --git a/html-db-website/aws-s3+lambda+rds/terraform/main.tf b/html-db-website/aws-s3+lambda+rds/terraform/main.tf
new file mode 100644
index 0000000..c8b3391
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/terraform/main.tf
@@ -0,0 +1,79 @@
+locals {
+ zip_path = "${path.module}/../app/lambda.zip"
+}
+
+resource "aws_lambda_function" "this" {
+ function_name = var.function_name
+ role = aws_iam_role.lambda.arn
+ runtime = "python3.11"
+ handler = "app.handler"
+
+ filename = local.zip_path
+ source_code_hash = filebase64sha256(local.zip_path)
+
+ timeout = 15
+
+ vpc_config {
+ subnet_ids = aws_subnet.private[*].id
+ security_group_ids = [aws_security_group.lambda_sg.id]
+ }
+
+ environment {
+ variables = {
+ DATABASE_URL = "postgresql+pg8000://${var.db_username}:${var.db_password}@${aws_db_instance.postgres.address}/${var.db_name}"
+ }
+ }
+}
+
+resource "aws_apigatewayv2_api" "http" {
+ name = "${var.function_name}-api"
+ protocol_type = "HTTP"
+
+ cors_configuration {
+ allow_origins = ["https://${aws_cloudfront_distribution.s3_distribution.domain_name}"]
+ allow_methods = ["GET", "POST", "OPTIONS"]
+ allow_headers = ["content-type"]
+ }
+}
+
+resource "aws_apigatewayv2_integration" "lambda" {
+ api_id = aws_apigatewayv2_api.http.id
+ integration_type = "AWS_PROXY"
+ integration_uri = aws_lambda_function.this.invoke_arn
+ payload_format_version = "2.0"
+}
+
+resource "aws_apigatewayv2_route" "get_tasks" {
+ api_id = aws_apigatewayv2_api.http.id
+ route_key = "GET /tasks"
+ target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
+}
+
+resource "aws_apigatewayv2_route" "post_tasks" {
+ api_id = aws_apigatewayv2_api.http.id
+ route_key = "POST /tasks"
+ target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
+}
+
+resource "aws_apigatewayv2_stage" "default" {
+ api_id = aws_apigatewayv2_api.http.id
+ name = "$default"
+ auto_deploy = true
+}
+
+resource "aws_db_subnet_group" "db" {
+ name = "main"
+ subnet_ids = aws_subnet.private[*].id
+}
+
+resource "aws_db_instance" "postgres" {
+ allocated_storage = 20
+ engine = "postgres"
+ instance_class = "db.t3.micro"
+ db_name = var.db_name
+ username = var.db_username
+ password = var.db_password
+ db_subnet_group_name = aws_db_subnet_group.db.name
+ vpc_security_group_ids = [aws_security_group.rds_sg.id]
+ skip_final_snapshot = true
+}
diff --git a/html-db-website/aws-s3+lambda+rds/terraform/network.tf b/html-db-website/aws-s3+lambda+rds/terraform/network.tf
new file mode 100644
index 0000000..320893b
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/terraform/network.tf
@@ -0,0 +1,13 @@
+resource "aws_vpc" "main" {
+ cidr_block = "10.0.0.0/16"
+ enable_dns_hostnames = true
+}
+
+resource "aws_subnet" "private" {
+ count = 2
+ vpc_id = aws_vpc.main.id
+ cidr_block = "10.0.${count.index}.0/24"
+ availability_zone = data.aws_availability_zones.available.names[count.index]
+}
+
+data "aws_availability_zones" "available" {}
diff --git a/html-db-website/aws-s3+lambda+rds/terraform/outputs.tf b/html-db-website/aws-s3+lambda+rds/terraform/outputs.tf
new file mode 100644
index 0000000..21982ee
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/terraform/outputs.tf
@@ -0,0 +1,7 @@
+output "website_url" {
+ value = "https://${aws_cloudfront_distribution.s3_distribution.domain_name}"
+}
+
+output "api_url" {
+ value = aws_apigatewayv2_api.http.api_endpoint
+}
diff --git a/html-db-website/aws-s3+lambda+rds/terraform/provider.tf b/html-db-website/aws-s3+lambda+rds/terraform/provider.tf
new file mode 100644
index 0000000..9403d13
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/terraform/provider.tf
@@ -0,0 +1,17 @@
+terraform {
+ required_version = ">= 1.6"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ }
+}
+
+provider "aws" {
+ region = var.aws_region
+ s3_use_path_style = true
+ skip_credentials_validation = false
+ skip_metadata_api_check = false
+ skip_requesting_account_id = true
+}
diff --git a/html-db-website/aws-s3+lambda+rds/terraform/s3.tf b/html-db-website/aws-s3+lambda+rds/terraform/s3.tf
new file mode 100644
index 0000000..34dd495
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/terraform/s3.tf
@@ -0,0 +1,50 @@
+resource "aws_s3_bucket" "frontend" {
+ bucket = "html-db-public-s3-bucket"
+}
+
+resource "aws_s3_object" "index" {
+ bucket = aws_s3_bucket.frontend.id
+ key = "index.html"
+ content = templatefile("${path.module}/templates/index.html.tftpl", {
+ api_url = aws_apigatewayv2_stage.default.invoke_url
+ })
+ content_type = "text/html"
+ etag = md5(templatefile("${path.module}/templates/index.html.tftpl", {
+ api_url = aws_apigatewayv2_stage.default.invoke_url
+ }))
+}
+
+resource "aws_s3_bucket_website_configuration" "hosting" {
+ bucket = aws_s3_bucket.frontend.id
+ index_document { suffix = "index.html" }
+}
+
+resource "aws_cloudfront_distribution" "s3_distribution" {
+ origin {
+ domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
+ origin_id = "S3-Origin"
+ }
+ enabled = true
+ default_root_object = "index.html"
+ default_cache_behavior {
+ allowed_methods = ["GET", "HEAD"]
+ cached_methods = ["GET", "HEAD"]
+ target_origin_id = "S3-Origin"
+ viewer_protocol_policy = "redirect-to-https"
+ forwarded_values {
+ query_string = false
+ cookies {
+ forward = "none"
+ }
+ }
+ }
+ viewer_certificate {
+ cloudfront_default_certificate = true
+ }
+ restrictions {
+ geo_restriction {
+ restriction_type = "none"
+ locations = []
+ }
+ }
+}
diff --git a/html-db-website/aws-s3+lambda+rds/terraform/security_group.tf b/html-db-website/aws-s3+lambda+rds/terraform/security_group.tf
new file mode 100644
index 0000000..260b43e
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/terraform/security_group.tf
@@ -0,0 +1,22 @@
+resource "aws_security_group" "lambda_sg" {
+ name = "lambda-sg"
+ vpc_id = aws_vpc.main.id
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+}
+
+resource "aws_security_group" "rds_sg" {
+ name = "rds-sg"
+ vpc_id = aws_vpc.main.id
+ ingress {
+ from_port = 5432
+ to_port = 5432
+ protocol = "tcp"
+ security_groups = [aws_security_group.lambda_sg.id]
+ }
+}
+
diff --git a/html-db-website/aws-s3+lambda+rds/terraform/templates/index.html.tftpl b/html-db-website/aws-s3+lambda+rds/terraform/templates/index.html.tftpl
new file mode 100644
index 0000000..365f292
--- /dev/null
+++ b/html-db-website/aws-s3+lambda+rds/terraform/templates/index.html.tftpl
@@ -0,0 +1,41 @@
+
+
+