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.
GETandHEADsupport 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.