Kontakt os

One Endpoint, Whole Website: Serving a Static Site Through an Azure Function

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?

  1. 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.
  2. 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 IsSignedInAsync works - cookies, Entra ID, whatever fits - is its own topic; the point is it’s one if at the top.)
  3. MapToBlobPath does the job a web server normally does silently: default documents. / becomes index.html, extension-less routes like /guides/onboarding become guides/onboarding/index.html. Forget this and your front page is a 404.
  4. DownloadStreamingAsync returns a live stream, and FileStreamResult pipes 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.
  5. A missing blob throws RequestFailedException with status 404; the exception filter turns that into a clean NotFoundResult instead 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-batch the 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. MapToBlobPath above 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. 🚪

Pinksky bruger cookies for at forbedre funktionaliteten på denne hjemmeside. Du bestemmer selv hvad vi må bruge. Læs mere i vores cookiepolitik.