Introduction
This is the 12th installment of "AWS CDK 100 Drill Exercises." See here for an overview of the series.
This time we build the simplest way to publish a static site on AWS — S3 static website hosting — with no CDN, no WAF, and no custom domain. It looks like a plain, unremarkable pattern, but implementing it surfaces one genuinely interesting question.
"A bucket policy that allows
s3:GetObjecttoPrincipal: *should count as 'public' — so why does deploying it against a bucket withBlockPublicAccess.BLOCK_ALLenabled succeed?"
The short answer: S3's Block Public Access doesn't decide public vs. private purely based on whether Principal is *. This time we verify that behavior with actual CDK code.
What you'll learn in this article
- The basics of S3 static website hosting (
websiteIndexDocument/websiteErrorDocument) - How the
aws:SourceIpcondition exempts a "public" policy from S3's Block Public Access evaluation - Why
enforceSSLcan't be enabled, because the S3 website endpoint doesn't support HTTPS - How to auto-detect the deploy operator's own global IP so the site is viewable immediately after deployment
- The limits of this pattern — what you can and can't do without CloudFront
📁 Code repository: GitHub
Architecture Overview
| Trait | Benefit |
|---|---|
| No CloudFront, no WAF, no ACM certificate | The fastest, cheapest way to get a static site online. A solid baseline before moving on to cloudfront-s3-static-website, covered next time |
| An IP allow list that coexists with Block Public Access | Lets you actually verify that a Principal: * policy scoped with an aws:SourceIp condition is exempted from S3's "public" determination |
| Automatic detection of the operator's IP | Detects the deploying machine's own IP via curl, so the site is viewable right after deployment with no manual configuration |
Data Flow
Browser
│ HTTP (the S3 website endpoint doesn't support HTTPS)
▼
S3 bucket website endpoint (<bucket>.s3-website-<region>.amazonaws.com)
│ Evaluated by bucket policy: source IP must match allowedIps/allowedIpv6s
▼
index.html / error.html
Every request is anonymous. The website endpoint doesn't support SigV4 authentication, so the only access control available is the bucket policy's aws:SourceIp condition.
Implementation Highlights
1. websiteIndexDocument alone doesn't finish the job — the tug-of-war with Block Public Access
Just enabling S3 static website hosting is only a few lines of CDK code.
new s3.Bucket(this, 'WebsiteBucket', {
websiteIndexDocument: 'index.html',
websiteErrorDocument: 'error.html',
});
But that alone leaves nobody able to view the site. New S3 buckets have public access blocked by default (at the account level), and this reference implementation also sets blockPublicAccess: BLOCK_ALL on the bucket explicitly (via the repo's shared bucket helper) — so every anonymous s3:GetObject gets denied. Simply turning Block Public Access off, though, risks unintentionally opening the bucket to the entire world.
Instead, the stack keeps blockPublicAccess: BLOCK_ALL in place and adds only a bucket policy scoped down by aws:SourceIp.
websiteBucket.addToResourcePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
principals: [new iam.AnyPrincipal()],
actions: ['s3:GetObject'],
resources: [websiteBucket.arnForObjects('*')],
conditions: {
IpAddress: {
'aws:SourceIp': allowedIps.map(ip => `${ip}/32`),
},
},
}));
At first glance, this policy — which includes Principal: * — looks like it should get the PutBucketPolicy call itself rejected by Block Public Access's BlockPublicPolicy: true. In practice, though, the deployment succeeds.
Why: when S3's Block Public Access decides "is this policy public?", it looks at more than just whether Principal is * — it also inspects the Condition clause. To be judged non-public, a statement has to be scoped to a fixed value of one of a specific set of keys: aws:SourceIp, aws:SourceArn, aws:SourceVpc, aws:SourceVpce, aws:SourceOwner, aws:SourceAccount, aws:PrincipalOrgID, s3:DataAccessPointArn, s3:DataAccessPointAccount, or aws:userid. A Principal: * policy whose reachable callers are pinned down by an aws:SourceIp CIDR condition therefore isn't treated as "public." (One caveat: an aws:SourceIp range broader than /8 for IPv4 — e.g. 0.0.0.0/1 — is still considered public. The /32 host addresses this stack uses are well within bounds.)
This behavior also comes up frequently in the context of SCPs and IAM Access Analyzer, but actually deploying an IP-restricted bucket policy against a Block-Public-Access-enabled bucket in CDK and confirming it firsthand deepens the understanding considerably.
⚠️ Note: if you omit both
allowedIpsandallowedIpv6s, no bucket policy is added at all.blockPublicAccess: BLOCK_ALLstill applies, so the result is a site that's "unreachable by anyone," not "publicly open." This stack has no "allow everyone if unspecified" fallback.
2. enforceSSL: false — a setting that looks wrong at first glance
Every other bucket in this repository follows a baseline policy of enforceSSL: true, rejecting HTTP connections. WebsiteBucket is the one exception, set to enforceSSL: false.
const bucket = new s3.Bucket(scope, id, {
enforceSSL: false, // the website endpoint only supports HTTP
websiteIndexDocument: 'index.html',
websiteErrorDocument: 'error.html',
// ...
});
The S3 static website hosting endpoint (*.s3-website-<region>.amazonaws.com) doesn't support HTTPS at all, to begin with. Set enforceSSL: true here, and the bucket policy would reject the very (plaintext HTTP) requests the website endpoint actually receives, breaking the site entirely.
In other words, "I want this over HTTPS" isn't something this bucket can solve on its own — it's solved by putting CloudFront in front of it. That's exactly the bridge to cloudfront-s3-static-website, covered in the next article.
3. Auto-detecting the deploy operator's IP so the site is instantly viewable
bin/s3-static-web-site.ts fetches the deploying machine's own global IP via curl and feeds it straight into the bucket policy's allow list.
const allowedIps = parseIpListEnv(process.env.ALLOWED_IPS) ?? [getMyGlobalIp()];
const allowedIpv6s = parseIpListEnv(process.env.ALLOWED_IPV6S) ?? (() => {
const myIpv6 = getMyGlobalIpv6();
return myIpv6 ? [myIpv6] : undefined;
})();
IPv6 detection (getMyGlobalIpv6()) returns undefined quietly when it can't be determined (e.g. in a devcontainer/CI environment with no IPv6 connectivity), while IPv4 detection (getMyGlobalIp()) throws an exception on failure. That means deploying from a fully offline machine fails unless you explicitly set the ALLOWED_IPS environment variable. The same override applies when the machine you're deploying from differs from the machine whose browser will view the site (e.g. deploying from a devcontainer but browsing from the host PC) — use ALLOWED_IPS/ALLOWED_IPV6S explicitly in that case too.
ALLOWED_IPS=203.0.113.10,203.0.113.20 \
ALLOWED_IPV6S=2001:db8::1 \
npm run stage:deploy:all
Deploy & Verify
export PROJECT=your-project
export ENV=dev
npm run bootstrap # first time only
npm run stage:deploy:all
curl http://<output WebsiteBucketUrl>/
Access from an IP not on the allow list, and the S3 website endpoint returns an HTTP 403 (Access Denied) page.
Cost Estimate
💰 Rough monthly estimate (Tokyo region, low traffic)
| Service | Usage | Rough monthly cost |
|---|---|---|
| S3 storage | A few MB of static content | Under $0.01 |
| S3 requests | Low-volume GET | Under $0.01 |
| S3 server access logs | Small volume | Under $0.01 |
Total: under $0.05/month
With no CloudFront, no WAF, and no compute in the picture at all, the cost is essentially just S3 storage and request charges.
Summary
What we learned from this pattern:
-
S3's Block Public Access evaluation doesn't decide public/private purely by whether
Principalis*. Statements scoped by a specific set of condition keys that includesaws:SourceIpare exempted from that "public" determination -
The S3 static website hosting endpoint is HTTP-only. Enabling
enforceSSLbreaks the site entirely — going HTTPS requires an additional layer such as CloudFront -
The "fallback when unspecified" behavior isn't consistent across helpers. IPv6 detection quietly returns
undefinedon failure, while IPv4 detection throws. When using helper functions, design your error handling around this asymmetry rather than assuming both behave the same -
An IP allow list is not a substitute for authentication.
aws:SourceIpis only evaluated per request, so while it's useful as a restriction for demos or personal use, it's insufficient as access control tied to a specific individual
Next time, we'll evolve this same workspace into CloudFront + WAF + OAC to add HTTPS and a private origin, in cloudfront-s3-static-website.
References
- Hosting a static website using Amazon S3
- Blocking public access to your Amazon S3 storage
- Bucket policy examples — IP address condition
- aws-s3-deployment module
Let's keep learning practical AWS CDK patterns through the 100 drill exercises!
If you found this helpful, please ⭐ the repository!
📌 You can see the entire code in my GitHub repository.

Top comments (0)