Private S3 Static Hosting Without CloudFront
I needed a home for a growing pile of small static sites, and I ended up serving all of them from one private S3 bucket through a small nginx container: about 40 lines of config, no CloudFront, no public bucket.
The sites were the usual mix: Single Page Application (SPA), HTML, a one-page tool, generated reports, internal docs. A public bucket per site would have exposed files that were never meant to be public. A CloudFront distribution per site felt like a lot of setup for pages a handful of people open.
So I set myself three rules:
- One private bucket. Nothing in it is publicly readable.
- One container service, running next to our other services and behind the same load balancer.
- A folder per site. Uploading
my-app/to the bucket should make it live at/my-app/, with no deploy and no config change.
Here’s the setup I ended up with, how the config works line by line, and the gaps I’d fix next time.
Why I didn’t use S3 website hosting or CloudFront
I looked at the two usual options first. Both are good tools; neither fit what I had.
| Option | Bucket stays private | Work to add a new site | Runs on our existing ECS setup | Custom error page |
|---|---|---|---|---|
| S3 static website hosting | No, objects must be public | None | No | Yes |
| CloudFront + Origin Access Control | Yes | Low to medium (behaviours, or a distribution per site) | No | Yes |
| nginx + sigv4-proxy (what I built) | Yes | None, just upload a folder | Yes | Yes |
S3 website hosting was out straight away. It only works if the objects are publicly readable, and it serves plain HTTP on its own endpoint.
CloudFront with Origin Access Control is the textbook answer for a private bucket, and I’d still pick it for public, global traffic.
What tipped it for me was the domain. The sites had to live under our company domain, and that domain already ran through a load balancer in front of our container services on ECS. Adding one more ECS service behind that load balancer was a small change. CloudFront would have meant a second entry point for the same domain, with its own certificate and DNS setup, just to serve a few static pages.
How I put it together
I split the work across two containers, so that only one of them ever holds AWS credentials.
- nginx handles everything HTTP: turning
/my-report/style.cssinto the S3 keymy-report/style.css, falling back toindex.html, the health check and the error page. - sigv4-proxy handles everything AWS: it signs each request with an IAM role’s credentials and forwards it to S3.
- The bucket has all public access blocked. Each top-level folder is one site.
I like this split because my nginx config has no secrets in it, and I can replace either part without touching the other.
My nginx config, piece by piece
The whole service is one default.conf with five parts. In the snippets below I’ve renamed the bucket to my-static-sites; the region is eu-central-1.
1. The upstream
upstream sigv4_backend {
server sigv4-proxy:8080;
}
I never let nginx talk to S3 directly. Every request goes to sigv4-proxy first, which nginx reaches under the hostname sigv4-proxy.
2. The Host header
Every proxied location sets the same header:
proxy_set_header Host my-static-sites.s3.eu-central-1.amazonaws.com;
This one line is what makes the whole thing work. sigv4-proxy reads the Host header to decide where to forward the request and what to sign it for. Pointing it at the bucket’s S3 endpoint means the request gets signed for S3, in the right region, for the right bucket, and nginx never sees a credential.
3. Two routing rules
# /<site>/<path> -> s3://my-static-sites/<site>/<path>
location ~ ^/([^/]+)/(.+)$ {
set $site $1;
set $path $2;
proxy_pass http://sigv4_backend/$site/$path;
proxy_set_header Host my-static-sites.s3.eu-central-1.amazonaws.com;
}
# /<site> or /<site>/ -> s3://my-static-sites/<site>/index.html
location ~ ^/([^/]+)/?$ {
set $site $1;
proxy_pass http://sigv4_backend/$site/index.html;
proxy_set_header Host my-static-sites.s3.eu-central-1.amazonaws.com;
}
The first part of the URL is the site, and it maps straight to a top-level folder in the bucket. That’s how I got “add a site without a deploy”: the rules don’t know or care which sites exist.
I copy the captures into $site and $path with set, because regex captures like $1 get overwritten whenever nginx evaluates another regex. Named variables are safer.
4. A health check that never touches S3
location = /health {
access_log off;
return 200 'healthy\n';
add_header Content-Type text/plain;
}
Our load balancer polls this constantly, so I answer it from nginx alone and keep it out of the access log. No S3 request, no log noise.
5. Turning S3 errors into a friendly 404
proxy_intercept_errors on;
error_page 403 404 /404.html;
location = /404.html {
internal;
root /usr/share/nginx/html;
}
proxy_intercept_errors on swaps S3’s XML error response for my own page. I bake that page into the image, so it still works when S3 is the thing that’s failing.
The detail worth knowing: I map 403 as well as 404. The signing role doesn’t have s3:ListBucket, and without it S3 answers 403 Access Denied for a key that doesn’t exist, so it doesn’t reveal which keys exist. Without 403 in that list, every mistyped URL would show an “Access Denied” XML page. The catch is that a real permission problem also looks like a 404, so if everything suddenly 404s, the sigv4-proxy logs are where I’d look first.
Running sigv4-proxy with one permission
For the signing I used aws-sigv4-proxy, a small open-source proxy from AWS Labs. It takes an unsigned HTTP request, signs it with AWS Signature Version 4 using whatever credentials the AWS SDK finds, and forwards it.
To try it locally, Docker Compose was enough for me:
services:
sigv4-proxy:
image: public.ecr.aws/aws-observability/aws-sigv4-proxy:latest
command: ["--name", "s3", "--region", "eu-central-1"]
environment:
AWS_PROFILE: static-sites-reader
volumes:
- ~/.aws:/root/.aws:ro
web:
build: .
ports:
- "8080:80"
depends_on:
- sigv4-proxy
On ECS I don’t pass any keys. The task gets an IAM role, and the SDK inside sigv4-proxy picks it up on its own.
That role has exactly one permission:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-static-sites/*"
}
]
}
Read-only, one bucket. Even if the container were compromised, all it could do is read files that are already meant to be served. I could add s3:ListBucket to get real 404s instead of 403s, but I’d rather not let the role list every key in the bucket.
How I package and deploy it
The image is just three lines on top of the official nginx Alpine image:
FROM nginx:1.31.8-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY 404.html /usr/share/nginx/html/404.html
I build it in Bitbucket Pipelines, tag it with the build number and latest, and push it to Amazon ECR:
pipelines:
custom:
build-and-deploy:
- step:
name: Build and Push to ECR
services:
- docker
script:
- docker build -t static-sites:$BITBUCKET_BUILD_NUMBER -t static-sites:latest .
- pipe: atlassian/aws-ecr-push-image:2.6.0
variables:
AWS_DEFAULT_REGION: 'eu-central-1'
IMAGE_NAME: 'static-sites'
TAGS: "${BITBUCKET_BUILD_NUMBER} latest"
I made it a custom pipeline that only runs when I trigger it, because the image almost never changes. Publishing a new site is just an upload, for example aws s3 sync ./dist s3://my-static-sites/my-report/. I only rebuild the container when the routing rules or the 404 page change.
The gaps I found, and how I’d fix them
This config works well for what we’ve put in the bucket so far: flat sites whose links include the trailing slash. When I went back through it to write this up, I found a few gaps that would show up as soon as a different kind of site lands in the bucket. Here’s each one and the fix I’d apply.
1. /site without a trailing slash loses its CSS
/my-report/ looks fine, but /my-report loads the HTML with no styles. The second rule serves index.html for both, but the browser resolves relative links against the URL it sees. On /my-report, style.css becomes /style.css instead of /my-report/style.css.
My fix is to redirect instead of serving, together with absolute_redirect off;. Without that, nginx builds the redirect with its own scheme and port, and since TLS ends at our load balancer, users would get sent to http://.
2. Folders inside a site have no index page
/my-docs/ works, but /my-docs/guide/ returns 404. That URL matches the first rule, so nginx asks S3 for the key my-docs/guide/, which doesn’t exist. Only the top level of each site gets the index.html fallback.
The fix is a rule for any path ending in /, placed before the general rule, because nginx uses the first regex location that matches.
3. Single-page apps break on deep links
A React or Vue app would work from its home page, but refreshing /my-app/users/42 shows the 404 page, because users/42 is a client-side route, not a file in the bucket.
If I host an SPA, I’ll give it its own prefix location that falls back to its index.html. The ^~ modifier makes it win over the regex rules.
location ^~ /my-app/ {
error_page 403 404 = /my-app/index.html;
proxy_pass http://sigv4_backend;
proxy_set_header Host ${S3_HOST};
}
4. The bucket name is hard-coded three times
The same Host line appears in every location, so moving to another bucket or region means editing three lines and rebuilding.
The official nginx image already has a fix built in: at startup it runs envsubst on any file in /etc/nginx/templates/. I’d copy the config there as default.conf.template, write ${S3_HOST}, and set S3_HOST in the ECS task definition. Only variables that exist in the environment are replaced, so nginx’s own $site and $1 are left alone.
5. S3 response headers leak through
Responses carry x-amz-id-2 and x-amz-request-id, which tell anyone who looks that the site is S3 behind a proxy. Two lines at server level hide them: proxy_hide_header x-amz-id-2; and proxy_hide_header x-amz-request-id;.
6. File names with spaces fail
nginx decodes the URL before the regex captures it, and a proxy_pass built from variables sends that decoded path as it is. A file called Q3 report.pdf would turn into a request S3 rejects. My simple fix is a naming rule for uploads: lowercase, hyphens, no spaces.
7. Every request goes to S3
Nothing is cached, so each page view costs an S3 GET. For our low-traffic internal sites that’s fine. If traffic grows, I’d set Cache-Control at upload time (aws s3 sync --cache-control "max-age=300") so browsers cache, or turn on nginx’s proxy_cache.
The routing block with fixes 1, 2 and 4 applied
absolute_redirect off;
# /<site> -> 301 to /<site>/
location ~ ^/([^/]+)$ {
return 301 /$1/;
}
# any path ending in / -> that folder's index.html
location ~ ^/(.+)/$ {
set $dir $1;
proxy_pass http://sigv4_backend/$dir/index.html;
proxy_set_header Host ${S3_HOST};
}
# everything else -> the object itself
location ~ ^/([^/]+)/(.+)$ {
set $site $1;
set $path $2;
proxy_pass http://sigv4_backend/$site/$path;
proxy_set_header Host ${S3_HOST};
}
The exact-match locations (/health, /404.html, /favicon.ico) still take priority over all three rules.
What I’d harden next
Three changes are on my list. None of them adds new moving parts:
- No long-lived keys in CI. The pipeline still pushes to ECR with an access key stored in repository variables. I’d like to switch to Bitbucket’s OpenID Connect provider and an IAM role the pipeline assumes, so there’s nothing left to leak or rotate.
- Pin images. I’d deploy by build-number tag (or image digest) instead of
latest, and pin sigv4-proxy to a version too. Rolling back would then just mean redeploying the previous tag. - Authentication for internal sites. The bucket is private, but nginx serves whatever is in it to anyone who can reach the load balancer. For internal sites, an ALB listener rule with OIDC authentication, or nginx’s
auth_request, would close that gap.
Looking back
This setup is deliberately boring: two small containers, about 40 lines of config, one IAM permission, and publishing a site is just a folder upload. For internal, low-traffic sites on infrastructure we already run, it’s been the right trade for me.
I’d still switch to CloudFront with Origin Access Control if:
- the audience became public or global, and caching close to users mattered,
- each site needed its own custom domain and certificate,
- traffic grew enough for S3 request costs and latency to show.
Until then, one private bucket and a small proxy does everything I need.