Infrastructure as code (#3)

* Moves website to website/

* Adds terraform gitignores

* Terraform with AWS provider

* Initialises Terraform

* Locals and variables for provider

* Fetches SSL certificate from ACM

* S3 static website bucket

* CloudFront distribution

* Route53 records

* Deployment workflow uses secret S3 bucket suffix

* Adds README

---------

Co-authored-by: Joe Carstairs <65492573+Sycamost@users.noreply.github.com>
This commit is contained in:
Joe Carstairs
2024-05-05 21:00:40 +01:00
committed by GitHub
parent 579e12cfeb
commit 86450b3dd8
40 changed files with 293 additions and 3 deletions

View File

@@ -1,8 +1,6 @@
name: Build and deploy
on:
pull_request:
branches: ["main"]
push:
branches: ["main"]
workflow_dispatch:
@@ -20,6 +18,7 @@ jobs:
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_BUCKET_SUFFIX: ${{ secrets.S3_BUCKET_SUFFIX }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -43,4 +42,4 @@ jobs:
aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
aws configure set region eu-west-2
aws s3 sync ./dist/ s3://joeac-personal-website --delete
aws s3 sync ./dist/ "s3://joeac.net-$S3_BUCKET_SUFFIX" --delete

6
.gitignore vendored
View File

@@ -19,3 +19,9 @@ pnpm-debug.log*
# macOS-specific files
.DS_Store
.terraform/
**/*.tfvars
**/*.tfstate
**/*.tfstate.backup

View File

@@ -2,6 +2,34 @@
Joe Carstairs' personal website
Structure:
├website: My public-facing website
└infrastructure: The infrastructure of my website as code
## Infrastructure
The infrastructure has these components:
- AWS Route53Domains (for domain name registration)
- AWS Route53 (for domain name resolution)
- AWS CloudFront (for path-based routing)
- AWS S3 (for static website hosting)
The CloudFront bit is needed, because S3 static website hosting can only accept
HTTP requests. CloudFront manages receiving HTTPS requests and forwarding them
to HTTP.
The S3 bucket includes a secret string of random characters. This is because
when you set up static website hosting, the S3 API becomes open to the internet,
and there's no way to turn this off. So you are theoretically open to DDoS
attacks, for which you will be charged. Including a random string in the bucket
name makes it less likely that an attacker will find the bucket to send requests
to.
The secret is stored in a GitHub secret called `S3_BUCKET_SUFFIX` so that it can
be accessed by GitHub Actions workflows.
## Invalidating the CloudFront cache
When you update pages, youll need to invalidate the CloudFront cache in order

25
infrastructure/.terraform.lock.hcl generated Normal file
View File

@@ -0,0 +1,25 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "4.67.0"
constraints = "~> 4.16"
hashes = [
"h1:LfOuBkdYCzQhtiRvVIxdP/KGJODa3cRsKjn8xKCTbVY=",
"zh:0843017ecc24385f2b45f2c5fce79dc25b258e50d516877b3affee3bef34f060",
"zh:19876066cfa60de91834ec569a6448dab8c2518b8a71b5ca870b2444febddac6",
"zh:24995686b2ad88c1ffaa242e36eee791fc6070e6144f418048c4ce24d0ba5183",
"zh:4a002990b9f4d6d225d82cb2fb8805789ffef791999ee5d9cb1fef579aeff8f1",
"zh:559a2b5ace06b878c6de3ecf19b94fbae3512562f7a51e930674b16c2f606e29",
"zh:6a07da13b86b9753b95d4d8218f6dae874cf34699bca1470d6effbb4dee7f4b7",
"zh:768b3bfd126c3b77dc975c7c0e5db3207e4f9997cf41aa3385c63206242ba043",
"zh:7be5177e698d4b547083cc738b977742d70ed68487ce6f49ecd0c94dbf9d1362",
"zh:8b562a818915fb0d85959257095251a05c76f3467caa3ba95c583ba5fe043f9b",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9c385d03a958b54e2afd5279cd8c7cbdd2d6ca5c7d6a333e61092331f38af7cf",
"zh:b3ca45f2821a89af417787df8289cb4314b273d29555ad3b2a5ab98bb4816b3b",
"zh:da3c317f1db2469615ab40aa6baba63b5643bae7110ff855277a1fb9d8eb4f2c",
"zh:dc6430622a8dc5cdab359a8704aec81d3825ea1d305bbb3bbd032b1c6adfae0c",
"zh:fac0d2ddeadf9ec53da87922f666e1e73a603a611c57bcbc4b86ac2821619b1d",
]
}

4
infrastructure/acm.tf Normal file
View File

@@ -0,0 +1,4 @@
data "aws_acm_certificate" "joeac_ssl_certificate" {
domain = local.domain
statuses = ["ISSUED"]
}

View File

@@ -0,0 +1,59 @@
resource "aws_cloudfront_distribution" "joeac" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
aliases = ["joeac.net"]
origin {
domain_name = aws_s3_bucket_website_configuration.website.website_endpoint
origin_id = local.website_origin_id
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "http-only"
origin_ssl_protocols = ["TLSv1", "TLSv1.1", "TLSv1.2"]
}
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD", "OPTIONS"]
target_origin_id = local.website_origin_id
cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
viewer_protocol_policy = "redirect-to-https"
}
restrictions {
geo_restriction {
restriction_type = "none"
locations = []
}
}
viewer_certificate {
acm_certificate_arn = data.aws_acm_certificate.joeac_ssl_certificate.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
depends_on = [aws_cloudfront_origin_access_control.website]
}
resource "aws_cloudfront_origin_access_control" "website" {
name = "website"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
data "aws_cloudfront_cache_policy" "caching_optimized" {
name = "Managed-CachingOptimized"
}
locals {
website_origin_id = "website"
}

5
infrastructure/locals.tf Normal file
View File

@@ -0,0 +1,5 @@
locals {
aws_region = "us-east-1"
domain = "joeac.net"
}

11
infrastructure/main.tf Normal file
View File

@@ -0,0 +1,11 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.16"
}
}
required_version = ">= 1.2.0"
}

View File

@@ -0,0 +1,12 @@
provider "aws" {
region = local.aws_region
access_key = var.aws_access_key
secret_key = var.aws_secret_key
default_tags {
tags = {
project = "joeac-website"
owner = "terraform"
}
}
}

35
infrastructure/route53.tf Normal file
View File

@@ -0,0 +1,35 @@
# This hosted zone must have NS records which point to the same nameservers as
# those listed with the domain registrar. Right now, this means manually going
# into Route53Domains, finding the nameservers, and manually copying these into
# the NS records for this hosted zone.
resource "aws_route53_zone" "joeac_zone" {
name = "joeac.net"
}
resource "aws_route53_record" "cloudfront" {
# This is the subdomain for the record. Specify the root domain by leaving it blank
name = ""
zone_id = aws_route53_zone.joeac_zone.id
type = "A"
alias {
name = aws_cloudfront_distribution.joeac.domain_name
zone_id = aws_cloudfront_distribution.joeac.hosted_zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "cloudfront_aaaa" {
# This is the subdomain for the record. Specify the root domain by leaving it blank
name = ""
zone_id = aws_route53_zone.joeac_zone.id
type = "AAAA"
alias {
name = aws_cloudfront_distribution.joeac.domain_name
zone_id = aws_cloudfront_distribution.joeac.hosted_zone_id
evaluate_target_health = false
}
}

78
infrastructure/s3.tf Normal file
View File

@@ -0,0 +1,78 @@
resource "aws_s3_bucket" "website" {
bucket = local.bucket_name
}
locals {
bucket_name = "${local.domain}-${var.secret_s3_bucket_suffix}"
}
resource "aws_s3_bucket_website_configuration" "website" {
bucket = aws_s3_bucket.website.id
index_document {
suffix = "index.html"
}
error_document {
key = "error/index.html"
}
}
resource "aws_s3_bucket_ownership_controls" "website" {
bucket = aws_s3_bucket.website.id
rule {
object_ownership = "BucketOwnerPreferred"
}
depends_on = [aws_s3_bucket_public_access_block.website]
}
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
resource "aws_s3_bucket_acl" "website" {
bucket = aws_s3_bucket.website.id
acl = "public-read"
depends_on = [aws_s3_bucket_ownership_controls.website]
}
resource "aws_s3_bucket_versioning" "website" {
bucket = aws_s3_bucket.website.id
versioning_configuration {
status = "Disabled"
}
}
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
policy = data.aws_iam_policy_document.website.json
}
# TODO: can we restrict access to just from the CloudFront distro?
data "aws_iam_policy_document" "website" {
statement {
sid = "AllowPublicRead"
effect = "Allow"
resources = [
"arn:aws:s3:::${local.bucket_name}",
"arn:aws:s3:::${local.bucket_name}/*",
]
actions = ["S3:GetObject"]
principals {
type = "*"
identifiers = ["*"]
}
}
depends_on = [aws_s3_bucket_public_access_block.website, aws_s3_bucket.website]
}

View File

@@ -0,0 +1,28 @@
variable "aws_access_key" {
type = string
sensitive = true
description = "An AWS access key with permission to provision all relevant resources"
}
variable "aws_secret_key" {
type = string
sensitive = true
description = "The secret corresponding to the provided AWS access key"
}
variable "secret_s3_bucket_suffix" {
type = string
sensitive = true
description = "This string should be a long string of up to 54 random characters. It will be appended to the S3 bucket name to mitigate the risk of DDoS attacks."
nullable = false
validation {
condition = length(var.secret_s3_bucket_suffix) > 12
error_message = "This string should be at least 12 characters"
}
validation {
condition = length(var.secret_s3_bucket_suffix) <= 54
error_message = "This string should be no more than 54 characters long"
}
}

View File

Before

Width:  |  Height:  |  Size: 949 KiB

After

Width:  |  Height:  |  Size: 949 KiB

View File

Before

Width:  |  Height:  |  Size: 949 KiB

After

Width:  |  Height:  |  Size: 949 KiB