Hands-On: Buckets, Uploads, and a Public Static Site
What You Will Build
Two buckets with different jobs: a private one for app uploads accessed through presigned URLs, and a public one serving a static website.
Step 1 — Create a Bucket in the Console
- Go to S3 → Create bucket.
- Bucket name:
myname-app-uploads-2026— remember, names are globally unique, so suffix with something distinctive. - Region: same region as the rest of your stack to avoid cross-region transfer costs.
- Leave Block all public access checked — this bucket stays private.
- Keep defaults for the rest and click Create bucket.
Upload a test file through the console: open the bucket → Upload → Add files → pick any image → Upload. Click the object and try its Object URL — you get AccessDenied. Good. That's Block Public Access doing its job.
Step 2 — Work with the Bucket from the CLI
The console is fine for one-offs; everything repeatable goes through the CLI.
# create a second bucket for the static site
aws s3 mb s3://myname-static-site-2026 --region us-east-1
# upload a single file
aws s3 cp ./logo.png s3://myname-app-uploads-2026/assets/logo.png
# upload a whole directory
aws s3 sync ./uploads s3://myname-app-uploads-2026/uploads/
# list contents
aws s3 ls s3://myname-app-uploads-2026/ --recursive --human-readable
# download
aws s3 cp s3://myname-app-uploads-2026/assets/logo.png ./logo-copy.png
aws s3 sync only transfers changed files — it's the workhorse for deploying frontend builds.
Step 3 — Build a Tiny Static Site
Create two files locally. index.html:
<html>
<head><title>My S3 Site</title></head>
<body>
<h1>Served straight from S3</h1>
<p>No web server involved.</p>
</body>
</html>
And error.html:
<html>
<body><h1>404 - Not Found</h1></body>
</html>
Upload both:
aws s3 sync . s3://myname-static-site-2026/ --exclude "*" --include "*.html"
Step 4 — Enable Website Hosting
aws s3 website s3://myname-static-site-2026/ \
--index-document index.html \
--error-document error.html
The site now has an endpoint, but every request still returns 403 because the bucket blocks public access. Two more steps fix that.
Step 5 — Allow Public Reads with a Bucket Policy
First, disable Block Public Access on this bucket only (never account-wide):
aws s3api put-public-access-block \
--bucket myname-static-site-2026 \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=false,RestrictPublicBuckets=false
Then apply a policy that allows anyone to read objects — and only read. Save as policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::myname-static-site-2026/*"
}
]
}
aws s3api put-bucket-policy \
--bucket myname-static-site-2026 \
--policy file://policy.json
Visit the website endpoint:
http://myname-static-site-2026.s3-website-us-east-1.amazonaws.com
Note the action is scoped to s3:GetObject on objects only — no s3:ListBucket, so nobody can enumerate your bucket, and no write actions. Keep the uploads bucket out of this entirely; it never gets a public policy.
Step 6 — Presigned URLs for the Private Bucket
The uploads bucket stays private, but your app can hand out temporary access. From the CLI:
aws s3 presign s3://myname-app-uploads-2026/assets/logo.png --expires-in 300
That URL works in any browser for 5 minutes, then dies. From Node with the v3 SDK — the pattern I use for user-download endpoints:
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const s3 = new S3Client({ region: 'us-east-1' });
async function getDownloadUrl(key) {
const command = new GetObjectCommand({
Bucket: 'myname-app-uploads-2026',
Key: key,
});
return getSignedUrl(s3, command, { expiresIn: 300 });
}
Presigned uploads work the same way with PutObjectCommand — the browser uploads directly to S3 and the file never passes through your server.
Cleanup
Buckets must be empty before deletion:
aws s3 rm s3://myname-static-site-2026/ --recursive
aws s3 rb s3://myname-static-site-2026
aws s3 rm s3://myname-app-uploads-2026/ --recursive
aws s3 rb s3://myname-app-uploads-2026
Common Problems
| Symptom | Likely Cause |
|---|---|
BucketAlreadyExists | Someone else owns that name globally — add a suffix |
Policy upload fails with AccessDenied | Block Public Policy is still enabled on the bucket |
| Website endpoint returns 403 | Policy resource ARN missing the trailing /* |
Presigned URL returns SignatureDoesNotMatch | Clock skew, or the URL was modified after signing |