I needed to host an internal handbook. Nothing fancy: a static site full of onboarding guides and how-tos, built with a static site generator into a folder of HTML, CSS, JS, and images. The one hard requirement: only signed-in employees get to see it.
And that requirement quietly kills all the easy hosting options. Blob Storage’s static website feature? Public. A plain CDN? Public. The moment “who is asking” matters for every single file - not just the pages, the images and search index too - the files have to live somewhere private, and something with auth has to sit in front and hand them out.
My something is one Azure Function. One endpoint. It serves the entire site.
The Blob Container Is the Filesystem
There’s no clever mapping layer. I upload the build output into the container exactly as the generator produced it:
index.html
guides/onboarding/index.html
guides/expenses/index.html
assets/main.css
assets/search.js
images/office-map.png
The container layout is the URL space. Deploying a new version of the site is one CLI command:
az storage blob upload-batch --source ./public --destination site-content --overwrite
The Catch-All Function
Here’s the whole thing, trimmed to its skeleton:
[Function("Site")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "{*path}")] HttpRequest req,
string? path,
CancellationToken ct)
{
// Every file goes through this gate - pages, scripts, images, all of it.
if (!await _auth.IsSignedInAsync(req))
return new UnauthorizedResult();
var blobPath = MapToBlobPath(path);
var blob = _container.GetBlobClient(blobPath);
try
{
var response = await blob.DownloadStreamingAsync(cancellationToken: ct);
var contentType = response.Value.Details.ContentType ?? ContentTypeFor(blobPath);
return new FileStreamResult(response.Value.Content, contentType);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return new NotFoundResult();
}
}
private static string MapToBlobPath(string? path)
{
if (string.IsNullOrEmpty(path))
return "index.html"; // "/" → the front page
if (path.EndsWith('/') || !Path.HasExtension(path))
return $"{path.TrimEnd('/')}/index.html"; // "/guides/onboarding/" → its index
return path; // "/assets/main.css" → as-is
}
What’s happening here?
Route = "{*path}"is the whole trick. The*makes it a catch-all: one route binding matches/,/guides/onboarding/,/assets/main.css, anything. One function, every file on the site.- The auth guard runs before anything touches storage. That’s the entire reason this setup exists - a static host can protect a site, this protects every byte. (How
IsSignedInAsyncworks - cookies, Entra ID, whatever fits - is its own topic; the point is it’s oneifat the top.) MapToBlobPathdoes the job a web server normally does silently: default documents./becomesindex.html, extension-less routes like/guides/onboardingbecomeguides/onboarding/index.html. Forget this and your front page is a 404.DownloadStreamingAsyncreturns a live stream, andFileStreamResultpipes it straight to the response. The function never buffers a whole file in memory - a 4 MB image flows through, it doesn’t land here.- A missing blob throws
RequestFailedExceptionwith status 404; the exception filter turns that into a cleanNotFoundResultinstead of a pre-flight existence check (which would just be a second storage call).
Content Types: The Unglamorous Part That Breaks Everything
If you serve HTML with the wrong Content-Type, the browser doesn’t render your page, it downloads it. Ask me how I know.
Best option: set correct content types on the blobs at upload time (upload-batch infers most of them). But blobs uploaded by hand or by older scripts often end up as application/octet-stream, so I keep a fallback map:
private static string ContentTypeFor(string path) => Path.GetExtension(path).ToLowerInvariant() switch
{
".html" => "text/html",
".css" => "text/css",
".js" => "text/javascript",
".json" => "application/json",
".svg" => "image/svg+xml",
".png" => "image/png",
".woff2" => "font/woff2",
_ => "application/octet-stream",
};
What You Get for Free
- Auth on every file. Not just page-level protection - the org chart PNG and the search index JSON are exactly as protected as the pages.
- One deploy target. Build the site,
upload-batchthe folder, done. No web server to configure, nothing to restart. - Generator-agnostic. The function doesn’t know or care if the folder came from Hugo, Astro, Docusaurus, or hand-written HTML.
- Cheap environments. Staging is just a second container and one config value. Rollback is re-uploading yesterday’s build folder.
One More Trick: Stop Re-Downloading Unchanged Files
There’s an elephant in the version above: every request downloads the full file from storage and pushes it to the browser - even when the file hasn’t changed in weeks and the browser has a perfect copy from five minutes ago.
The fix is already sitting in Blob Storage: every blob has an ETag, a version fingerprint that changes on every write. Browsers already know the game - once they’ve seen an ETag header, they send it back as If-None-Match on the next request. All the function has to do is forward that header into the SDK call:
var options = new BlobDownloadOptions();
var ifNoneMatch = req.Headers.IfNoneMatch.ToString();
if (!string.IsNullOrEmpty(ifNoneMatch))
options.Conditions = new BlobRequestConditions { IfNoneMatch = new ETag(ifNoneMatch) };
var response = await blob.DownloadStreamingAsync(options, ct);
if (response.GetRawResponse().Status == 304)
{
// Browser's copy is current - Azure never sent us the body at all.
req.HttpContext.Response.Headers[HeaderNames.ETag] = ifNoneMatch;
return new StatusCodeResult(StatusCodes.Status304NotModified);
}
req.HttpContext.Response.Headers[HeaderNames.ETag] = response.Value.Details.ETag.ToString();
var contentType = response.Value.Details.ContentType ?? ContentTypeFor(blobPath);
return new FileStreamResult(response.Value.Content, contentType);
The beautiful part: the ETag comparison happens inside Azure Storage, not in your code. On a match, storage answers 304 with no body - the file bytes never reach the function, and the function relays a bare 304 to the browser. No server-side cache, nothing to invalidate, and it works identically across scaled-out instances because nobody holds any state. The browser is the cache, Azure is the validator, and the function stays what it was: a pipe.
Two rules to make it stick: always set the ETag response header (no ETag out means the browser never asks conditionally again), and pass the value through untouched - the quotes are part of it, and “cleaning them up” silently breaks the match forever.
Gotchas
- You are the web server now. Default documents, trailing slashes, 404 pages - all the invisible things a real web server does are your job.
MapToBlobPathabove is the minimum, not the maximum. - SPA? Then unknown routes need a fallback. For a client-side-routed app, a route with no matching blob should serve
index.html(200, not 404) and let the router sort it out. For a docs site like mine, a real 404 is correct. - Cold starts sit in front of your CSS. The function is in the path of every byte, so a consumption-plan cold start delays the whole page, not just an API call. For an internal tool I can live with it; know your tolerance.
Wrapping Up
One catch-all route, a private container, and a twenty-line function: a whole authenticated website with no web server to run. Add the If-None-Match pass-through and unchanged files stop moving entirely. The container is the filesystem, the function is the doorman. 🚪
