v0.1.0.NET 10 · six packages · MIT
Building blocks for small sites behind Cloudflare
The parts every ASP.NET Core and Blazor site on Azure Container Apps needs and nobody wants to write twice: a job queue in PostgreSQL, origin protection, cache headers, Turnstile, Tailwind without Node, image uploads. The web sibling of Deskling.
dotnet add package Webling.Jobs
<PackageReference Include="Webling.Jobs" Version="0.1.0" />
using Webling.AspNetCore;
using Webling.Cloudflare;
builder.AddCloudflareOrigin();
builder.Services.AddTurnstile(config.GetSection("Turnstile"));
builder.Services.AddPublicCaching("__Host-session");
var app = builder.Build();
// 403 unless the request came through your zone.
app.UseCloudflareOrigin();
app.UseSecurityHeaders(nonce =>
$"script-src 'self' 'nonce-{nonce}'");
app.UseAuthentication();
// [PublicPage] components: cached at the edge,
// private for signed-in visitors.
app.UsePublicPageCaching();
using Webling.Cloudflare;
using Webling.Jobs;
builder.Services.AddDbContext<AppDbContext>(...);
builder.Services.AddJobDispatcher<AppDbContext>(
config.GetSection("Jobs"));
builder.Services.AddJobHandler<ResizePhoto, ResizePhotoHandler>();
builder.Services.AddCloudflareCache(config.GetSection("Cloudflare"));
// Anywhere, in the same SaveChanges as the upload:
db.EnqueueJob(new ResizePhoto(photo.Id), now);
await db.SaveChangesAsync();
using Webling.Aspire;
web.PublishAsAzureContainerApp((_, app) =>
app.UseCloudflareIngress("www.example.com",
certificateName: "origin-ca"));
if (builder.ExecutionContext.IsPublishMode)
{
// Fails the deploy if Cloudflare changed its ranges.
await CloudflareIngress.EnsureRangesCurrentAsync();
builder.RunJobBeforeApps("migrations",
subscription, resourceGroup, "web", "worker");
}
Safe when misconfigured
No origin secret outside Development: the app won't start. Turnstile unreachable: the check fails. No purge token: nothing is sent. A missing setting never turns into an open door.
Your names, your strings
Cookies, paths, job names and secrets come from your app and its configuration. The packages ship no text, and the job table uses explicit names whatever your EF naming convention is.
Tested against the real thing
The job queue runs against PostgreSQL, the middleware on TestServer, the Tailwind build on Linux, Windows and macOS. Cloudflare's IP list is checked every week.
What's inside
Six packages, one version. Take the job queue alone, or the whole stack from AppHost to stylesheet.
Webling.JobsBackground jobs in PostgreSQL
Enqueue in the same transaction as the change that caused the job. Replicas claim jobs with FOR UPDATE SKIP LOCKED, hold a 5-minute lease, retry with backoff and give the attempt back on shutdown. Old finished jobs are cleaned up.
EnqueueJob IJob IJobHandler<T> AddJobDispatcher<TContext> ApplyWeblingJobs
Webling.AspNetCoreOrigin, headers and caching
An origin check with a shared secret, health endpoints hidden from the edge, the client IP trusted only after the check. Security headers with a nonce per request. Edge caching for public pages that turns private when a personal cookie is present.
UseCloudflareOrigin UseSecurityHeaders GetCspNonce [PublicPage] PublicCaching
Webling.CloudflareTurnstile and cache purges
Verify a Turnstile token behind an interface your use cases can depend on. Purge changed pages from Cloudflare's cache by address, 30 to a request as the Free plan allows. No ASP.NET Core, so a worker can use it.
IHumanCheck TurnstileCheck CloudflareCache AddTurnstile
Webling.AspireContainer Apps behind Cloudflare
Ingress that admits only Cloudflare's IPv4 ranges, probes that pass AllowedHosts, an uploaded Origin CA certificate, and a deployment step that runs your migrations job before any app rolls out.
UseCloudflareIngress EnsureRangesCurrentAsync RunJobBeforeApps
Webling.TailwindTailwind without Node
The pinned Tailwind v4 standalone CLI, downloaded once per user and checked against its SHA-256, compiles your stylesheet before each build that changed a style or a component. Nothing ships with your app.
TailwindInput TailwindOutput TailwindSource
Webling.ImagesImage uploads
Formats recognized by their first bytes, HEIC refused with a reason, uploads cut off at a size limit, and photos re-encoded upright, in sRGB and without metadata, as WebP in several sizes or JPEG on white.
ImageSignature UploadStream ImageEncoder
Every package grew out of Adjudeca and is used by it. Nothing here is a framework: each piece is a few hundred lines you could read in one sitting.
A job that keeps failing
Delivery is at least once, so handlers are idempotent. A failure goes back to pending with a growing delay and jitter, so jobs that failed together don't retry together. After the fifth attempt the job stays failed with its last error, for 30 days.
- attempt 1fails30 s + ≤20%
Back to pending.
- attempt 2fails1 min + ≤20%
Doubles each time.
- attempt 3fails2 min + ≤20%
Capped at 1 hour.
- attempt 4fails4 min + ≤20%
Any replica may take it.
- attempt 5failsfailed
Kept with its error.
pending→running→done · lease expired → claimed again · host stopping → pending, attempt given back · last attempt → failed
Only Cloudflare gets in. Only your zone gets past.
Allowing Cloudflare's IP ranges at the ingress keeps the internet out, but any Cloudflare customer's zone can still reach your origin. A secret that only your zone's Transform Rule sends closes that gap, and the checks run before anything else in the pipeline.
Health probes from inside the environment still get through; the same paths requested through Cloudflare answer 404.
- ingress Cloudflare's IPv4 ranges only
- Bundled with the package and checked against cloudflare.com every week, and again when you deploy.
- app
X-Origin-Secretor 403 - Compared in constant time, then removed from the request so nothing logs it. Outside Development the app refuses to start without a secret of at least 32 characters.
- app Client IP after the check
CF-Connecting-IPbecomes the remote address only on requests that passed it, so rate limits see the visitor, never a spoofed header.- edge Cached, then purged
- Public pages are cached for five minutes and served stale for ten more; the worker purges a page when its content changes. A visitor with a personal cookie always gets a private answer.
Install
One tag versions all six packages, so keep them on the same version. Webling is 0.x: any minor version may change public types.
NuGet
.NET 10
dotnet add package Webling.Jobs
dotnet add package Webling.AspNetCore
dotnet add package Webling.Cloudflare
dotnet add package Webling.Aspire # AppHost
dotnet add package Webling.Images
<!-- build only -->
<PackageReference Include="Webling.Tailwind" Version="0.1.0"
PrivateAssets="all" />
What it assumes
The stack it was built for; parts work alone
- PostgreSQL through EF Core for the job table (the claim is PostgreSQL SQL).
- Cloudflare in front, with a Transform Rule that sends the origin secret.
- Azure Container Apps, published by Aspire from CI, for the AppHost pieces.
- Tailwind CSS v4; the standalone CLI exists for macOS, Linux (glibc and musl) and Windows x64.
No support. Built for my own projects and shared as is: issues and pull requests aren't monitored, and any release may change or break things.