How this site is deployed: S3, CloudFront, OpenTofu and GitHub Actions
The page you are reading is served by the stack it describes. When I built this portfolio I wanted it to be a small proof of how I work: everything as code, a one-push deploy, HTTPS on a custom domain, a working contact form with zero servers, and a bill that rounds to pocket change. This article walks through the whole setup, including the parts that fought back.
The target architecture
git push
โ
GitHub Actions โโ aws s3 sync + cloudfront invalidation
โ
โโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Route 53 (lamine.cloud) โ
โ apex + www โโ A/AAAA alias โโโบ CloudFront โ
โ โ (ACM cert, โ
โ www โบ apex 301 redirect โโโโโโค CF Function) โ
โ โผ โ
โ S3 bucket (private, OAC) โ
โ โ
โ /contact โบ API Gateway โบ Lambda โบ SES (DKIM domain) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Static files in S3, CloudFront in front for TLS and caching, Route 53 for DNS, one serverless path for the contact form. All of it provisioned with OpenTofu and deployed by GitHub Actions. The domain itself came from Namecheap ($4 the first year, $24/year after that), then delegation moved to Route 53.
S3, but private: OAC instead of website hosting
The tutorial-grade way to host a site on S3 is the "static website hosting" toggle with a public bucket. I did not want a public bucket, for two reasons: the website endpoint is plain HTTP only, and a bucket that allows anonymous reads is one policy typo away from allowing more than that. The clean pattern is a fully private bucket that only CloudFront can read, through an Origin Access Control:
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 = "lamine-cloud-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
The bucket policy then allows s3:GetObject only when the request is signed by my CloudFront distribution (AWS:SourceArn condition). Nobody hits the bucket directly, ever.
The ACM gotcha every first-timer hits
CloudFront only accepts certificates from ACM in us-east-1, no matter where the rest of your stack lives (mine is in eu-west-3). In OpenTofu that means a second provider alias just for the certificate:
provider "aws" { region = "eu-west-3" }
provider "aws" { alias = "use1" region = "us-east-1" }
resource "aws_acm_certificate" "site" {
provider = aws.use1
domain_name = "lamine.cloud"
subject_alternative_names = ["www.lamine.cloud"]
validation_method = "DNS"
}
Validation is a CNAME record that OpenTofu writes into the Route 53 zone itself, so the whole chain (zone, record, cert, distribution) converges in one tofu apply. The only manual step in the entire project was pasting the four Route 53 nameservers into Namecheap once.
www to apex, without a server
I wanted www.lamine.cloud to 301 to the apex. The old-school answer is a second S3 bucket in redirect mode, but CloudFront can do it at the edge with a CloudFront Function, a few lines of JavaScript that run on the viewer request:
function handler(event) {
var req = event.request;
if (req.headers.host.value === 'www.lamine.cloud') {
return { statusCode: 301, statusDescription: 'Moved Permanently',
headers: { location: { value: 'https://lamine.cloud' + req.uri } } };
}
return req;
}
CloudFront Functions cost next to nothing and run in under a millisecond. For a redirect there is no reason to reach for Lambda@Edge.
CI/CD: a deploy is a git push
The site is a single repository: site/ for the static files, infra/ for the OpenTofu. GitHub Actions does the delivery:
- name: Sync site to S3
run: |
aws s3 sync site/ "s3://$BUCKET" --delete \
--cache-control "public,max-age=300" --exclude "assets/*"
aws s3 sync site/assets/ "s3://$BUCKET/assets/" \
--cache-control "public,max-age=86400"
- name: Invalidate CloudFront
run: aws cloudfront create-invalidation \
--distribution-id "$CF_DIST" --paths "/*"
Two details worth stealing:
- Split cache policies. HTML gets a 5 minute TTL so content updates propagate fast; heavy assets (PDF CVs, images) get 24 hours. The invalidation makes updates effectively instant anyway, and the TTLs are the safety net.
- A secrets gate. I wanted the workflow to skip cleanly on forks or before secrets exist, and GitHub does not allow the
secretscontext in a job-levelif. The workaround is a tiny first job that exportsready: ${{ secrets.AWS_ACCESS_KEY_ID != '' }}as an output, and a deploy job withneeds: checkplusif: needs.check.outputs.ready == 'true'.
The CI credentials belong to a dedicated IAM user whose policy allows exactly three things: list/read/write on that one bucket, and cloudfront:CreateInvalidation on that one distribution. If those keys ever leak, the blast radius is my own website.
The contact form: serverless, and one 403 war story
The "Work with me" form posts JSON to a small Python Lambda that sends two emails through SES: a notification to me (Reply-To set to the visitor) and a confirmation to the visitor (Reply-To set to me). No database, no server, nothing to patch.
The plan was to expose the Lambda through a Function URL, the simplest possible option. On this fresh AWS account it returned 403 on every request, signed or not, even after recreating the URL, with no error in the logs. Function URLs on brand-new accounts can be restricted in ways the console never explains. After burning an evening on it I swapped in an API Gateway HTTP API with an AWS_PROXY integration: fifteen lines of OpenTofu, worked on the first try. Lesson kept: the boring, older service is often the one with the fewest surprises.
SES had its own homework. Sending from a gmail.com address fails DMARC, so the sender is contact@lamine.cloud backed by a domain identity with DKIM: three CNAME records, plus SPF alignment, all managed in the same OpenTofu module. Bounces and complaints publish to an SNS topic that emails me immediately. Production access (leaving the SES sandbox) is a support request where you explain your volumes and list hygiene; while it is pending, the code path that emails visitors fails soft and the site simply promises a personal reply instead.
What it costs
| Item | Monthly | Note |
|---|---|---|
| Route 53 zone | $0.50 | Flat, per hosted zone |
| S3 | ~$0.01 | A few MB of objects and requests |
| CloudFront | $0.00 | Free tier: 1 TB egress, 10M requests |
| Lambda + API Gateway | $0.00 | Free tier covers form traffic many times over |
| SES | $0.00 | Cents per thousand emails after free tier |
| Domain | ~$2.00 | $4 the first year, then $24/year on Namecheap |
Roughly $2.50 a month, most of it the domain, TLS and global CDN included. A t3.micro "just to host my portfolio" would cost three to four times more and add an OS to patch.
Closing
None of this is exotic, and that is the point. A private bucket behind a CDN, DNS and certificates that converge in one apply, a CI user that can only do its one job, and a serverless path for the single dynamic feature. The same shape scales from a portfolio to a real product frontend, and I have since reused the exact module for a second production site.
Want the same setup, or a review of yours? Work with me.