July 9, 2026
Learning AWS Day 3: S3 Static Hosting
Day 2 left me with a server running around the clock, waiting for requests that might come at any hour, and I was the one responsible for keeping it alive and secure. Day 3 flips that entirely. What if there’s no server at all? S3 can host a real website, static HTML, CSS, JS, with no EC2 instance, no Uvicorn process, nothing that needs to stay running. For someone coming from a frontend background this should feel oddly familiar, because it’s exactly what Cloudflare Pages and Vercel do under the hood. The difference is that S3 doesn’t hide the mechanism from you the way those platforms do. You get to see every wire.
What S3 actually is
S3 stands for Simple Storage Service, and the important word there is storage, not filesystem. You don’t mount an S3 bucket or SSH into it the way I SSHed into the EC2 instance on Day 2. Everything happens through API calls, PUT to store an object, GET to retrieve one, DELETE to remove one, whether you’re issuing those calls from the console or the CLI.
One detail that surprised me: bucket names have to be globally unique, not just unique within my own account, but unique across every AWS account on the planet. The reason makes sense once you think about it. S3 URLs embed the bucket name directly, so AWS needs that name to unambiguously identify one bucket out of everyone’s buckets, worldwide, in order to route a request correctly.
The free tier gives you 5GB of storage, which is far more than enough for learning. And unlike EC2, there’s no running state to worry about. Nothing idles and burns money while it waits. You pay for storage and for requests, and at the scale of a few test files that’s effectively zero.
Part 1: hosting a public static website
Creating the bucket, and the “Block Public Access” confusion
I created a bucket called dakshinasd-static-2026 in ap-south-1, and immediately hit a decision I didn’t fully understand yet: unchecking “Block all public access.” This setting exists because of a wave of real data breaches around 2018, where companies left S3 buckets full of customer records, credentials, and source code accidentally exposed to the entire internet. AWS added this as a blanket safety gate in response.
What confused me at first is that unchecking it does not make the bucket public. It only permits you to create public access policies going forward. Nothing becomes visible to anyone the moment you flip that checkbox. It’s more like removing a lock that was preventing you from even trying to open the door, not opening the door itself.
Enabling static website hosting
From there it was Bucket → Properties → Static website hosting → Enable, with index.html set as the index document. AWS hands you back a website endpoint URL once you save that. I noted it down and opened it immediately, the way I’ve learned to do after Day 2 taught me that things rarely work on the first try.
I got a 403.
The 403 that’s actually two problems wearing one error code
This turned out to be the most instructive part of the whole session. The 403 had two separate causes stacked on top of each other, and understanding both is what actually fixed it.
The first cause was simple: there was no file in the bucket yet. Nothing existed for the endpoint to serve. The second cause was less obvious and is the one that trips up almost everyone the first time they touch S3 hosting: even with a file present, the bucket still had no bucket policy explicitly granting public read access.
This is the part worth sitting with. AWS enforces two entirely separate layers of public access control, and both have to be satisfied before anything is actually reachable. Layer one is the “Block Public Access” setting, the account or bucket level gate I’d already turned off. Layer two is the bucket policy itself, an explicit, written grant of permission. Turning off Block Public Access is unlocking the door. The bucket policy is the part where you actually push it open. I’d assumed unlocking it was the whole job, and it isn’t.
Fixing it took two steps. First, uploading the file via the CLI:
# Upload a file to S3 via CLI
aws s3 cp index.html s3://your-bucket-name/index.html
Then adding a bucket policy under Permissions → Bucket policy in the console:
// Bucket policy for public read access (paste in console → Permissions → Bucket policy)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}
After both steps, reloading the endpoint URL finally showed the page.
A credentials error I’ll clearly be hitting again
Partway through this I ran the CLI upload and got hit with “Unable to locate credentials,” which stopped me for a second because nothing about my setup had changed since Day 2. The cause turned out to be that AWS_PROFILE doesn’t persist across terminal sessions. I’d set it in one terminal window earlier, opened a fresh one for this session, and the variable simply wasn’t there anymore.
# Always verify which AWS account/profile is active before doing work
export AWS_PROFILE=personal-learning
aws sts get-caller-identity
I have a feeling this is going to happen to me several more times before it becomes reflexive. The habit I’m trying to build is set it first, verify it with get-caller-identity, and only then start doing the actual work, rather than assuming the profile from an hour ago is still active.
Seeing it actually work
With both fixes in place, the site loaded, plain HTML served straight from S3, no server process anywhere, nothing running that I needed to keep alive. It’s worth drawing the connection explicitly: this is the exact same model Cloudflare Pages, Vercel, and Netlify are built on, files sitting in object storage, served out through a CDN. Those platforms just wrap it in a much nicer developer experience. S3 static hosting is the same idea with all the wiring left visible.
One caveat worth flagging even though I didn’t dig into it today: the S3 website endpoint is plain HTTP, not HTTPS. In production you’d put CloudFront, AWS’s CDN, in front of the bucket to get HTTPS and better performance. That’s a topic for another day, not this one.
The gotcha that matters more than the fix
The bucket policy I wrote grants s3:GetObject on your-bucket-name/*, and that wildcard applies to everything in the bucket, not just index.html. If I’d accidentally uploaded a .env file, a database export, or anything with credentials in it into that same bucket, it would have been immediately and silently public. The habit I’m taking from this is to treat public buckets as containing only files that are intentionally public, and to keep anything private in a completely separate bucket rather than relying on remembering not to upload the wrong thing.
Part 2: private buckets and presigned URLs
The problem this solves
Sometimes you want files in S3 that are never publicly accessible, private user documents, invoices, internal assets, but you still need to hand out access occasionally, like a “download your invoice” link. Presigned URLs are AWS’s answer to that: temporary, cryptographically signed access to one specific object, without changing any bucket-level settings at all.
Confirming the bucket is actually private
I created a second bucket, dakshinasd-private-2026, and made no changes to Block Public Access and wrote no bucket policy, which is the default private state. After uploading a test file, I tried hitting its direct object URL in the browser and got an Access Denied response, an XML error body rather than a 403 page. That’s worth noting on its own: this isn’t a bug or something to fix, it’s confirmation that the bucket is genuinely private and not just something I’m assuming is private.
Generating and using a presigned URL
# Generate a presigned URL valid for 5 minutes
aws s3 presign s3://your-private-bucket/file.txt --expires-in 300
This produces a long URL with a chain of query string parameters. Opened in a browser, the file loaded immediately, no bucket setting changed anywhere. The mechanism is that the URL itself contains a cryptographic signature generated from my IAM credentials plus an expiry timestamp, both encoded right into the query string. S3 validates that signature and checks the timestamp on every single request against that URL. The authorization lives entirely inside the URL, not in any bucket configuration.
To actually watch it expire rather than take it on faith, I generated a second one with a much shorter window:
# Or 30 seconds to actually watch it expire
aws s3 presign s3://your-private-bucket/file.txt --expires-in 30
Same URL, thirty seconds later, Access Denied again. This is genuinely how “view this attachment” or “download your invoice” features work in real products, and seeing it fail on schedule made the mechanism click in a way reading about it hadn’t.
The upload side, filed for later
A natural question came up while I was working through this: what about the reverse direction, a user uploading a file to a private bucket from a frontend? The pattern is the same idea with a presigned PUT URL instead of a GET, the backend generates it using its own credentials, hands it to the frontend, and the frontend uploads directly to S3 without the file ever routing through my own server. The distinction worth remembering here is that the backend generating these URLs should use an IAM Role, not an IAM User, because roles issue temporary credentials automatically rather than relying on long-lived access keys sitting in application code. I haven’t built this yet, it’s a flagged exercise for a future session, not something I implemented today.
EBS versus S3, briefly
Day 2’s EC2 instance came with an 8GB EBS volume attached to it, Elastic Block Store, and it’s worth being clear about how that differs from what I did today. EBS is block storage, it behaves like an actual hard drive, gets mounted to one instance at a time, has a real filesystem, and the OS and any apps on it read and write to it the way they would local disk. S3 is object storage, no filesystem, nothing to mount, purely driven by API calls, and not tied to any single instance at all. It’s reachable from anywhere.
The rule of thumb I’m working from now: databases and OS volumes need filesystem semantics, so they belong on EBS. User uploads, static assets, backups, anything meant for distribution belongs on S3. This matters going into Day 4, since RDS, AWS’s managed database service, actually uses EBS under the hood for its storage even though you never touch that layer directly. It’s why RDS has an “allocated storage” setting measured in GB in the first place.
Closing
I ended the session with two buckets running, effectively free at this scale since it’s a handful of kilobytes well inside the free tier. The real takeaway from Day 3 is that S3 looks deceptively simple from the outside, upload a file, get a URL, but it has a deliberately layered permission model underneath that catches nearly everyone the first time they touch it. The 403-that’s-actually-two-problems is one of the most commonly searched S3 issues out there, and now I understand exactly why it happens instead of just knowing the fix by rote.
Day 4 is RDS, a managed Postgres instance, connecting the FastAPI app I deployed on Day 2 to a real database instead of SQLite, and figuring out the security group rules that let an app on one instance actually talk to a database on another. If Day 1’s IAM lockout taught me to slow down before removing permissions, S3’s two-layer access model is teaching me to slow down before assuming a checkbox did more than it actually did.