Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
89 changes: 89 additions & 0 deletions html-db-website/aws-s3+lambda+rds/README.md
Original file line number Diff line number Diff line change
@@ -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

<img src="./misc/architecture.dot.png">

## 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=
```
83 changes: 83 additions & 0 deletions html-db-website/aws-s3+lambda+rds/app/app.py
Original file line number Diff line number Diff line change
@@ -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'),
}
Binary file added html-db-website/aws-s3+lambda+rds/app/lambda.zip
Binary file not shown.
3 changes: 3 additions & 0 deletions html-db-website/aws-s3+lambda+rds/app/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
pg8000==1.30.1
20 changes: 20 additions & 0 deletions html-db-website/aws-s3+lambda+rds/app/zip.sh
Original file line number Diff line number Diff line change
@@ -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 ..
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions html-db-website/aws-s3+lambda+rds/terraform/iam.tf
Original file line number Diff line number Diff line change
@@ -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}/*"
}]
})
}
79 changes: 79 additions & 0 deletions html-db-website/aws-s3+lambda+rds/terraform/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions html-db-website/aws-s3+lambda+rds/terraform/network.tf
Original file line number Diff line number Diff line change
@@ -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" {}
7 changes: 7 additions & 0 deletions html-db-website/aws-s3+lambda+rds/terraform/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions html-db-website/aws-s3+lambda+rds/terraform/provider.tf
Original file line number Diff line number Diff line change
@@ -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
}
Loading