00
Article / Infra

How this site is built

Infrastructure and deployment design for hosoitoru.jp — by Toru Hosoi

01

Overview.

A simple setup: static HTML served from AWS. S3 + CloudFront (OAC) for hosting, GitHub Actions (OIDC) for auto-deploy on every push to main, and Terraform for all of the infrastructure.

GitHub → Actions → S3 → CloudFront → hosoitoru.jp ↑ OIDC (temporary IAM Role assume)
02

S3 + CloudFront (OAC).

The S3 bucket has public access fully blocked. Any direct URL hits return 403. The only way in is through CloudFront.

Why OAC

I use OAC (Origin Access Control) for CloudFront-to-S3 access, not the older OAI (Origin Access Identity). AWS recommends OAC for new deployments. It verifies requests via SigV4, which makes the access control stronger.

The bucket policy pins AWS:SourceArn to the specific CloudFront distribution ARN, so even another distribution in the same account can't read from this bucket. This follows the principle of least privilege.

HTTPS and certificates

I issue a wildcard certificate (hosoitoru.jp + *.hosoitoru.jp) in ACM so future subdomains don't need a re-issuance.

ACM certificates for CloudFront must be in us-east-1. I handle this by defining a second AWS provider alias in Terraform: provider "aws" { region = "us-east-1" }.

Handling 404 / 403

S3 returns 403 for non-existent paths (because the bucket is private). CloudFront rewrites those to 404 and serves a custom 404 page.

03

GitHub Actions OIDC.

There is no IAM User for deploys. I didn't want long-lived access keys sitting in GitHub Secrets — if one leaks, the attacker has persistent access.

How OIDC works

GitHub Actions issues an OIDC token (JWT) at runtime. The workflow uses it to call sts:AssumeRoleWithWebIdentity and temporarily assume an IAM Role.

  • Credentials are short-lived — even if leaked, they expire quickly
  • No long-lived secrets in GitHub
  • The IAM Role's trust policy scopes the subject to a specific repository, so no other repo can assume it

IAM Role permissions

Scoped to the bare minimum:

  • S3: PutObject / GetObject / DeleteObject / ListBucket on the deploy bucket only
  • CloudFront: CreateInvalidation / GetInvalidation on the target distribution only
04

Terraform.

S3, CloudFront, Route 53, ACM, and IAM (OIDC) — all managed in Terraform.

State management

S3 backend with use_lockfile (native S3 locking, Terraform 1.10+).

backend "s3" {
  bucket       = "hosoitoru-jp-tfstate"
  key          = "infra/terraform.tfstate"
  region       = "ap-northeast-1"
  use_lockfile = true
}

Previously ran DynamoDB-backed locking; moved to the native S3 lock to drop the extra table and its IAM permissions. Simpler config.

A us-east-1 provider just for the CloudFront ACM

The ACM certificate attached to CloudFront has to be issued in us-east-1 — a quirky CloudFront constraint. Everything else lives in ap-northeast-1 (Tokyo), so I define a second provider "aws" with a us-east-1 alias and attach it only to the ACM resources.

Dedup ACM validation records

Validation CNAMEs for the root domain and wildcard sometimes coincide. I run them through for_each with ... (spread) to dedupe before registering with Route 53 — otherwise Terraform errors on duplicate records.

Optional Route 53 zone creation

If route53_zone_id is empty, I create a new zone with count = 1. If an existing zone ID is passed in, it's reused. The same module drops into other projects without changes.

05

Deploy flow.

A push to main is the whole deploy. No manual steps.

  1. Edit pages, push to main
  2. GitHub Actions starts → assumes the IAM Role via OIDC
  3. aws s3 sync uploads the diff to S3
  4. CloudFront invalidates /*
  5. Propagated in about a minute