Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for custom domain on OVH #3

Merged
merged 3 commits into from
Feb 18, 2024
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
39 changes: 32 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ with the AWS following services:
The distribution price class is set `PriceClass_100` (North America, Europe and Israel). It defines on which edge
location Cloudfront will serve the requests. In order to target another
audience, [change the price class.](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html)

Cloudfront also allows us to set up a custom domain for our website (NOT implemented in this project).
- An S3 bucket for Cloudfront standard access logs. It can be connected
to [AWS Athena](https://aws.amazon.com/blogs/big-data/easily-query-aws-service-logs-using-amazon-athena/) for further
analysis.
Expand All @@ -51,14 +49,35 @@ with the AWS following services:
[Lambda function concurrent executions](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html) that
we need to be aware of and monitor when serving the website to a larger audience.
- Xray for tracing on the Lambda function. Lambda logs are pushed to a Cloudwatch log group.
- A Dynamodb table will store the data. The data consists of some fake data about users (see [users.json](backend/users.json)).
Terraform reads that file and put the items in Dynamodb.
- A Dynamodb table will store the data. The data consists of some fake data about users (
see [users.json](backend/users.json)).
Terraform reads that file and put the items in Dynamodb.
A provisioned billing mode is used for this project. Depending on your usage, you may
consider [On Demand mode](https://aws.amazon.com/blogs/aws/amazon-dynamodb-on-demand-no-capacity-planning-and-pay-per-request-pricing/)
or increase the provisioned capacities.

This project also demonstrates the following features of Terraform:

- Cross-region deployment with the Cloudfront ACM certificate in us-east-1 (mandatory)
- Multi cloud by using OVH DNS zone instead of AWS Route53
- Terraform local provisioners to deploy the frontend to an S3 bucket only when there are changes

![Architecture image](img/architecture.png)

### Custom domain with OVH

If you have an existing DNS Zone on [OVH](https://www.ovhcloud.com/fr/), you can leverage it to have a custom domain on
top your CloudFront distribution. To use it, set the variable `ovh_domain_conf`.

Example:

```shell
# Will use the domain haidara.io on OVH to create a DNS record with the following format: ${var.prefix}-${var.env}.haidara.io
export TF_VAR_ovh_domain_conf='{"dns_zone_name": "haidara.io"}'
# Or this one will create demo.haidara.io`
export TF_VAR_ovh_domain_conf='{"dns_zone_name": "haidara.io", "subdomain": "demo"}'
```

### Screenshot

![Web page image](img/screenshot.png)
Expand All @@ -85,13 +104,16 @@ when:
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | >= 1.7 |
| <a name="requirement_archive"></a> [archive](#requirement\_archive) | ~> 2 |
| <a name="requirement_aws"></a> [aws](#requirement\_aws) | ~> 5 |
| <a name="requirement_ovh"></a> [ovh](#requirement\_ovh) | ~> 0.37 |

### Providers

| Name | Version |
|------|---------|
| <a name="provider_archive"></a> [archive](#provider\_archive) | ~> 2 |
| <a name="provider_aws"></a> [aws](#provider\_aws) | ~> 5 |
| <a name="provider_aws.cloudfront-us-east-1"></a> [aws.cloudfront-us-east-1](#provider\_aws.cloudfront-us-east-1) | ~> 5 |
| <a name="provider_ovh"></a> [ovh](#provider\_ovh) | ~> 0.37 |
| <a name="provider_terraform"></a> [terraform](#provider\_terraform) | n/a |

### Modules
Expand All @@ -102,6 +124,8 @@ No modules.

| Name | Type |
|------|------|
| [aws_acm_certificate.cf_certificate](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate) | resource |
| [aws_acm_certificate_validation.validation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate_validation) | resource |
| [aws_apigatewayv2_api.http_api](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_api) | resource |
| [aws_apigatewayv2_integration.lambda_integration](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_integration) | resource |
| [aws_apigatewayv2_route.users](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_route) | resource |
Expand All @@ -126,6 +150,8 @@ No modules.
| [aws_s3_bucket_policy.cf_origin_bucket_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_policy) | resource |
| [aws_s3_object.architecture_img](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_object) | resource |
| [aws_sns_topic.alerting](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic) | resource |
| [ovh_domain_zone_record.cert_validation_record](https://registry.terraform.io/providers/ovh/ovh/latest/docs/resources/domain_zone_record) | resource |
| [ovh_domain_zone_record.cf_record](https://registry.terraform.io/providers/ovh/ovh/latest/docs/resources/domain_zone_record) | resource |
| [terraform_data.deploy_to_s3](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
| [archive_file.lambda_package](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source |
| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
Expand All @@ -141,23 +167,22 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| <a name="input_aws_region"></a> [aws\_region](#input\_aws\_region) | Region to deploy to | `string` | `"eu-west-3"` | no |
| <a name="input_cloudfront_price_class"></a> [cloudfront\_price\_class](#input\_cloudfront\_price\_class) | The price class for this distribution. One of PriceClass\_All, PriceClass\_200, PriceClass\_100. See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html | `string` | `"PriceClass_100"` | no |
| <a name="input_default_tags"></a> [default\_tags](#input\_default\_tags) | Default tags to apply to resources | `map(string)` | <pre>{<br> "app": "devops-challenge"<br>}</pre> | no |
| <a name="input_env"></a> [env](#input\_env) | Name of the environment | `string` | `"dev"` | no |
| <a name="input_front_build_dir"></a> [front\_build\_dir](#input\_front\_build\_dir) | The folder where the frontend has been built | `string` | `"frontend/dist/devops-challenge/"` | no |
| <a name="input_lambda_directory"></a> [lambda\_directory](#input\_lambda\_directory) | The directory containing lambda | `string` | `"backend"` | no |
| <a name="input_ovh_domain_conf"></a> [ovh\_domain\_conf](#input\_ovh\_domain\_conf) | OVH DNS zone configuration if you want to use a custom domain. | <pre>object({<br> dns_zone_name = string<br> subdomain = optional(string, "")<br><br> })</pre> | <pre>{<br> "dns_zone_name": "",<br> "subdomain": ""<br>}</pre> | no |
| <a name="input_prefix"></a> [prefix](#input\_prefix) | A prefix appended to each resource | `string` | `"devops-challenge"` | no |

### Outputs

| Name | Description |
|------|-------------|
| <a name="output_cloudfront_url"></a> [cloudfront\_url](#output\_cloudfront\_url) | Cloudfront URL to access the website |
| <a name="output_custom_domain"></a> [custom\_domain](#output\_custom\_domain) | The custom domain name when OVH is used |
| <a name="output_frontend_bucket_name"></a> [frontend\_bucket\_name](#output\_frontend\_bucket\_name) | Name of the bucket containing the static files |
| <a name="output_users_endpoint"></a> [users\_endpoint](#output\_users\_endpoint) | API Gateway url to access users |
<!-- END OF PRE-COMMIT-TERRAFORM DOCS HOOK -->


### Repository: monorepo structure

This mono repository has the following structure:
Expand Down
11 changes: 1 addition & 10 deletions backend.tf
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
locals {
users_raw = jsondecode(file("${path.root}/backend/users.json"))
# change users list to a map of users suitable for Terraform for_each
users_map = { for u in local.users_raw : u["id"] => {
name = u["name"]
address = u["address"]
} }
}

resource "aws_iam_role" "lambda_role" {
name = "${var.prefix}-${var.env}-api-backend"
description = "IAM Role for the API Backend"
Expand Down Expand Up @@ -72,7 +63,7 @@ resource "aws_dynamodb_table" "users" {
}

resource "aws_dynamodb_table_item" "users" {
for_each = local.users_map
for_each = local.backend_users_map
table_name = aws_dynamodb_table.users.name
hash_key = aws_dynamodb_table.users.hash_key
range_key = aws_dynamodb_table.users.range_key
Expand Down
29 changes: 10 additions & 19 deletions frontend.tf
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
locals {
front_config_file = "${path.module}/${var.front_build_dir}/assets/config.tpl.json"

front_config_final_content = templatefile(local.front_config_file, {
api_url = aws_apigatewayv2_api.http_api.api_endpoint
env = var.env
}
)
cf_origin_id = "s3-website-origin-${var.env}"
}


# The bucket hosting the static files
resource "aws_s3_bucket" "origin_website" {
bucket = "${var.prefix}-${var.env}-${data.aws_caller_identity.current.account_id}"
Expand Down Expand Up @@ -72,21 +60,21 @@ resource "aws_s3_bucket_acl" "cf_logs_acl" {
resource "terraform_data" "deploy_to_s3" {
triggers_replace = [
aws_s3_bucket.origin_website.bucket,
filebase64sha256("${path.module}/${var.front_build_dir}/index.html"),
local.front_config_final_content
filebase64sha256("${path.module}/${local.frontend_build_dir}/index.html"),
local.frontend_config_final_content
]

# Generate the template for the frontend
provisioner "local-exec" {
command = "echo '${local.front_config_final_content}' > ${path.module}/${var.front_build_dir}/assets/config.json"
command = "echo '${local.frontend_config_final_content}' > ${path.module}/${local.frontend_build_dir}/assets/config.json"
}

/*
We suppose here that the required AWS credentials are exported in the environment variables.
Otherwise, the following AWS commands will not work.
*/
provisioner "local-exec" {
command = "aws s3 sync --exclude '${aws_s3_object.architecture_img.key}' --exclude 'assets/config.tpl.json' --delete ${var.front_build_dir} s3://${aws_s3_bucket.origin_website.bucket}"
command = "aws s3 sync --exclude '${aws_s3_object.architecture_img.key}' --exclude 'assets/config.tpl.json' --delete ${local.frontend_build_dir} s3://${aws_s3_bucket.origin_website.bucket}"
}

# Do not cache the index.html so that changes are deployed automatically. Other files are cached by default.
Expand All @@ -107,8 +95,9 @@ resource "aws_cloudfront_origin_access_identity" "origin_access_identity" {
resource "aws_cloudfront_distribution" "website" {
enabled = true
comment = "cloudfront distribution for devops challenge"
price_class = var.cloudfront_price_class
price_class = "PriceClass_100" # North America, Europe and Israel.
default_root_object = "index.html"
aliases = local.cf_aliases

# As it's an SPA, we let the SPA handle access to files not found in the bucket
custom_error_response {
Expand Down Expand Up @@ -142,8 +131,10 @@ resource "aws_cloudfront_distribution" "website" {
}

viewer_certificate {
# Because we don't use a custom domain with certificate
cloudfront_default_certificate = true
cloudfront_default_certificate = var.ovh_domain_conf.dns_zone_name == ""
acm_certificate_arn = var.ovh_domain_conf.dns_zone_name == "" ? null : aws_acm_certificate.cf_certificate[0].arn
minimum_protocol_version = var.ovh_domain_conf.dns_zone_name == "" ? "TLSv1" : "TLSv1.2_2021"
ssl_support_method = var.ovh_domain_conf.dns_zone_name == "" ? null : "sni-only"
}

logging_config {
Expand Down
26 changes: 26 additions & 0 deletions locals.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
locals {
# Frontend (Angular) related configuration
frontend_build_dir = "frontend/dist/devops-challenge/"
frontend_config_file = "${path.module}/${local.frontend_build_dir}/assets/config.tpl.json"

frontend_config_final_content = templatefile(local.frontend_config_file, {
api_url = aws_apigatewayv2_api.http_api.api_endpoint
env = var.env
}
)

# Custom domain related configuration
ovh_domain_name = var.ovh_domain_conf.dns_zone_name
cf_subdomain = var.ovh_domain_conf.subdomain == "" ? "${var.prefix}-${var.env}" : var.ovh_domain_conf.subdomain
cf_fqdn = "${local.cf_subdomain}.${local.ovh_domain_name}"
cf_aliases = var.ovh_domain_conf.dns_zone_name != "" ? [local.cf_fqdn] : []
cf_origin_id = "s3-website-origin-${var.env}"

# Backend related configuration
backend_users_raw = jsondecode(file("${path.root}/backend/users.json"))
# change users list to a map of users suitable for Terraform for_each
backend_users_map = { for u in local.backend_users_raw : u["id"] => {
name = u["name"]
address = u["address"]
} }
}
13 changes: 13 additions & 0 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ terraform {
source = "hashicorp/aws"
version = "~> 5"
}

ovh = {
source = "ovh/ovh"
version = "~> 0.37"
}

archive = {
source = "hashicorp/archive"
version = "~> 2"
Expand All @@ -21,4 +27,11 @@ provider "aws" {
}
}

provider "aws" { # Cloudfront cert needs to be in us-east-1
alias = "cloudfront-us-east-1"
region = "us-east-1"
}

provider "ovh" {
endpoint = "ovh-eu"
}
5 changes: 5 additions & 0 deletions outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ output "users_endpoint" {
description = "API Gateway url to access users"
value = "${aws_apigatewayv2_api.http_api.api_endpoint}/users"
}

output "custom_domain" {
description = "The custom domain name when OVH is used"
value = var.ovh_domain_conf.dns_zone_name == "" ? null : "https://${local.cf_fqdn}"
}
36 changes: 36 additions & 0 deletions r53-acm.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
resource "aws_acm_certificate" "cf_certificate" {
count = var.ovh_domain_conf.dns_zone_name == "" ? 0 : 1
provider = aws.cloudfront-us-east-1
domain_name = local.cf_fqdn
validation_method = "DNS"
tags = merge({ Name = local.cf_fqdn })

lifecycle {
create_before_destroy = true
}
}

resource "ovh_domain_zone_record" "cf_record" {
count = var.ovh_domain_conf.dns_zone_name == "" ? 0 : 1
fieldtype = "CNAME"
subdomain = local.cf_subdomain
target = "${aws_cloudfront_distribution.website.domain_name}."
zone = local.ovh_domain_name
ttl = 60
}

resource "ovh_domain_zone_record" "cert_validation_record" {
count = var.ovh_domain_conf.dns_zone_name == "" ? 0 : 1
fieldtype = "CNAME"
subdomain = replace(tolist(aws_acm_certificate.cf_certificate[0].domain_validation_options)[0].resource_record_name, ".${local.ovh_domain_name}.", "")
target = tolist(aws_acm_certificate.cf_certificate[0].domain_validation_options)[0].resource_record_value
zone = local.ovh_domain_name
ttl = 60
}

resource "aws_acm_certificate_validation" "validation" {
count = var.ovh_domain_conf.dns_zone_name == "" ? 0 : 1
provider = aws.cloudfront-us-east-1
certificate_arn = aws_acm_certificate.cf_certificate[0].arn
validation_record_fqdns = ["${ovh_domain_zone_record.cert_validation_record[0].subdomain}.${ovh_domain_zone_record.cert_validation_record[0].zone}"]
}
21 changes: 11 additions & 10 deletions variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,6 @@ variable "default_tags" {
}
}

variable "front_build_dir" {
description = "The folder where the frontend has been built"
type = string
default = "frontend/dist/devops-challenge/"
}

variable "lambda_directory" {
description = "The directory containing lambda"
type = string
Expand All @@ -36,8 +30,15 @@ variable "env" {
default = "dev"
}

variable "cloudfront_price_class" {
type = string
description = "The price class for this distribution. One of PriceClass_All, PriceClass_200, PriceClass_100. See https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html"
default = "PriceClass_100" # North America, Europe and Israel.
variable "ovh_domain_conf" {
description = "OVH DNS zone configuration if you want to use a custom domain."
type = object({
dns_zone_name = string
subdomain = optional(string, "")

})
default = {
dns_zone_name = ""
subdomain = ""
}
}