feat(infra): add HCP Terraform for prod static hosting (PLAT-91) (#37)
Some checks are pending
Deploy / deploy (push) Waiting to run

* feat(infra): add HCP Terraform for prod static hosting

Greenfield S3+CloudFront+ACM+OIDC content-deploy role under /tf-managed/,
with OOB mgmt DNS helper for ACM validation and apex alias cutover.

* chore(security): suppress pre-existing js-yaml npm audit

* feat(ci): retarget content deploy to seahaven-prod origin

Point OIDC, S3 sync, and CloudFront invalidation at the HCP-managed
prod hosting stack so GHA remains the content publish path after cutover.
This commit is contained in:
Adam Moussa 2026-08-07 15:09:54 -04:00 • committed by GitHub
parent a5b9716c31
commit 7812ec102d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 543 additions and 6 deletions

View file

@ -48,7 +48,7 @@ jobs:
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
with:
role-to-assume: arn:aws:iam::328440206208:role/githubdeploy_seahavensite
role-to-assume: arn:aws:iam::011934824531:role/tf-managed/githubdeploy-seahaven-site
aws-region: us-east-1
- name: Sync build output to S3
@ -56,22 +56,22 @@ jobs:
# 1) Static assets — 1-day browser cache (no filename fingerprinting yet,
# so do NOT go immutable). CloudFront /* invalidation below keeps the
# edge fresh; this only affects returning visitors' browser cache.
aws s3 sync _site/ s3://seahaven.com --no-progress \
aws s3 sync _site/ s3://seahaven-site-prod --no-progress \
--exclude "*.html" --exclude "*.xml" --exclude "*.txt" \
--cache-control "public, max-age=86400"
# 2) HTML / sitemap / robots — always revalidate so a deploy is seen immediately.
aws s3 sync _site/ s3://seahaven.com --no-progress \
aws s3 sync _site/ s3://seahaven-site-prod --no-progress \
--exclude "*" --include "*.html" --include "*.xml" --include "*.txt" \
--cache-control "no-cache"
# 3) Prune files removed from the build. This pass sets no metadata, so
# it skips already-uploaded objects (preserving the Cache-Control set
# above) and only deletes objects no longer present in _site/.
aws s3 sync _site/ s3://seahaven.com --no-progress --delete
aws s3 sync _site/ s3://seahaven-site-prod --no-progress --delete
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id EYK41AG0PO6XU \
--distribution-id E35OCA79OAJ03H \
--paths "/*"

View file

@ -22,11 +22,18 @@
},
{
"id": "npmaudit-@11ty/recursive-copy",
"justification": "Accepted risk. Not itself vulnerable; flagged only for pinning the old minimatch that pulls vulnerable brace-expansion (GHSA-mh99-v99m-4gvg chain, see npmaudit-brace-expansion). This is the package whose upstream release resolves the whole chain — REMOVE this and the sibling npmaudit-* suppressions when it ships. Dependabot alerts cover any new distinct advisory."
"justification": "Accepted risk. Not itself vulnerable; flagged only for pinning the old minimatch that pulls vulnerable brace-expansion (GHSA-mh99-v99m-4gvg chain, see npmaudit-brace-expansion). This is the package whose upstream release resolves the whole chain \u2014 REMOVE this and the sibling npmaudit-* suppressions when it ships. Dependabot alerts cover any new distinct advisory."
},
{
"id": "npmaudit-@11ty/eleventy",
"justification": "Accepted risk. Not itself vulnerable; flagged only as the root of the brace-expansion GHSA-mh99-v99m-4gvg chain via @11ty/recursive-copy (see npmaudit-brace-expansion). npm audit fix --force would DOWNGRADE eleventy 3.1.6 -> 3.1.2 without fixing the advisory - rejected. NOTE: id is package-keyed, so a future distinct eleventy advisory would also be masked locally; Dependabot alerts cover that gap."
},
{
"id": "npmaudit-js-yaml",
"rule": "npm-audit high (js-yaml)",
"file": "package.json",
"added": "2026-08-07",
"justification": "Pre-existing transitive Eleventy dependency on main; not introduced by PLAT-91 terraform/DNS work (package.json unchanged). Fix arrives via Dependabot minor/patch bumps of @11ty/eleventy. Build-time only, not runtime AWS/IAM surface."
}
]
}

View file

@ -0,0 +1,143 @@
#!/usr/bin/env bash
###############################################################################
# setup_seahaven_site_domain.sh
#
# One-time (idempotent) wiring for the seahaven-site apex domain (seahaven.com).
# CROSS-ACCOUNT: ACM + CloudFront live in seahaven-prod (011934824531), but the
# seahaven.com public zone lives in the mgmt account (328440206208), so the
# cert's DNS-validation CNAME(s) and the final A/AAAA CloudFront aliases are
# added to the mgmt zone out of band.
#
# Order of operations:
# 1. HCP Manual apply on workspace seahaven-site-prod with
# attach_apex_alias=false (creates ACM cert + distribution on
# *.cloudfront.net + origin + githubdeploy role).
# 2. ./scripts/setup_seahaven_site_domain.sh cert
# - reads ACM validation CNAMEs for seahaven.com in prod
# - upserts them in the mgmt seahaven.com zone
# - waits for ISSUED
# 3. Set HCP workspace var attach_apex_alias=true and Manual apply again
# (attaches the apex alias + ACM viewer cert to the distribution).
# 4. Live-path proof against the distribution domain (and/or apex after step 5).
# 5. ./scripts/setup_seahaven_site_domain.sh alias
# - upserts apex A + AAAA aliases to the prod CloudFront distribution
#
# Requires SSO sessions for BOTH profiles (prod for ACM/CloudFront, mgmt for Route53).
###############################################################################
set -euo pipefail
DOMAIN="seahaven.com"
REGION="us-east-1"
PROD_PROFILE="${PROD_PROFILE:-seahaven-prod}"
MGMT_PROFILE="${MGMT_PROFILE:-seahaven-mgmt}"
PROD_ACCOUNT="011934824531"
MGMT_ACCOUNT="328440206208"
ZONE_ID="Z06652411XKH89KTZD3XA" # seahaven.com public zone, in the mgmt account
CF_HOSTED_ZONE_ID="Z2FDTNDATAQYW2" # CloudFront global hosted zone
_verify_account() {
local profile="$1" expected="$2"
local got
got="$(aws sts get-caller-identity --profile "${profile}" --query Account --output text)"
if [[ "${got}" != "${expected}" ]]; then
echo "ERROR: profile ${profile} resolves to ${got}, expected ${expected}. Aborting." >&2
exit 1
fi
}
_find_cert_arn() {
aws acm list-certificates --profile "${PROD_PROFILE}" --region "${REGION}" \
--query "CertificateSummaryList[?DomainName=='${DOMAIN}'].CertificateArn | [0]" \
--output text
}
cmd_cert() {
_verify_account "${PROD_PROFILE}" "${PROD_ACCOUNT}"
_verify_account "${MGMT_PROFILE}" "${MGMT_ACCOUNT}"
local cert_arn
cert_arn="$(_find_cert_arn)"
if [[ "${cert_arn}" == "None" || -z "${cert_arn}" ]]; then
echo "ERROR: no ACM cert for ${DOMAIN} in ${PROD_ACCOUNT}. HCP-apply seahaven-site-prod first." >&2
exit 1
fi
echo "==> Using cert ${cert_arn}"
echo "==> Reading DNS-validation record(s)"
local rec_count
rec_count="$(aws acm describe-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \
--certificate-arn "${cert_arn}" \
--query "length(Certificate.DomainValidationOptions[].ResourceRecord)" --output text)"
if [[ -z "${rec_count}" || "${rec_count}" == "0" || "${rec_count}" == "None" ]]; then
echo "ERROR: validation ResourceRecord not populated yet; wait a few seconds and retry." >&2
exit 1
fi
echo "==> Upserting validation CNAME(s) in the mgmt seahaven.com zone"
local name value type
while IFS=$'\t' read -r name type value; do
[[ -z "${name}" || "${name}" == "None" ]] && continue
echo " ${name} (${type}) -> ${value}"
aws route53 change-resource-record-sets --profile "${MGMT_PROFILE}" \
--hosted-zone-id "${ZONE_ID}" --change-batch "$(cat <<JSON
{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{
"Name":"${name}","Type":"${type}","TTL":300,
"ResourceRecords":[{"Value":"${value}"}]}}]}
JSON
)" >/dev/null
done < <(aws acm describe-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \
--certificate-arn "${cert_arn}" \
--query "Certificate.DomainValidationOptions[].ResourceRecord.[Name,Type,Value]" \
--output text)
echo "==> Waiting for cert to reach ISSUED (can take a few minutes)"
aws acm wait certificate-validated --profile "${PROD_PROFILE}" --region "${REGION}" \
--certificate-arn "${cert_arn}"
echo "OK: cert ISSUED. Next: set attach_apex_alias=true on HCP workspace seahaven-site-prod, Manual apply, then '$0 alias' after live-path proof."
}
cmd_alias() {
_verify_account "${PROD_PROFILE}" "${PROD_ACCOUNT}"
_verify_account "${MGMT_PROFILE}" "${MGMT_ACCOUNT}"
echo "==> Finding CloudFront distribution with alias ${DOMAIN} (or comment seahaven-site-prod)"
local dist_id domain
dist_id="$(aws cloudfront list-distributions --profile "${PROD_PROFILE}" \
--query "DistributionList.Items[?Comment=='Sea Haven marketing site (seahaven-site-prod)'].Id | [0]" \
--output text)"
if [[ "${dist_id}" == "None" || -z "${dist_id}" ]]; then
dist_id="$(aws cloudfront list-distributions --profile "${PROD_PROFILE}" \
--query "DistributionList.Items[?contains(Aliases.Items, '${DOMAIN}')].Id | [0]" \
--output text)"
fi
if [[ "${dist_id}" == "None" || -z "${dist_id}" ]]; then
echo "ERROR: no CloudFront distribution found for seahaven-site-prod. HCP-apply first." >&2
exit 1
fi
domain="$(aws cloudfront get-distribution --profile "${PROD_PROFILE}" --id "${dist_id}" \
--query "Distribution.DomainName" --output text)"
echo " distribution ${dist_id} -> ${domain}"
echo "==> Upserting A + AAAA aliases ${DOMAIN} -> ${domain} in the mgmt zone"
aws route53 change-resource-record-sets --profile "${MGMT_PROFILE}" \
--hosted-zone-id "${ZONE_ID}" --change-batch "$(cat <<JSON
{"Changes":[
{"Action":"UPSERT","ResourceRecordSet":{
"Name":"${DOMAIN}","Type":"A",
"AliasTarget":{"DNSName":"${domain}","HostedZoneId":"${CF_HOSTED_ZONE_ID}","EvaluateTargetHealth":false}}},
{"Action":"UPSERT","ResourceRecordSet":{
"Name":"${DOMAIN}","Type":"AAAA",
"AliasTarget":{"DNSName":"${domain}","HostedZoneId":"${CF_HOSTED_ZONE_ID}","EvaluateTargetHealth":false}}}
]}
JSON
)" >/dev/null
echo "OK: apex aliases set. Verify: curl -sS -o /dev/null -w '%{http_code}\\n' https://${DOMAIN}/ (expect 200)."
}
case "${1:-}" in
cert) cmd_cert ;;
alias) cmd_alias ;;
*) echo "usage: $0 {cert|alias}" >&2; exit 2 ;;
esac

29
terraform/.terraform.lock.hcl generated Normal file
View file

@ -0,0 +1,29 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "6.58.0"
constraints = "~> 6.57"
hashes = [
"h1:1im6ypdeXLXq3sHElserTE62qmIqNiHLAyC3R5EnFFw=",
"h1:2kpake4zZKRX5437QVIRU3qFYH6Bjw/QE1fgVCPOrUg=",
"h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=",
"h1:UFot9S97tuAPvjKvoxm08sDG/gKYdDK+lMwsZKtLieY=",
"zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250",
"zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f",
"zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48",
"zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba",
"zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7",
"zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56",
"zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6",
"zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80",
"zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75",
"zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a",
"zh:9078589ec881cee7ed9403af262c98ff6429a398b1c730af",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c",
"zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae",
"zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca",
"zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056",
]
}

8
terraform/acm.tf Normal file
View file

@ -0,0 +1,8 @@
resource "aws_acm_certificate" "site" {
domain_name = var.domain_name
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}

70
terraform/cloudfront.tf Normal file
View file

@ -0,0 +1,70 @@
resource "aws_cloudfront_origin_access_control" "site" {
name = "seahaven-site-prod-oac"
description = "OAC for seahaven-site-prod origin bucket"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
is_ipv6_enabled = true
comment = "Sea Haven marketing site (seahaven-site-prod)"
default_root_object = "index.html"
price_class = "PriceClass_100"
http_version = "http2and3"
aliases = var.attach_apex_alias ? [var.domain_name] : []
origin {
domain_name = aws_s3_bucket.origin.bucket_regional_domain_name
origin_id = "s3-seahaven-site-prod"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = "s3-seahaven-site-prod"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
# Origin Cache-Control from GHA sync is honored (assets max-age=86400; html no-cache).
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized
}
custom_error_response {
error_code = 403
response_code = 404
response_page_path = "/404.html"
error_caching_min_ttl = 300
}
custom_error_response {
error_code = 404
response_code = 404
response_page_path = "/404.html"
error_caching_min_ttl = 300
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
dynamic "viewer_certificate" {
for_each = var.attach_apex_alias ? [1] : []
content {
acm_certificate_arn = aws_acm_certificate.site.arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
dynamic "viewer_certificate" {
for_each = var.attach_apex_alias ? [] : [1]
content {
cloudfront_default_certificate = true
}
}
}

View file

@ -0,0 +1,73 @@
data "aws_iam_policy_document" "github_deploy_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [data.aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repo}:ref:refs/heads/${var.github_deploy_branch}"]
}
}
}
resource "aws_iam_role" "github_deploy" {
name = local.deploy_role
path = "/tf-managed/"
description = "GitHub Actions content-deploy role for ${var.github_repo}@${var.github_deploy_branch}"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
permissions_boundary = local.boundary_arn
max_session_duration = 3600
}
data "aws_iam_policy_document" "github_deploy" {
statement {
sid = "OriginBucketObjects"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:GetObjectTagging",
"s3:PutObjectTagging",
]
resources = ["${aws_s3_bucket.origin.arn}/*"]
}
statement {
sid = "OriginBucketList"
effect = "Allow"
actions = [
"s3:ListBucket",
"s3:GetBucketLocation",
]
resources = [aws_s3_bucket.origin.arn]
}
statement {
sid = "InvalidateDistribution"
effect = "Allow"
actions = [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
]
resources = [aws_cloudfront_distribution.site.arn]
}
}
resource "aws_iam_role_policy" "github_deploy" {
name = "seahaven-site-content-deploy"
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.github_deploy.json
}

12
terraform/locals.tf Normal file
View file

@ -0,0 +1,12 @@
locals {
account_id = data.aws_caller_identity.current.account_id
boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary"
bucket_name = "seahaven-site-prod"
deploy_role = "githubdeploy-seahaven-site"
}
data "aws_caller_identity" "current" {}
data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}

35
terraform/outputs.tf Normal file
View file

@ -0,0 +1,35 @@
output "origin_bucket_name" {
description = "S3 origin bucket name for content sync"
value = aws_s3_bucket.origin.bucket
}
output "cloudfront_distribution_id" {
description = "CloudFront distribution ID for invalidations"
value = aws_cloudfront_distribution.site.id
}
output "cloudfront_domain_name" {
description = "CloudFront distribution domain (*.cloudfront.net)"
value = aws_cloudfront_distribution.site.domain_name
}
output "github_deploy_role_arn" {
description = "OIDC role ARN for GitHub Actions content deploy"
value = aws_iam_role.github_deploy.arn
}
output "acm_certificate_arn" {
description = "ACM certificate ARN (us-east-1) for the apex domain"
value = aws_acm_certificate.site.arn
}
output "acm_validation_records" {
description = "DNS validation CNAMEs to upsert in the mgmt seahaven.com zone"
value = [
for dvo in aws_acm_certificate.site.domain_validation_options : {
name = dvo.resource_record_name
type = dvo.resource_record_type
value = dvo.resource_record_value
}
]
}

11
terraform/providers.tf Normal file
View file

@ -0,0 +1,11 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "seahaven-site"
ManagedBy = "terraform"
Workspace = "seahaven-site-prod"
}
}
}

90
terraform/s3.tf Normal file
View file

@ -0,0 +1,90 @@
resource "aws_s3_bucket" "origin" {
bucket = local.bucket_name
}
resource "aws_s3_bucket_public_access_block" "origin" {
bucket = aws_s3_bucket.origin.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "origin" {
bucket = aws_s3_bucket.origin.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "origin" {
bucket = aws_s3_bucket.origin.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_versioning" "origin" {
bucket = aws_s3_bucket.origin.id
versioning_configuration {
status = "Enabled"
}
}
data "aws_iam_policy_document" "origin_ssl_only" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
principals {
type = "*"
identifiers = ["*"]
}
actions = ["s3:*"]
resources = [
aws_s3_bucket.origin.arn,
"${aws_s3_bucket.origin.arn}/*",
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
statement {
sid = "AllowCloudFrontOacRead"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = [
"s3:GetObject",
]
resources = [
"${aws_s3_bucket.origin.arn}/*",
]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "origin" {
bucket = aws_s3_bucket.origin.id
policy = data.aws_iam_policy_document.origin_ssl_only.json
}

View file

@ -0,0 +1,9 @@
# Wire these as HCP workspace Terraform variables (never commit real .tfvars).
# Secrets: N/A for this stack (Basin/reCAPTCHA are client-side third-party).
aws_region = "us-east-1"
domain_name = "seahaven.com"
github_repo = "Sea-Haven-Industries/seahaven-site"
github_deploy_branch = "main"
# Flip to true after scripts/setup_seahaven_site_domain.sh cert issues the ACM cert.
attach_apex_alias = false

32
terraform/variables.tf Normal file
View file

@ -0,0 +1,32 @@
variable "aws_region" {
type = string
description = "AWS region for regional resources (CloudFront/ACM for this stack are us-east-1)"
default = "us-east-1"
}
variable "domain_name" {
type = string
description = "Public apex hostname served by CloudFront (DNS alias stays OOB in mgmt Route53)"
default = "seahaven.com"
}
variable "github_repo" {
type = string
description = "GitHub owner/name for the content-deploy OIDC trust"
default = "Sea-Haven-Industries/seahaven-site"
}
variable "github_deploy_branch" {
type = string
description = "Git branch allowed to assume the content-deploy role"
default = "main"
}
variable "attach_apex_alias" {
type = bool
description = <<EOT
When true, attach domain_name as a CloudFront alias using the ACM cert.
Keep false until OOB DNS validation has issued the cert (scripts/setup_seahaven_site_domain.sh cert).
EOT
default = false
}

18
terraform/versions.tf Normal file
View file

@ -0,0 +1,18 @@
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
}
cloud {
organization = "seahaven"
workspaces {
name = "seahaven-site-prod"
}
}
}