用 Terraform 在 AWS 上搭一个静态官网:S3 存储桶完全私有,CloudFront 通过 OAC(Origin Access Control)读取并强制 HTTPS,自定义 404 页,一条脚本上传文件并刷新缓存。
先看清成品
Terraform 1.16.6 + AWS provider 6.69.1 在本机执行了
fmt -check、init、validate,并用terraform test的 mock provider 跑了 2 个测试(私有桶 + HTTPS + 首页;非法桶名被拒绝),全部通过;deploy.sh通过bash -n语法检查。没有用真实 AWS 账号执行 apply 和上传,CloudFront 实际生效时间、访问效果以你的账号为准。
注意:AWS 按用量计费,价格以 AWS 官方价格页和账单为准,本文不列具体金额;做完实验记得删除资源。不要共享 root 账号或访问密钥,不要把密钥提交到仓库。中国大陆的 AWS 中国区(amazonaws.cn)是独立运营的分区,账号不通用,部分服务和 ARN 前缀(arn:aws-cn)不同,本文按全球区域编写。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 私有 S3 存储桶 | 四项公开访问阻止全开 |
| CloudFront 分发 | HTTPS 访问,https://xxxx.cloudfront.net |
| 桶策略 | 只允许这一个分发读取 |
| 部署脚本 | aws s3 sync + 缓存失效 |
| 测试 | 不连 AWS 也能检查关键安全设置 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| AWS 账号 | 用 IAM Identity Center 或 IAM 用户,不用 root | 新账号的免费额度以 AWS Free Tier 页面为准 |
| Terraform | 1.7 以上(mock 测试需要) | 本文用 1.16.6 |
| AWS CLI | v2 | 上传和刷新缓存 |
文件结构
20-aws-A-src/
├── versions.tf
├── variables.tf
├── main.tf
├── outputs.tf
├── deploy.sh
├── site/{index.html,404.html}
└── tests/site.tftest.hcl
第 1 步:provider 和变量
versions.tf:
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
}
variables.tf:
variable "region" {
type = string
default = "ap-northeast-1"
}
variable "bucket_name" {
type = string
description = "全局唯一的存储桶名,例如 binbho-site-20261021"
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
error_message = "存储桶名只能用小写字母、数字、点和连字符,长度 3-63。"
}
}
variable "price_class" {
type = string
default = "PriceClass_100"
}
PriceClass_100 只用北美和欧洲的边缘节点,费用较低;面向亚洲访客可以改成 PriceClass_200 或 PriceClass_All,价格差异看 CloudFront 价格页。
第 2 步:主配置
main.tf:
resource "aws_s3_bucket" "site" {
bucket = var.bucket_name
}
# 存储桶完全私有,只允许 CloudFront 通过 OAC 读取
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_cloudfront_origin_access_control" "site" {
name = "${var.bucket_name}-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# AWS 托管缓存策略 CachingOptimized
data "aws_cloudfront_cache_policy" "optimized" {
name = "Managed-CachingOptimized"
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
default_root_object = "index.html"
price_class = var.price_class
comment = "static site ${var.bucket_name}"
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-site"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = "s3-site"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = data.aws_cloudfront_cache_policy.optimized.id
compress = true
}
custom_error_response {
error_code = 403
response_code = 404
response_page_path = "/404.html"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
data "aws_iam_policy_document" "site" {
statement {
sid = "AllowCloudFrontRead"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.site.json
depends_on = [aws_s3_bucket_public_access_block.site]
}
要点:
- 存储桶不开“静态网站托管”,也不公开;CloudFront 用 OAC 签名请求读取。
- 桶策略的
AWS:SourceArn条件把读取权限限定到这一个分发。 - S3 对不存在的对象在没有 ListBucket 权限时返回 403,所以把 403 映射成 404 页面。
outputs.tf:
output "bucket" {
value = aws_s3_bucket.site.bucket
}
output "distribution_id" {
value = aws_cloudfront_distribution.site.id
}
output "site_url" {
value = "https://${aws_cloudfront_distribution.site.domain_name}"
}
第 3 步:离线测试
tests/site.tftest.hcl:
mock_provider "aws" {
mock_data "aws_cloudfront_cache_policy" {
defaults = { id = "658327ea-f89d-4fab-a63d-7e88639e58f6" }
}
mock_data "aws_iam_policy_document" {
defaults = { json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" }
}
}
variables {
bucket_name = "binbho-site-test"
}
run "private_bucket_and_https" {
command = plan
assert {
condition = aws_s3_bucket_public_access_block.site.block_public_policy && aws_s3_bucket_public_access_block.site.restrict_public_buckets
error_message = "存储桶必须阻止公开访问"
}
assert {
condition = aws_cloudfront_distribution.site.default_cache_behavior[0].viewer_protocol_policy == "redirect-to-https"
error_message = "必须强制 HTTPS"
}
assert {
condition = aws_cloudfront_distribution.site.default_root_object == "index.html"
error_message = "首页应为 index.html"
}
}
run "bad_bucket_name" {
command = plan
variables {
bucket_name = "Bad_Name"
}
expect_failures = [var.bucket_name]
}
terraform init
terraform validate
terraform test
检查结果(本机实际输出):
Success! The configuration is valid.
run "private_bucket_and_https"... pass
run "bad_bucket_name"... pass
Success! 2 passed, 0 failed.
mock provider 不调用 AWS,数据源的返回值是测试里写的假值,只用于检查配置逻辑。
第 4 步:创建资源(本文未执行)
aws sts get-caller-identity # 确认当前账号和身份
terraform plan -var bucket_name=你的桶名
terraform apply -var bucket_name=你的桶名
CloudFront 分发创建后需要一段时间部署完成,状态变为 Deployed 后再访问。
第 5 步:上传网站
deploy.sh:
#!/usr/bin/env bash
# 上传站点并刷新 CloudFront 缓存。需要已配置好 AWS 凭证(aws configure 或 SSO)。
set -euo pipefail
BUCKET=$(terraform output -raw bucket)
DIST=$(terraform output -raw distribution_id)
aws s3 sync site/ "s3://$BUCKET/" --delete
aws cloudfront create-invalidation --distribution-id "$DIST" --paths "/*" --query 'Invalidation.Id' --output text
./deploy.sh
curl -I "$(terraform output -raw site_url)"
curl -I "$(terraform output -raw site_url)/not-exist"
预期结果:首页 HTTP/2 200;不存在的路径返回 404 并显示自定义页面;直接访问 S3 对象地址返回 403。
调试示例
把桶名写成 Bad_Name 时,terraform plan 会在变量校验阶段停止并显示:
存储桶名只能用小写字母、数字、点和连字符,长度 3-63。
测试 bad_bucket_name 用 expect_failures 确认了这条校验确实生效。
出错了怎么办?
| 现象 | 原因 | 解决 |
|---|---|---|
BucketAlreadyExists |
桶名全球唯一,已被占用 | 换一个带日期或前缀的名字 |
| 访问 CloudFront 返回 403 AccessDenied | 桶策略没生效或 SourceArn 不对 | 检查 aws_s3_bucket_policy 是否已创建 |
| 更新了文件但看到旧页面 | CloudFront 缓存 | 运行 deploy.sh 里的失效命令 |
| 所有子页面都 404 | 链接写成了 /about/ 但没有 about/index.html |
OAC 方式下目录不会自动补 index.html,链接写完整文件名 |
terraform test 报 mock 不支持 |
Terraform 低于 1.7 | 升级 |
清理
aws s3 rm "s3://$(terraform output -raw bucket)" --recursive
terraform destroy -var bucket_name=你的桶名
模板规则
- 存储桶永远私有,只通过 CloudFront 访问。
- 改配置先
terraform test,再 plan、apply。 - 实验完成当天 destroy。
项目资源
| 用途 | 地址 |
|---|---|
| CloudFront 限制访问 S3(OAC) | https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html |
| Terraform AWS provider | https://registry.terraform.io/providers/hashicorp/aws/latest/docs |
| Terraform 测试中的 mock | https://developer.hashicorp.com/terraform/language/tests/mocking |
| AWS 价格 | https://aws.amazon.com/pricing/ |
| AWS 免费套餐 | https://aws.amazon.com/free/ |
| AWS 中国区 | https://www.amazonaws.cn/ |
静态网站在 AWS 上最常见的问题不是搭不起来,而是存储桶被公开。这套配置把“私有桶 + OAC + 限定分发”写进代码并用测试锁住,以后谁改了都能马上发现。