The plan was simple. Build a static portfolio. Drop it in an S3 bucket. Front it with Cloudflare for free SSL and a CDN. Point the domain. Done in twenty minutes.
It was not done in twenty minutes.
Nothing about the stack is wrong. S3 + Cloudflare is genuinely a great pairing for a static site, and what I ended up with is something I'd pick again tomorrow. But the path between "fresh bucket" and "site loading at my domain with HTTPS" has at least five trapdoors that the AWS docs and the Cloudflare docs each individually wave at without ever telling you they're connected.
This is the post I wish I'd read before I started.
The stack
Visitor → DNS (Cloudflare) → Cloudflare proxy → S3 website endpoint
↓
(HTTPS terminated here,
cert auto-issued and
auto-renewed forever)
Three pieces. The Cloudflare DNS that already runs my domain. The Cloudflare proxy in the middle providing SSL, CDN, and DDoS protection. And an S3 bucket configured for static website hosting. No CloudFront, no Amplify, no Lambda@Edge, no Terraform. Pure click-the-buttons-to-deploy.
Why this and not Amplify? Because my DNS is already at Cloudflare, and Amplify's main value-adds are its built-in CDN and SSL - both of which Cloudflare already provides for free. Adding Amplify would mean paying for a second CDN sitting in front of my files and giving up a piece of portability.
OK, the traps.
Trap 1: Block Public Access has TWO layers, not one
When you create a new S3 bucket, AWS asks you to acknowledge that you want public access by unchecking "Block all public access." Sensible default. Easy to handle.
What it doesn't tell you: there's also an account-level Block Public Access setting that overrides every bucket in the account. Even if you unchecked the bucket-level setting during creation, if the account-level setting is still on, your s3:GetObject bucket policy will fail to save with:
Your bucket policy changes can't be saved. You either don't have permissions to edit the bucket policy, or your bucket policy grants a level of public access that conflicts with your Block Public Access settings.
The error is technically accurate but useless - it doesn't tell you which Block Public Access settings to look at. Both have to be off:
- Account-level:
s3.console.aws.amazon.com/s3/settings-> top section, edit, uncheck all four boxes. - Bucket-level: bucket -> Permissions -> Block public access (bucket settings) -> same four boxes, verify all off.
Once both are off, the policy saves.
Trap 2: Drag-and-drop uploads the wrong thing
S3's web console has a drag-and-drop uploader. The drop zone says "Drag files and folders here." If you drag your project folder into it - the most natural Mac muscle memory in the world - you get the whole folder uploaded as a folder, which means your index.html ends up at s3://bucket/project-folder/index.html, not at s3://bucket/index.html.
The website endpoint serves from the root, so you get:
404 Not Found
Code: NoSuchKey
Key: index.html
The fix is to drag the contents of the folder, not the folder itself. Open the folder in Finder, multi-select each of the things inside (index file, asset directories, etc.), drag those.
Obvious in retrospect. Not obvious the first time you do it.
Trap 3: Bucket name has to match the domain
This is the big one. The one that lost me forty minutes.
When Cloudflare proxies a request from chandlerdigital.ca to its S3 origin, it forwards the original Host header - Host: chandlerdigital.ca. The S3 website endpoint uses the Host header to identify which bucket to serve. If your bucket is called website-chandlerdigital and the Host header says chandlerdigital.ca, S3 looks for a bucket called chandlerdigital.ca, can't find one, returns:
404 Not Found
Code: NoSuchBucket
BucketName: chandlerdigital.ca
There are three ways out:
- Name the bucket the same as the domain. Literally call it
chandlerdigital.ca. Most common solution. Works without further config. - Use a Cloudflare Origin Rule to rewrite the Host header to match the actual bucket name. More flexible if you want different bucket names per environment.
- Use CloudFront in front of S3, which handles host routing differently. Adds a layer but solves the problem and gives you HTTPS to origin.
I went with option 1. Created a new bucket called chandlerdigital.ca, redid the static website hosting and bucket policy setup, re-uploaded the files (this time correctly), and pointed Cloudflare at the new bucket's endpoint.
The lesson generalizes: any time you proxy a request to a host-based router (S3 website endpoints, some WAF rules, certain virtual-hosted services), the upstream needs to see a Host header it recognizes.
Trap 4: Cloudflare Full SSL mode breaks on S3 website endpoints
Cloudflare has five SSL modes. The choice between Full and Flexible matters more than most guides admit.
- Off - HTTP visitors get HTTP. No HTTPS at all.
- Flexible - Visitors get HTTPS (real cert, lock icon). Cloudflare talks to origin over HTTP.
- Full - Visitors get HTTPS. Cloudflare talks to origin over HTTPS, but doesn't validate the origin cert.
- Full (Strict) - Same as Full, plus the cert must be valid.
- Strict (SSL-Only Origin Pull) - Newer mode, requires an explicit Cloudflare-issued origin cert.
The intuitive choice for security is Full or Full (Strict). The problem: S3 website endpoints don't speak HTTPS at all. Not "they use a self-signed cert" - they literally don't listen on port 443. Set Cloudflare to Full mode and you get Error 522 and a broken site.
This is an S3 limitation, not a Cloudflare one. AWS doesn't let you put a cert on a website endpoint. If you want end-to-end HTTPS, you need CloudFront in between: CloudFront speaks HTTPS to S3, and Cloudflare can speak Full (Strict) to CloudFront.
For a public portfolio, Flexible mode is fine. The HTTPS leg visitors care about is encrypted with a real, valid Universal SSL certificate. The unencrypted leg is internal traffic between two cloud providers, carrying content that's already public. The browser still sees a lock icon, search engines still see a secure site, and password fields (which I don't have on a portfolio anyway) still get the green-checkmark treatment.
Mixing modes catches a lot of people up. If you started in Full and got 522, switch to Flexible. Reconsider Full only when you have CloudFront in the picture.
Trap 5: Universal SSL certs aren't instant
When you proxy a hostname through Cloudflare for the first time, Cloudflare auto-issues a free SSL certificate. The dashboard eventually says "Active." "Eventually" can mean 30 seconds, or 15 minutes, or several hours.
If your HTTPS site doesn't work immediately after proxying, before you debug anything else, check:
Cloudflare -> SSL/TLS -> Edge Certificates -> Status column.
If it says Pending Validation or Authorizing, you're not broken. You're waiting. The cert will become Active and HTTPS will start working without you doing anything.
There's a sub-trap inside this one: your browser caches failed HTTPS attempts. The site can be fully working at the network level and your local Chrome can still show you the error from twenty minutes ago. Always test in an incognito window until you've confirmed it works.
The actual sequence, with traps removed
If I were doing this clean tomorrow, the order is:
- Create the bucket. Name it exactly the same as your domain.
acme.com, notacme-website. - Turn off account-level Block Public Access first. Settings page, four boxes, save, confirm.
- Turn off bucket-level Block Public Access during creation. Acknowledge the warning.
- Enable static website hosting. Index document and error document both
index.html(soft-404 to home). - Bucket policy:
PublicReadGetObjectfor everyone. ARN should match the bucket name. - Upload the contents of your build folder, not the folder itself.
- Test the bare S3 website endpoint URL before touching DNS. If the site loads there, the bucket is good.
- Cloudflare DNS: CNAME the apex at the S3 endpoint, proxied (orange cloud).
- Cloudflare SSL mode: Flexible.
- Cloudflare: Always Use HTTPS on, Automatic HTTPS Rewrites on, Min TLS 1.2.
- Wait for the Universal SSL cert to provision. Edge Certificates -> Status -> Active.
- Test in an incognito window.
That's it. Twenty minutes, the way it was supposed to be.
What comes next
The deploy workflow today is "drag files into the S3 console." That's fine for the first deploy. For ongoing edits, the next step is a GitHub Action that runs aws s3 sync on every push to main. Configure once, forget about it forever. That's the topic of the next post, and the immediate next thing I'm setting up.
If you've shipped to S3 + Cloudflare and hit traps I didn't list here, I'd love to hear them. The list of five is probably incomplete.