Codebelt
Static Content Provider icon with separate website and asset-origin nodes.

Segregated Assets with Static Content Provider

Static Content Provider gives ASP.NET Core sites a small, read-only asset host and CDN origin so application logic, site-owned assets, and shared CDN resources can move on separate deployment lifecycles instead of being bundled into one web app.

TOOLS
  • .NET
  • ASP.NET Core
  • CDN
  • Static Assets
  • Docker
  • Architecture
  • Segregated Assets
  • DevEx
Codebelt Static Content Provider diagram showing a website handing static assets to a separate CDN origin and asset host.Codebelt Static Content Provider diagram showing a website handing static assets to a separate CDN origin and asset host.

The easy default for a website is to let the application serve everything: HTML, routing, business behavior, CSS, JavaScript, images, fonts, downloads, and every generated asset that happens to land under wwwroot.

That default is convenient. It is also one of those engineering shortcuts that becomes harder to defend as the system matures.

Static assets do not have the same lifecycle as application logic. They need different caching rules, different deployment concerns, different invalidation behavior, and often a different operational blast radius. A Razor Pages app, MVC site, API host, or worker-backed web system should not have to be the origin for every public byte merely because that was the shortest path during project setup.

Static Content Provider is the Codebelt Tool for that boundary. Its source project, Codebelt.Cdn.Origin, is a small ASP.NET Core and Kestrel application that serves physical files from a configured content root. It can run as a separate asset host for a website, or as the custom origin behind a CDN provider such as AWS CloudFront, Cloudflare, Azure Front Door, or Google Cloud CDN.

The design goal is deliberately narrow: serve public static content safely, with a correct HTTP contract, without turning the origin into another business application.

The old reason was domain sharding

Dedicated asset hosts are not new.

In the HTTP/1.x era, teams often used multiple asset domains to work around browser connection limits. Splitting requests across static1.example.com, static2.example.com, and static3.example.com increased parallel downloads when one TCP connection could only carry limited concurrent work.

That reason has aged out. HTTP/2 was standardized specifically to make better use of a connection, including multiple concurrent exchanges on the same connection. Codebelt.Cdn.Origin says the same thing in its own README: on HTTP/2 and HTTP/3, the connection-count argument for separate asset domains is not the point anymore, and old-style domain sharding can be counter-productive.

The architectural reason still holds.

Static content is a different deployment concern. It can be served from a smaller runtime surface, cached more aggressively, mounted or baked read-only, and placed behind CDN semantics that are inappropriate for dynamic application responses. The goal is no longer "more browser connections." The goal is segregation of duties.

That difference matters.

A cleaner host model

A useful production topology separates three concerns:

www.example.com     -> application and website rendering
assets.example.com  -> site-owned static assets
cdn.example.com     -> shared, versioned resources used across applications

The names are examples, not a naming rule. The important part is ownership.

www.example.com owns application behavior. It can deploy when routes, pages, services, authentication, data access, or business behavior change.

assets.example.com owns content that belongs to that particular site: stylesheets, scripts, images, icons, generated bundles, and article media. Those files can follow the website content lifecycle without forcing the application host to serve them.

cdn.example.com owns shared resources: versioned libraries, fonts, design-system artifacts, framework scripts, documentation assets, or anything that multiple applications consume. That surface should strongly prefer immutable, explicitly versioned URLs and long-lived cache headers.

This model is not more complex for its own sake. It is a way to keep the system honest about what each host is responsible for.

What the Static Content Provider adds

Codebelt.Cdn.Origin does not try to replace ASP.NET Core static-file behavior. It packages the right subset of it into a focused deployable tool.

In the current 2.0.0 line, the provider:

  • serves files from CdnOrigin:ContentRoot, defaulting to /cdnroot;
  • validates the content root at startup so missing, unreadable, or application-overlapping roots fail before traffic is served;
  • uses ASP.NET Core static-file middleware for GET and HEAD, content type handling, validators, conditional requests, range requests, and default documents;
  • rejects unknown file types by default unless explicit MIME mappings are configured;
  • supports two cache profiles: revalidate for mutable URLs and immutable for versioned or content-addressed paths;
  • supports configurable CORS, optional response compression, and health endpoints;
  • maps unsupported methods against existing files to 405 Method Not Allowed with an Allow header;
  • runs as a non-root container on port 8080, with /cdnroot as the read-only content mount.

That is the right shape for an origin. It has enough behavior to be a reliable static host and intentionally little else.

It is not an upload service. It is not an object store. It is not a reverse proxy. It is not a dynamic website. It is not a place to add "just one small endpoint."

That restraint is the feature.

HTTP semantics are the contract

A static origin is only useful if caches, browsers, and CDN providers can trust its responses.

For mutable URLs, the default revalidate profile emits a public response with max-age=12h, s-maxage=7d, and must-revalidate. That lets browsers and shared caches retain the response while still giving the origin a clear validation path.

For versioned or content-addressed URLs, the immutable profile emits a public one-year freshness lifetime with immutable. That is the profile to use for paths whose content will never change after publication.

The important part is that these are explicit profiles, not a single generic header sprayed onto every file. A mutable article image and a versioned framework bundle are not the same kind of asset.

The provider also avoids several old habits:

  • it no longer hashes file contents per request to build ETag values;
  • it does not emit no-transform by default, so a CDN can legitimately optimize responses;
  • it does not rely on Expires when Cache-Control: max-age is the authoritative freshness signal;
  • it does not cache responses inside the origin process when the browser and CDN are the actual cache layers;
  • it does not serve arbitrary unknown file extensions by default.

These choices keep the origin small and predictable. They also lean on the framework where the framework already owns the hard HTTP behavior.

Microsoft's ASP.NET Core static-files guidance documents the same baseline: static files are served with headers such as ETag, Last-Modified, and Content-Type, and the framework's static-file support is the correct place to handle that behavior. The Codebelt provider adds deployment shape, validation, explicit cache profiles, and a hardened container boundary around that baseline.

Two roles, one origin

Static Content Provider has two practical roles.

The first role is segregated web assets. A site can continue authoring assets in the normal project structure, then publish those files into a separate image or mount. The application renders HTML and references the asset host. The asset host serves the files.

For local development or a small deployment, that may be enough:

Browser -> www.example.com    -> ASP.NET Core application
        -> assets.example.com -> web-cdn-origin -> /cdnroot

The second role is CDN origin. In that mode, a provider such as CloudFront or Cloudflare sits in front of the origin. The CDN caches at the edge, honors or overrides cache headers according to its configuration, and sends cache misses or revalidations back to Codebelt.Cdn.Origin.

Browser -> cdn.example.com -> CDN edge -> web-cdn-origin -> /cdnroot

The same origin can serve both strategies because the core requirement is the same: a small, read-only HTTP surface with correct cache and file-serving semantics.

A minimal runtime path

The container path is intentionally direct. The published image sets ASPNETCORE_HTTP_PORTS=8080 and CdnOrigin__ContentRoot=/cdnroot, exposes port 8080, and runs as user 65532.

A local run can be as small as:

docker run -d --name cdn-origin \
  --read-only \
  -p 8080:8080 \
  -v /path/to/content:/cdnroot:ro \
  codebeltnet/web-cdn-origin:2.0.0

For content-hashed or versioned paths, add an immutable prefix:

CdnOrigin__Cache__ImmutablePathPrefixes__0=/assets/

Then keep the rule strict: only publish files under that prefix when the URL is immutable. If a file may be replaced in place, keep it under the revalidate profile.

That discipline is what makes CDN behavior boring in the best possible way.

The skill that moves an ASP.NET Core app there

The tool provides the runtime origin. Migration is a separate practice.

That is where .NET Segregated Assets fits. The skill is designed for ASP.NET Core applications that should keep authoring static files in wwwroot while serving deployed app assets from codebeltnet/web-cdn-origin. It inspects the application, preserves application-owned versus shared CDN ownership, and wires the local and deployment topology without asking the website to abandon its normal authoring workflow.

The source for that skill is available in the Codebelt agentic repository.

That distinction is important. A tool should not pretend to be a migration plan. A skill should not pretend to be a runtime. The two belong together because they own different parts of the same architectural move.

Use Static Content Provider when static delivery should be separated from the main web application and one of these statements is true:

  • the application should stop shipping or serving its own public assets;
  • site-owned assets need independent deployment or scaling;
  • a CDN should sit in front of a small custom origin instead of the business application;
  • cache policy needs to differ between mutable URLs and immutable versioned URLs;
  • public file serving should be read-only, explicit, and boring;
  • the team wants to avoid inventing static-file HTTP behavior that ASP.NET Core already provides correctly.

Do not use it to hide architectural uncertainty. If assets are still tightly coupled to dynamic rendering, credentials, per-user authorization, runtime generation, or private data access, keep that behavior in the application until the boundary is real.

Static segregation works when the content is genuinely public, file-shaped, and cacheable.

Better architecture is usually less convenient at first

The easiest solution is often to let the web app do everything. That is why so many systems keep assets, pages, APIs, and operational behavior in one deployment long after the initial convenience has stopped paying for itself.

Static content is a good place to push back.

Separating assets from application logic gives the system a clearer shape. It makes caching a first-class contract. It lets a CDN do CDN work. It gives operations a smaller origin to reason about. It also makes the application host less responsible for bytes that do not need application logic at all.

That is the practical value of web-cdn-origin: not a nostalgia pass for domain sharding, and not a custom static-file framework.

It is a small, deliberate runtime boundary for content that deserves its own lifecycle.

Timeline

The web-cdn-origin checkout used for this article has local-complete history, but its Git tags and changelog headings drift from the image versions that were actually published to Docker Hub after the early 1.x line. The timeline below therefore treats Docker Hub's stable image tags as the version ledger, then cross-checks local Git commits and changelog dates to explain what each published image contained.

  1. v1.0.0 2021-04-28

    The first published image was a small ASP.NET Core static origin with cache validators and permissive CORS.

    The initial source introduced Program and Startup, served a configured content path from /cdnroot, emitted Cache-Control, Expires, Last-Modified, and a content validator, and allowed cross-origin access for asset delivery.

  2. v1.1.0 2021-04-29

    The origin became container-first and learned default documents.

    By the time Docker Hub published 1.1.0, configuration had moved to environment variables such as CDNROOT, CACHECONTROL_*, and CDNROOT_DEFAULTFILES, and the pipeline could resolve index.html and default.htm before static-file serving.

  3. v1.1.5 2021-05-02

    The 1.1 line hardened cross-platform asset delivery.

    Docker Hub published intermediate 1.1.1 through 1.1.4 images while the provider switched to case-insensitive file lookup, added MD5-based ETag generation, and made ETAG_BYTESTOREAD configurable. By 1.1.5, CORS had moved onto OnPrepareResponse, so the static-file response itself emitted Access-Control-Allow-Origin alongside the cache and validator headers.

  4. v1.2.0 2021-05-20

    Response compression joined the origin pipeline.

    The provider added ASP.NET Core response compression and placed it before static-file serving, giving compressible assets a first implementation of Gzip/Brotli delivery while keeping the same file-origin model.

  5. v1.2.1 2021-12-11

    The published image moved to .NET 6.

    Docker Hub 1.2.1 aligns with the same source snapshot that the repository later labeled v1.3.0. In source terms, this was the .NET 6 retarget rather than a redesign of the asset-serving pipeline.

  6. v1.3.0 2022-11-22

    The next published image moved to .NET 7.

    Docker Hub 1.3.0 aligns with the net7.0 retarget that the repository later labeled v1.4.0. The static-file contract stayed the same, but the runtime baseline advanced again.

  7. v1.4.0 2024-04-30

    The final 1.x image moved to .NET 8 and refreshed its Alpine container base.

    Docker Hub 1.4.0 aligns with the April 2024 ALM commit rather than the later unpublished v1.4.1 Git tag. The project retargeted net8.0 and updated both the ASP.NET and SDK base images to 8.0.

  8. v2.0.0 2026-09-26 (current)

    The origin became a modern .NET 10, strongly configured, CDN-ready runtime.

    The 2.0.0 release replaced the legacy startup model with modern minimal hosting, introduced the CdnOrigin options section, added explicit revalidate and immutable cache profiles, made CORS, compression, content types, and health checks configurable, removed per-request content hashing for ETag, and hardened the container around non-root port 8080 with /cdnroot as the content mount.

Sources