Skip to content

Commit cbb8e60

Browse files
committed
feat: ✨ aws-s3+lambda+rds html-db-website
1 parent b8aa1c0 commit cbb8e60

File tree

16 files changed

+501
-0
lines changed

16 files changed

+501
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ This is an example repository to showcase some IaC usage with different cloud pr
1919
- [aws-ec2+rds](html-db-website/aws-ec2+rds)
2020
- [aws-ecs+rds](html-db-website/aws-ecs+rds)
2121
- [aws-eks](html-db-website/aws-eks)
22+
- [aws-s3+lambda+rds](html-db-website/aws-s3+lambda+rds)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# AWS-S3+Lambda+RDS
2+
3+
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.
4+
We are using Api gateway and Cloudfront (mostly to avoid CORS issues) to expose the application.
5+
6+
## Tree
7+
```
8+
.
9+
├── app
10+
│   ├── app.py
11+
│   ├── lambda.zip
12+
│   ├── requirements.txt
13+
│   └── zip.sh # Helper script to run to generate lambda.zip
14+
├── misc
15+
│   └── architecture.dot.png # Generated with https://github.com/patrickchugh/terravision.
16+
├── README.md
17+
└── terraform
18+
├── iam.tf
19+
├── main.tf
20+
├── network.tf
21+
├── outputs.tf
22+
├── provider.tf
23+
├── s3.tf
24+
├── security_group.tf
25+
├── templates
26+
│   └── index.html.tftpl
27+
└── variables.tf
28+
```
29+
30+
## Architecture diagram
31+
32+
<img src="./misc/architecture.dot.png">
33+
34+
## Infracost
35+
36+
```shell
37+
Name Monthly Qty Unit Monthly Cost
38+
39+
aws_db_instance.postgres
40+
├─ Database instance (on-demand, Single-AZ, db.t3.micro) 730 hours $13.14
41+
└─ Storage (general purpose SSD, gp2) 20 GB $2.30
42+
43+
aws_apigatewayv2_api.http
44+
└─ Requests (first 300M) Monthly cost depends on usage: $1.00 per 1M requests
45+
46+
aws_cloudfront_distribution.s3_distribution
47+
├─ Invalidation requests (first 1k) Monthly cost depends on usage: $0.00 per paths
48+
└─ US, Mexico, Canada
49+
├─ Data transfer out to internet (first 10TB) Monthly cost depends on usage: $0.085 per GB
50+
├─ Data transfer out to origin Monthly cost depends on usage: $0.02 per GB
51+
├─ HTTP requests Monthly cost depends on usage: $0.0075 per 10k requests
52+
└─ HTTPS requests Monthly cost depends on usage: $0.01 per 10k requests
53+
54+
aws_lambda_function.this
55+
├─ Requests Monthly cost depends on usage: $0.20 per 1M requests
56+
├─ Ephemeral storage Monthly cost depends on usage: $0.0000000309 per GB-seconds
57+
└─ Duration (first 6B) Monthly cost depends on usage: $0.0000166667 per GB-seconds
58+
59+
aws_s3_bucket.frontend
60+
└─ Standard
61+
├─ Storage Monthly cost depends on usage: $0.023 per GB
62+
├─ PUT, COPY, POST, LIST requests Monthly cost depends on usage: $0.005 per 1k requests
63+
├─ GET, SELECT, and all other requests Monthly cost depends on usage: $0.0004 per 1k requests
64+
├─ Select data scanned Monthly cost depends on usage: $0.002 per GB
65+
└─ Select data returned Monthly cost depends on usage: $0.0007 per GB
66+
67+
OVERALL TOTAL $15.44
68+
69+
*Usage costs can be estimated by updating Infracost Cloud settings, see docs for other options.
70+
71+
──────────────────────────────────
72+
22 cloud resources were detected:
73+
∙ 5 were estimated
74+
∙ 17 were free
75+
76+
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
77+
┃ Project ┃ Baseline cost ┃ Usage cost* ┃ Total cost ┃
78+
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━━━━━━━━┫
79+
┃ terraform ┃ $15 ┃ - ┃ $15 ┃
80+
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━━━━━━━━┛
81+
```
82+
83+
## Helpful informations
84+
85+
Must export database username and password before usage.
86+
```shell
87+
export TF_VAR_db_username=
88+
export TF_VAR_db_password=
89+
```
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import os
2+
import ssl
3+
from flask import Flask, request, jsonify
4+
from flask_sqlalchemy import SQLAlchemy
5+
from io import BytesIO
6+
7+
app = Flask(__name__)
8+
9+
ssl_context = ssl.create_default_context()
10+
ssl_context.check_hostname = False
11+
ssl_context.verify_mode = ssl.CERT_NONE
12+
13+
db_url = os.environ.get('DATABASE_URL')
14+
app.config['SQLALCHEMY_DATABASE_URI'] = db_url
15+
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
16+
'connect_args': {'ssl_context': ssl_context}
17+
}
18+
db = SQLAlchemy(app)
19+
20+
class Task(db.Model):
21+
id = db.Column(db.Integer, primary_key=True)
22+
content = db.Column(db.String(200), nullable=False)
23+
24+
@app.route('/tasks', methods=['GET', 'POST'])
25+
def tasks():
26+
with app.app_context():
27+
db.create_all()
28+
if request.method == 'POST':
29+
data = request.get_json()
30+
new_task = Task(content=data['content'])
31+
db.session.add(new_task)
32+
db.session.commit()
33+
return jsonify({"message": "Task added"}), 201
34+
all_tasks = Task.query.all()
35+
return jsonify([{"id": t.id, "content": t.content} for t in all_tasks])
36+
37+
def handler(event, context):
38+
method = event.get('requestContext', {}).get('http', {}).get('method', 'GET')
39+
path = event.get('rawPath', '/')
40+
query = event.get('rawQueryString', '')
41+
headers = event.get('headers', {})
42+
body = event.get('body', '') or ''
43+
if event.get('isBase64Encoded'):
44+
import base64
45+
body = base64.b64decode(body)
46+
else:
47+
body = body.encode('utf-8')
48+
49+
environ = {
50+
'REQUEST_METHOD': method,
51+
'PATH_INFO': path,
52+
'QUERY_STRING': query,
53+
'CONTENT_LENGTH': str(len(body)),
54+
'CONTENT_TYPE': headers.get('content-type', ''),
55+
'SERVER_NAME': 'lambda',
56+
'SERVER_PORT': '443',
57+
'wsgi.input': BytesIO(body),
58+
'wsgi.errors': BytesIO(),
59+
'wsgi.url_scheme': 'https',
60+
'wsgi.multithread': False,
61+
'wsgi.multiprocess': False,
62+
'wsgi.run_once': False,
63+
}
64+
for k, v in headers.items():
65+
key = 'HTTP_' + k.upper().replace('-', '_')
66+
environ[key] = v
67+
68+
response_started = {}
69+
response_body = []
70+
71+
def start_response(status, response_headers, exc_info=None):
72+
response_started['status'] = int(status.split(' ', 1)[0])
73+
response_started['headers'] = dict(response_headers)
74+
75+
result = app(environ, start_response)
76+
for chunk in result:
77+
response_body.append(chunk)
78+
79+
return {
80+
'statusCode': response_started['status'],
81+
'headers': response_started['headers'],
82+
'body': b''.join(response_body).decode('utf-8'),
83+
}
9.96 MB
Binary file not shown.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Flask==3.0.0
2+
Flask-SQLAlchemy==3.1.1
3+
pg8000==1.30.1
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/bin/bash
2+
3+
set -euo pipefail
4+
5+
rm -rf build lambda.zip
6+
mkdir build
7+
8+
cp app.py build/
9+
10+
# Build dependencies for Lambda using Docker
11+
docker run --rm \
12+
-v "$(pwd)/build":/var/task \
13+
-v "$(pwd)/requirements.txt":/var/requirements.txt \
14+
--entrypoint "" \
15+
public.ecr.aws/lambda/python:3.11 \
16+
python3.11 -m pip install -r /var/requirements.txt -t /var/task
17+
18+
cd build
19+
zip -r ../lambda.zip .
20+
cd ..
279 KB
Loading
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
resource "aws_iam_role" "lambda" {
2+
name = "${var.function_name}-role"
3+
4+
assume_role_policy = jsonencode({
5+
Version = "2012-10-17"
6+
Statement = [{
7+
Effect = "Allow"
8+
Principal = { Service = "lambda.amazonaws.com" }
9+
Action = "sts:AssumeRole"
10+
}]
11+
})
12+
}
13+
14+
resource "aws_iam_role_policy_attachment" "vpc_access" {
15+
role = aws_iam_role.lambda.name
16+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
17+
}
18+
19+
resource "aws_lambda_permission" "api" {
20+
statement_id = "AllowAPIGatewayInvoke"
21+
action = "lambda:InvokeFunction"
22+
function_name = aws_lambda_function.this.function_name
23+
principal = "apigateway.amazonaws.com"
24+
source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*"
25+
}
26+
27+
resource "aws_s3_bucket_public_access_block" "frontend" {
28+
bucket = aws_s3_bucket.frontend.id
29+
30+
block_public_acls = false
31+
block_public_policy = false
32+
ignore_public_acls = false
33+
restrict_public_buckets = false
34+
}
35+
36+
resource "aws_s3_bucket_policy" "public_read" {
37+
depends_on = [aws_s3_bucket_public_access_block.frontend]
38+
39+
bucket = aws_s3_bucket.frontend.id
40+
policy = jsonencode({
41+
Version = "2012-10-17"
42+
Statement = [{
43+
Effect = "Allow", Principal = "*", Action = "s3:GetObject"
44+
Resource = "${aws_s3_bucket.frontend.arn}/*"
45+
}]
46+
})
47+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
locals {
2+
zip_path = "${path.module}/../app/lambda.zip"
3+
}
4+
5+
resource "aws_lambda_function" "this" {
6+
function_name = var.function_name
7+
role = aws_iam_role.lambda.arn
8+
runtime = "python3.11"
9+
handler = "app.handler"
10+
11+
filename = local.zip_path
12+
source_code_hash = filebase64sha256(local.zip_path)
13+
14+
timeout = 15
15+
16+
vpc_config {
17+
subnet_ids = aws_subnet.private[*].id
18+
security_group_ids = [aws_security_group.lambda_sg.id]
19+
}
20+
21+
environment {
22+
variables = {
23+
DATABASE_URL = "postgresql+pg8000://${var.db_username}:${var.db_password}@${aws_db_instance.postgres.address}/${var.db_name}"
24+
}
25+
}
26+
}
27+
28+
resource "aws_apigatewayv2_api" "http" {
29+
name = "${var.function_name}-api"
30+
protocol_type = "HTTP"
31+
32+
cors_configuration {
33+
allow_origins = ["https://${aws_cloudfront_distribution.s3_distribution.domain_name}"]
34+
allow_methods = ["GET", "POST", "OPTIONS"]
35+
allow_headers = ["content-type"]
36+
}
37+
}
38+
39+
resource "aws_apigatewayv2_integration" "lambda" {
40+
api_id = aws_apigatewayv2_api.http.id
41+
integration_type = "AWS_PROXY"
42+
integration_uri = aws_lambda_function.this.invoke_arn
43+
payload_format_version = "2.0"
44+
}
45+
46+
resource "aws_apigatewayv2_route" "get_tasks" {
47+
api_id = aws_apigatewayv2_api.http.id
48+
route_key = "GET /tasks"
49+
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
50+
}
51+
52+
resource "aws_apigatewayv2_route" "post_tasks" {
53+
api_id = aws_apigatewayv2_api.http.id
54+
route_key = "POST /tasks"
55+
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
56+
}
57+
58+
resource "aws_apigatewayv2_stage" "default" {
59+
api_id = aws_apigatewayv2_api.http.id
60+
name = "$default"
61+
auto_deploy = true
62+
}
63+
64+
resource "aws_db_subnet_group" "db" {
65+
name = "main"
66+
subnet_ids = aws_subnet.private[*].id
67+
}
68+
69+
resource "aws_db_instance" "postgres" {
70+
allocated_storage = 20
71+
engine = "postgres"
72+
instance_class = "db.t3.micro"
73+
db_name = var.db_name
74+
username = var.db_username
75+
password = var.db_password
76+
db_subnet_group_name = aws_db_subnet_group.db.name
77+
vpc_security_group_ids = [aws_security_group.rds_sg.id]
78+
skip_final_snapshot = true
79+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
resource "aws_vpc" "main" {
2+
cidr_block = "10.0.0.0/16"
3+
enable_dns_hostnames = true
4+
}
5+
6+
resource "aws_subnet" "private" {
7+
count = 2
8+
vpc_id = aws_vpc.main.id
9+
cidr_block = "10.0.${count.index}.0/24"
10+
availability_zone = data.aws_availability_zones.available.names[count.index]
11+
}
12+
13+
data "aws_availability_zones" "available" {}

0 commit comments

Comments
 (0)