Codebelt
v2.0.0

Static Content Provider

Serve generated or application-owned static assets from a small, read-only origin instead of the business application.

Build and Delivery Codebelt.Cdn.Origin 576 pulls 0 stars Updated 2026-09-26

What it is

Static Content Provider is the Codebelt product surface for Codebelt.Cdn.Origin, a small ASP.NET Core and Kestrel service that serves physical files supplied at runtime. It is designed to run either behind a CDN as an origin or as a separately deployed asset host.

It deliberately does one job: serve static content safely. It is not an upload service, object store, directory browser, reverse proxy, dynamic website, or place for business logic.

Why choose it

Choose this Tool when static delivery should be separated from an ASP.NET Core application or from a generated asset pipeline. The separation is architectural. It keeps asset serving isolated from application logic, gives assets their own deployment and scaling path, and creates a cache-friendly surface for CDNs and browsers.

The current implementation validates its content root at startup, serves files read-only, uses framework static-file behavior for HTTP semantics, and avoids third-party production dependencies. Invalid configuration fails before the service starts serving traffic.

What it gives you

  • Read-only file serving from a configured content root.
  • GET and HEAD support with correct headers and body behavior.
  • Last-Modified, ETag, conditional requests, byte ranges, and default-document handling through ASP.NET Core static-file middleware.
  • Explicit cache profiles for mutable and immutable paths.
  • Optional response compression, CORS, and health endpoints.
  • Directory browsing disabled and unknown file types rejected by default.

How it fits the SDLC

Static Content Provider belongs in build and delivery because it gives published assets a deployable runtime surface. A website can keep authoring files normally, publish an asset set, and run that asset set independently as a CDN origin or static host.

This is different from the .NET Segregated Assets skill. The Tool is the deployable static origin. The Skill performs the migration and verification practice that moves an ASP.NET Core application toward segregated asset delivery without losing ownership clarity.

Getting started

This recipe runs a small ASP.NET Core website alongside a separate static asset host. Start both with Docker Compose, then package the same website and assets for Kubernetes. It uses codebeltnet/web-cdn-origin:2.0.0 and its 2.0 configuration contract.

The browser requests HTML from the website and CSS from the asset host:

Browser -> www.example.com    -> website Service -> ASP.NET Core :8080
        -> static.example.com -> assets Service  -> web-cdn-origin :8080 -> /cdnroot

Both public hosts terminate TLS at the ingress controller. This gives you an independent asset host; adding a CDN in front of it is a separate step.

Create the website and assets

Use Docker with Linux containers and Docker Compose v2. Create this directory structure; all commands below run from sample-website/:

sample-website/
  docker-compose.yml
  src/
    Website/
      Website.csproj
      Program.cs
      Dockerfile
      Dockerfile.static
      .dockerignore
      approot/
        css/
          site.css
  k8s/
    Deployment.yaml
    Service.yaml
    Ingress.yaml

src/Website/Website.csproj:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>
</Project>

src/Website/Program.cs:

using System.Text.Encodings.Web;

namespace Website;

internal static class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        var configuredUrl = builder.Configuration["Assets:BaseUrl"];
        if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var assetBaseUrl) ||
            (assetBaseUrl.Scheme != "http" && assetBaseUrl.Scheme != "https"))
        {
            throw new InvalidOperationException("Assets:BaseUrl must be an absolute HTTP or HTTPS URL.");
        }

        var stylesheetUrl = HtmlEncoder.Default.Encode(new Uri(assetBaseUrl, "/css/site.css").AbsoluteUri);
        var app = builder.Build();

        app.MapGet("/", () => Results.Content($$"""
            <!doctype html>
            <html lang="en">
            <head>
              <meta charset="utf-8">
              <meta name="viewport" content="width=device-width, initial-scale=1">
              <title>Sample website</title>
              <link rel="stylesheet" href="{{stylesheetUrl}}">
            </head>
            <body>
              <main>
                <h1>A website with a separate asset host</h1>
                <p>This HTML comes from the website. Its stylesheet comes from web-cdn-origin.</p>
              </main>
            </body>
            </html>
            """, "text/html; charset=utf-8"));
        app.MapGet("/health/live", () => Results.Text("Healthy"));
        app.MapGet("/health/ready", () => Results.Text("Healthy"));
        app.Run();
    }
}

Assets:BaseUrl is a setting owned by this sample website. It must be a URL the browser can reach, such as http://localhost:8010 locally or https://static.example.com in Kubernetes. A Compose service name or Kubernetes service name would only resolve inside its container network. The sample has no external application dependencies, so its health endpoints simply confirm that it can handle requests.

src/Website/approot/css/site.css:

body { margin: 0; font-family: system-ui, sans-serif; background: #f5f7fa; color: #182638; }
main { max-width: 48rem; margin: 5rem auto; padding: 2rem; border-top: 0.3rem solid #1479b8; }

src/Website/Dockerfile builds the website using the .NET SDK and ASP.NET Core runtime images:

FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY Website.csproj ./
RUN dotnet restore Website.csproj
COPY Program.cs ./
RUN dotnet publish Website.csproj -c Release -o /app/publish --no-restore /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
ENV ASPNETCORE_HTTP_PORTS=8080
COPY --from=build /app/publish ./
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Website.dll"]

src/Website/Dockerfile.static packages only the public assets into the origin image:

FROM codebeltnet/web-cdn-origin:2.0.0
ENV ASPNETCORE_HTTP_PORTS=8080
COPY --chown=65532:65532 approot/ /cdnroot/

The origin image supplies its entry point and non-root user. Only put public files under approot/; it becomes the origin's served content root. The website image copies only its project and program, so these assets are not included in the website's published output.

src/Website/.dockerignore:

bin/
obj/

Run locally with Docker Compose

docker-compose.yml builds the website and mounts the asset directory into the published origin image. The read-only bind mount lets you edit CSS locally while preventing writes from the origin container. Disabling automatic host-directory creation makes a missing asset directory an explicit error.

services:
  website:
    build:
      context: ./src/Website
      dockerfile: Dockerfile
    environment:
      ASPNETCORE_ENVIRONMENT: Production
      Assets__BaseUrl: http://localhost:8010
    ports:
      - "127.0.0.1:8000:8080"
  assets:
    image: codebeltnet/web-cdn-origin:2.0.0
    environment:
      CdnOrigin__Cache__Revalidate__MaxAge: "00:00:00"
      CdnOrigin__Cache__Revalidate__SharedMaxAge: "00:00:00"
    ports:
      - "127.0.0.1:8010:8080"
    volumes:
      - type: bind
        source: ./src/Website/approot
        target: /cdnroot
        read_only: true
        bind:
          create_host_path: false
docker compose config --quiet
docker compose up --build -d
curl --fail http://localhost:8000/
curl --fail --head http://localhost:8010/css/site.css
curl --fail http://localhost:8010/health/ready

Open http://localhost:8000/ and check that the page is styled. The CSS response should be 200 OK with a CSS content type, ETag, Last-Modified, and Cache-Control containing max-age=0, s-maxage=0, and must-revalidate. On Windows PowerShell, use curl.exe if curl resolves to a PowerShell alias. Stop the local example with docker compose down.

Build the deployment images

Replace registry.example.com/demo below and in Deployment.yaml with a registry path you own. Authenticate with that registry before pushing. These commands build for Linux AMD64; choose the platform matching your cluster nodes if they use another architecture.

docker build --platform linux/amd64 -t registry.example.com/demo/website:1.0.0 -f src/Website/Dockerfile src/Website
docker build --platform linux/amd64 -t registry.example.com/demo/website-assets:1.0.0 -f src/Website/Dockerfile.static src/Website
docker push registry.example.com/demo/website:1.0.0
docker push registry.example.com/demo/website-assets:1.0.0

Kubernetes uses the assets embedded in website-assets:1.0.0, so no host directory or persistent volume is required. Publish new tags when the website or assets change.

Deploy to Kubernetes

This step assumes a cluster with Linux nodes, kubectl access, and an installed controller that supports Kubernetes Ingress. Replace public in Ingress.yaml with its IngressClass name. Replace www.example.com and static.example.com throughout with your own hosts, and point both DNS records to the controller's external address.

Create a namespace for the example:

kubectl create namespace sample-website

The cluster must be able to pull both images. For a private registry, provision an image pull secret in sample-website and reference it through spec.template.spec.imagePullSecrets in both Deployments.

k8s/Deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: website
  namespace: sample-website
spec:
  replicas: 1
  selector:
    matchLabels:
      app: website
  template:
    metadata:
      labels:
        app: website
    spec:
      nodeSelector:
        kubernetes.io/os: linux
      containers:
        - name: website
          image: registry.example.com/demo/website:1.0.0
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: Production
            - name: ASPNETCORE_HTTP_PORTS
              value: "8080"
            - name: Assets__BaseUrl
              value: https://static.example.com
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            initialDelaySeconds: 5
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            initialDelaySeconds: 10
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 250m
              memory: 128Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: assets
  namespace: sample-website
spec:
  replicas: 1
  selector:
    matchLabels:
      app: assets
  template:
    metadata:
      labels:
        app: assets
    spec:
      nodeSelector:
        kubernetes.io/os: linux
      containers:
        - name: assets
          image: registry.example.com/demo/website-assets:1.0.0
          env:
            - name: ASPNETCORE_HTTP_PORTS
              value: "8080"
            - name: CdnOrigin__Cache__Revalidate__MaxAge
              value: "00:00:00"
            - name: CdnOrigin__Cache__Revalidate__SharedMaxAge
              value: "00:00:00"
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            initialDelaySeconds: 5
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            initialDelaySeconds: 10
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 250m
              memory: 128Mi

The origin's readiness check verifies content-root accessibility. The resource values are starting allocations for this small example; size them against your workload. Both containers listen on 8080, and both Services map port 80 to the named http container port.

k8s/Service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: website
  namespace: sample-website
spec:
  type: ClusterIP
  selector:
    app: website
  ports:
    - name: http
      port: 80
      targetPort: http
---
apiVersion: v1
kind: Service
metadata:
  name: assets
  namespace: sample-website
spec:
  type: ClusterIP
  selector:
    app: assets
  ports:
    - name: http
      port: 80
      targetPort: http

k8s/Ingress.yaml:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: website
  namespace: sample-website
spec:
  ingressClassName: public
  tls:
    - hosts:
        - www.example.com
        - static.example.com
      secretName: website-tls
  rules:
    - host: www.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: website
                port:
                  number: 80
    - host: static.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: assets
                port:
                  number: 80

Provision website-tls in the same namespace with a certificate covering both hosts. For an existing certificate and private key stored outside the project, run:

kubectl -n sample-website create secret tls website-tls --cert=/path/to/tls.crt --key=/path/to/tls.key

If your cluster already uses cert-manager, configure its issuer and certificate for this secret instead. HTTP-to-HTTPS redirects depend on the ingress controller; configure them using that controller's documented settings.

Apply the manifests and wait for both deployments:

kubectl apply -f k8s/Deployment.yaml
kubectl apply -f k8s/Service.yaml
kubectl apply -f k8s/Ingress.yaml
kubectl -n sample-website rollout status deployment/website --timeout=120s
kubectl -n sample-website rollout status deployment/assets --timeout=120s
curl --fail https://www.example.com/
curl --fail --head https://static.example.com/css/site.css

Open your website URL and confirm in the browser's Network panel that /css/site.css loads from the static host over HTTPS. The asset path remains /css/site.css because approot/ maps directly to /cdnroot/.

Choose a cache policy for your assets

The recipe uses a mutable filename and sets both browser and shared-cache freshness to zero. The default must-revalidate behavior remains enabled, so caches can retain the file but must validate it before reuse. Unchanged content can return 304 Not Modified.

For versioned or content-hashed asset URLs, configure CdnOrigin__Cache__ImmutablePathPrefixes__0 to match their path prefix and update the website to reference those URLs. Only use that profile when a published URL's content will never change. Keep previous asset versions available while older website instances or cached HTML may still reference them. See the source configuration reference for cache, CORS, compression, and health options.