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

- Date: 2026-06-25
- Author: Jeppe Spanggaard
- Description: Learn how a single Azure Function with a catch-all route can proxy an entire static website out of a private Blob Storage container, with auth in front of every file.
- URL: https://jeppe-spanggaard.dk/blogs/azure-function-blob-storage-static-site-proxy/
- Tags: csharp, API Performance, Azure Functions

## TL;DR

Put the static build in a **private** blob container. Write one HTTP-triggered function with a catch-all route (`{*path}`). For every request: check the session, map the route to a blob path, stream the blob back. `/`, `/guides/onboarding/`, `/assets/main.css` - same function, same twenty lines, whole website.


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:

```powershell
az storage blob upload-batch --source ./public --destination site-content --overwrite
```

## The Catch-All Function

Here's the whole thing, trimmed to its skeleton:

```csharp
[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:

```csharp
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:

```csharp
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. 🚪

