├── .gitignore ├── README.md └── src ├── .terraform.lock.hcl ├── acm.tf ├── cloudfront.tf ├── providers.tf ├── route53.tf ├── s3.tf ├── terraform.tfvars └── variables.tf /.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/* 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | 8 | # Crash log files 9 | crash.log 10 | 11 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 12 | # .tfvars files are managed as part of configuration and so should be included in 13 | # version control. 14 | # 15 | # example.tfvars 16 | 17 | # Ignore override files as they are usually used to override resources locally and so 18 | # are not checked in 19 | override.tf 20 | override.tf.json 21 | *_override.tf 22 | *_override.tf.json 23 | 24 | # Include override files you do wish to add to version control using negated pattern 25 | # 26 | # !example_override.tf 27 | 28 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 29 | # example: *tfplan* 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # terraform-s3-static-website 2 | 3 | Terraform for hosting a secure static website hosted on AWS using S3. 4 | 5 | There is a [step by step guide](https://www.alexhyett.com/terraform-s3-static-website-hosting) for this code on my [blog](https://www.alexhyett.com/terraform-s3-static-website-hosting). 6 | 7 | The terraform will set up the following components: 8 | 9 | - S3 bucket for www.yourdomain.com which will host your website files. 10 | - S3 bucket for yourdomain.com which redirects to www.yourdomain.com. 11 | - SSL certificate for yourdomain.com. 12 | - Cloudfront distribution for www S3 bucket. 13 | - Cloudfront distribution for S3 bucket redirect. 14 | - Route 53 records for: 15 | - root 16 | - www 17 | 18 | ## Using this repository 19 | 20 | ### Create your S3 bucket to store your state files. 21 | 22 | Create a new S3 bucket manually to store your state files. This should have the following policy set: 23 | 24 | ``` 25 | { 26 | "Version": "2012-10-17", 27 | "Statement": [ 28 | { 29 | "Effect": "Allow", 30 | "Principal": { 31 | "AWS": "arn:aws:iam::1234567890:root" 32 | }, 33 | "Action": "s3:ListBucket", 34 | "Resource": "arn:aws:s3:::yourdomain-terraform" 35 | }, 36 | { 37 | "Effect": "Allow", 38 | "Principal": { 39 | "AWS": "arn:aws:iam::1234567890:root" 40 | }, 41 | "Action": [ 42 | "s3:GetObject", 43 | "s3:PutObject" 44 | ], 45 | "Resource": "arn:aws:s3:::yourdomain-terraform/*" 46 | } 47 | ] 48 | } 49 | ``` 50 | 51 | Where `1234567890` is replaced with your AWS account ID and `yourdomain-terraform` is replaced with the name of your S3 bucket. 52 | 53 | ### Update your provider settings 54 | 55 | In `providers.tf` update the bucket (`yourdomain-terraform`) and region (`eu-west-1`) parameters to match your setup. Note, the `acm_provider` needs to point to `us-east-1` for Cloudfront to be able to use it. 56 | 57 | ### Update the variables 58 | 59 | In `terraform.tfvars` update the variables with the name of your domain and S3 bucket. You can just replace `yourdomain` with your actual domain. 60 | 61 | Generally you will want to call you S3 bucket the name of your domain but if that is not possible (due to a conflict) then you can call it something else. 62 | 63 | ## Commands to run 64 | 65 | - `terraform init` - To initialise the project and download any required packages. 66 | - `terraform plan` - To see what has changed compared to your current configuration. 67 | - `terrform apply` - To apply your changes. 68 | 69 | The terraform scripts are set up to validate your SSL certificate via email. So you should receive an email from AWS to `webmaster@yourdomain.com` when running apply which you need to approve in order for the SSL certificate to be validated. 70 | 71 | Alternatively, I have left the code for DNS validation in which can be uncommented if you don't have email set up (`acm.tf` and `route53.tf`). 72 | 73 | Note however, that DNS validation requires the domain nameservers to already be pointing to AWS. You won't actually know the nameservers until after the NS route 53 record has been created. Alternatively, you can follow the validation instructions from the ACM page for your domain and apply to where your nameservers are currently hosted. DNS validation can take 30 minutes or more during which the terraform script will still be running. 74 | 75 | ## Post Changes 76 | 77 | Once the terraform scripts have been run successfully you will need to upload your scripts to your `www.yourdomain.com` S3 bucket. 78 | 79 | At the very least you need an `index.html` and a `404.html` file. 80 | 81 | You can upload the contents of a directory to your S3 bucket by using the command: 82 | 83 | ``` 84 | aws s3 sync . s3://www.yourdomain.com 85 | ``` 86 | 87 | Whenever you make changes to the files in your S3 bucket you need to invalidate the Cloudfront cache. 88 | 89 | ``` 90 | aws cloudfront create-invalidation --distribution-id E3EDVELPIKTLHJ --paths "/*"; 91 | ``` 92 | 93 | Where `E3EDVELPIKTLHJ` is the Cloudfront ID associated with your www S3 bucket. 94 | -------------------------------------------------------------------------------- /src/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "3.23.0" 6 | constraints = "~> 3.0" 7 | hashes = [ 8 | "h1:tSznQxPJvolDnmqqaTK9SsJ0bluTws7OAWcnc1t0ABs=", 9 | "zh:30b0733027c00472618da998bc77967c692e238ae117c07e046fdd7336b83fa3", 10 | "zh:3677550a8bef8e01c67cb615407dc8a69d32f4e36017033cd6f71a831c99d5de", 11 | "zh:3c2fb4c14bfd43cf20ee25d0068ce09f1d48758408b8f1c88a096cea243612b3", 12 | "zh:5577543322003693c4fe24a69ed0d47e58f867426fd704fac94cf5c16d3d6153", 13 | "zh:6771f09d76ad01ffc04baa3bce7a3eed09f6a8a949274ffbd9d11756a58a4329", 14 | "zh:7a57b79d304d17cf52ee3ddce91679f6b4289c5bdda2e31b763bf7d512e542d9", 15 | "zh:815fb027e17bfe754b05367d20bd0694726a95a99b81e8d939ddd44e2b1f05a9", 16 | "zh:a3d67db5ec0f4e9750eb19676a9a1aff36b0721e276a4ba789f42b991bf5951c", 17 | "zh:cd67ff33860ad578172c19412ce608ba818e7590083197df2b793f870d6f50a3", 18 | "zh:fbe0835055d1260fb77ad19a32a8726248ba7ac187f6c463ded90737b4cea8e6", 19 | ] 20 | } 21 | 22 | provider "registry.terraform.io/hashicorp/template" { 23 | version = "2.2.0" 24 | hashes = [ 25 | "h1:0wlehNaxBX7GJQnPfQwTNvvAf38Jm0Nv7ssKGMaG6Og=", 26 | "zh:01702196f0a0492ec07917db7aaa595843d8f171dc195f4c988d2ffca2a06386", 27 | "zh:09aae3da826ba3d7df69efeb25d146a1de0d03e951d35019a0f80e4f58c89b53", 28 | "zh:09ba83c0625b6fe0a954da6fbd0c355ac0b7f07f86c91a2a97849140fea49603", 29 | "zh:0e3a6c8e16f17f19010accd0844187d524580d9fdb0731f675ffcf4afba03d16", 30 | "zh:45f2c594b6f2f34ea663704cc72048b212fe7d16fb4cfd959365fa997228a776", 31 | "zh:77ea3e5a0446784d77114b5e851c970a3dde1e08fa6de38210b8385d7605d451", 32 | "zh:8a154388f3708e3df5a69122a23bdfaf760a523788a5081976b3d5616f7d30ae", 33 | "zh:992843002f2db5a11e626b3fc23dc0c87ad3729b3b3cff08e32ffb3df97edbde", 34 | "zh:ad906f4cebd3ec5e43d5cd6dc8f4c5c9cc3b33d2243c89c5fc18f97f7277b51d", 35 | "zh:c979425ddb256511137ecd093e23283234da0154b7fa8b21c2687182d9aea8b2", 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/acm.tf: -------------------------------------------------------------------------------- 1 | # SSL Certificate 2 | resource "aws_acm_certificate" "ssl_certificate" { 3 | provider = aws.acm_provider 4 | domain_name = var.domain_name 5 | subject_alternative_names = ["*.${var.domain_name}"] 6 | validation_method = "EMAIL" 7 | #validation_method = "DNS" 8 | 9 | tags = var.common_tags 10 | 11 | lifecycle { 12 | create_before_destroy = true 13 | } 14 | } 15 | 16 | # Uncomment the validation_record_fqdns line if you do DNS validation instead of Email. 17 | resource "aws_acm_certificate_validation" "cert_validation" { 18 | provider = aws.acm_provider 19 | certificate_arn = aws_acm_certificate.ssl_certificate.arn 20 | #validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn] 21 | } 22 | -------------------------------------------------------------------------------- /src/cloudfront.tf: -------------------------------------------------------------------------------- 1 | # Cloudfront distribution for main s3 site. 2 | resource "aws_cloudfront_distribution" "www_s3_distribution" { 3 | origin { 4 | domain_name = aws_s3_bucket.www_bucket.website_endpoint 5 | origin_id = "S3-www.${var.bucket_name}" 6 | 7 | custom_origin_config { 8 | http_port = 80 9 | https_port = 443 10 | origin_protocol_policy = "http-only" 11 | origin_ssl_protocols = ["TLSv1", "TLSv1.1", "TLSv1.2"] 12 | } 13 | } 14 | 15 | enabled = true 16 | is_ipv6_enabled = true 17 | default_root_object = "index.html" 18 | 19 | aliases = ["www.${var.domain_name}"] 20 | 21 | custom_error_response { 22 | error_caching_min_ttl = 0 23 | error_code = 404 24 | response_code = 200 25 | response_page_path = "/404.html" 26 | } 27 | 28 | default_cache_behavior { 29 | allowed_methods = ["GET", "HEAD"] 30 | cached_methods = ["GET", "HEAD"] 31 | target_origin_id = "S3-www.${var.bucket_name}" 32 | 33 | forwarded_values { 34 | query_string = false 35 | 36 | cookies { 37 | forward = "none" 38 | } 39 | } 40 | 41 | viewer_protocol_policy = "redirect-to-https" 42 | min_ttl = 31536000 43 | default_ttl = 31536000 44 | max_ttl = 31536000 45 | compress = true 46 | } 47 | 48 | restrictions { 49 | geo_restriction { 50 | restriction_type = "none" 51 | } 52 | } 53 | 54 | viewer_certificate { 55 | acm_certificate_arn = aws_acm_certificate_validation.cert_validation.certificate_arn 56 | ssl_support_method = "sni-only" 57 | minimum_protocol_version = "TLSv1.1_2016" 58 | } 59 | 60 | tags = var.common_tags 61 | } 62 | 63 | # Cloudfront S3 for redirect to www. 64 | resource "aws_cloudfront_distribution" "root_s3_distribution" { 65 | origin { 66 | domain_name = aws_s3_bucket.root_bucket.website_endpoint 67 | origin_id = "S3-.${var.bucket_name}" 68 | custom_origin_config { 69 | http_port = 80 70 | https_port = 443 71 | origin_protocol_policy = "http-only" 72 | origin_ssl_protocols = ["TLSv1", "TLSv1.1", "TLSv1.2"] 73 | } 74 | } 75 | 76 | enabled = true 77 | is_ipv6_enabled = true 78 | 79 | aliases = [var.domain_name] 80 | 81 | default_cache_behavior { 82 | allowed_methods = ["GET", "HEAD"] 83 | cached_methods = ["GET", "HEAD"] 84 | target_origin_id = "S3-.${var.bucket_name}" 85 | 86 | forwarded_values { 87 | query_string = true 88 | 89 | cookies { 90 | forward = "none" 91 | } 92 | 93 | headers = ["Origin"] 94 | } 95 | 96 | viewer_protocol_policy = "allow-all" 97 | min_ttl = 0 98 | default_ttl = 86400 99 | max_ttl = 31536000 100 | } 101 | 102 | restrictions { 103 | geo_restriction { 104 | restriction_type = "none" 105 | } 106 | } 107 | 108 | viewer_certificate { 109 | acm_certificate_arn = aws_acm_certificate_validation.cert_validation.certificate_arn 110 | ssl_support_method = "sni-only" 111 | minimum_protocol_version = "TLSv1.1_2016" 112 | } 113 | 114 | tags = var.common_tags 115 | } 116 | -------------------------------------------------------------------------------- /src/providers.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 0.14" 3 | 4 | required_providers { 5 | aws = { 6 | source = "hashicorp/aws" 7 | version = "~> 3.0" 8 | } 9 | } 10 | 11 | backend "s3" { 12 | bucket = "yourdomain-terraform" 13 | key = "prod/terraform.tfstate" 14 | region = "eu-west-1" 15 | } 16 | } 17 | 18 | provider "aws" { 19 | region = "eu-west-1" 20 | } 21 | 22 | provider "aws" { 23 | alias = "acm_provider" 24 | region = "us-east-1" 25 | } 26 | -------------------------------------------------------------------------------- /src/route53.tf: -------------------------------------------------------------------------------- 1 | # Route 53 for domain 2 | resource "aws_route53_zone" "main" { 3 | name = var.domain_name 4 | tags = var.common_tags 5 | } 6 | 7 | resource "aws_route53_record" "root-a" { 8 | zone_id = aws_route53_zone.main.zone_id 9 | name = var.domain_name 10 | type = "A" 11 | 12 | alias { 13 | name = aws_cloudfront_distribution.root_s3_distribution.domain_name 14 | zone_id = aws_cloudfront_distribution.root_s3_distribution.hosted_zone_id 15 | evaluate_target_health = false 16 | } 17 | } 18 | 19 | resource "aws_route53_record" "www-a" { 20 | zone_id = aws_route53_zone.main.zone_id 21 | name = "www.${var.domain_name}" 22 | type = "A" 23 | 24 | alias { 25 | name = aws_cloudfront_distribution.www_s3_distribution.domain_name 26 | zone_id = aws_cloudfront_distribution.www_s3_distribution.hosted_zone_id 27 | evaluate_target_health = false 28 | } 29 | } 30 | 31 | # Uncomment the below block if you are doing certificate validation using DNS instead of Email. 32 | #resource "aws_route53_record" "cert_validation" { 33 | # for_each = { 34 | # for dvo in aws_acm_certificate.ssl_certificate.domain_validation_options : dvo.domain_name => { 35 | # name = dvo.resource_record_name 36 | # record = dvo.resource_record_value 37 | # type = dvo.resource_record_type 38 | # zone_id = aws_route53_zone.main.zone_id 39 | # } 40 | # } 41 | # 42 | # allow_overwrite = true 43 | # name = each.value.name 44 | # records = [each.value.record] 45 | # ttl = 60 46 | # type = each.value.type 47 | # zone_id = each.value.zone_id 48 | #} 49 | -------------------------------------------------------------------------------- /src/s3.tf: -------------------------------------------------------------------------------- 1 | # S3 bucket for website. 2 | resource "aws_s3_bucket" "www_bucket" { 3 | bucket = "www.${var.bucket_name}" 4 | acl = "public-read" 5 | policy = data.aws_iam_policy_document.allow_public_s3_read.json 6 | 7 | cors_rule { 8 | allowed_headers = ["Authorization", "Content-Length"] 9 | allowed_methods = ["GET", "POST"] 10 | allowed_origins = ["https://www.${var.domain_name}"] 11 | max_age_seconds = 3000 12 | } 13 | 14 | website { 15 | index_document = "index.html" 16 | error_document = "404.html" 17 | } 18 | 19 | tags = var.common_tags 20 | } 21 | 22 | # S3 bucket for redirecting non-www to www. 23 | resource "aws_s3_bucket" "root_bucket" { 24 | bucket = var.bucket_name 25 | acl = "public-read" 26 | policy = data.aws_iam_policy_document.allow_public_s3_read.json 27 | 28 | website { 29 | redirect_all_requests_to = "https://www.${var.domain_name}" 30 | } 31 | 32 | tags = var.common_tags 33 | } 34 | 35 | # S3 Allow Public read access as data object 36 | data "aws_iam_policy_document" "allow_public_s3_read" { 37 | statement { 38 | sid = "PublicReadGetObject" 39 | effect = "Allow" 40 | 41 | actions = [ 42 | "s3:GetObject", 43 | ] 44 | 45 | principals { 46 | type = "AWS" 47 | identifiers = "*" 48 | } 49 | 50 | resources = [ 51 | "arn:aws:s3:::${var.bucket_name}/*" 52 | "arn:aws:s3:::www-${var.bucket_name}/*" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/terraform.tfvars: -------------------------------------------------------------------------------- 1 | domain_name = "yourdomain.com" 2 | bucket_name = "yourdomain.com" 3 | 4 | common_tags = { 5 | Project = "yourdomain" 6 | } -------------------------------------------------------------------------------- /src/variables.tf: -------------------------------------------------------------------------------- 1 | variable "domain_name" { 2 | type = string 3 | description = "The domain name for the website." 4 | } 5 | 6 | variable "bucket_name" { 7 | type = string 8 | description = "The name of the bucket without the www. prefix. Normally domain_name." 9 | } 10 | 11 | variable "common_tags" { 12 | description = "Common tags you want applied to all components." 13 | } 14 | --------------------------------------------------------------------------------