ASP.NET Core with a Blazor Server admin page and two API endpoints. Plain files on disk, no database, no login.
$CLICKWRAP_DATA/
race-timer/
stats.json # download counters for this app
3.3.0.1/
app.zip # zipped ClickOnce publish folder
metadata.json
3.4.0.0/
app.zip
metadata.json
time-maker/
1.6.0.0/
...
metadata.json:
{
"version": "3.4.0.0",
"releaseNotes": "Fixed lap import.",
"uploadedUtc": "2026-08-28T09:14:03Z",
"sizeBytes": 8412331,
"sha256": "9f2c…"
}Latest is the highest version folder name, parsed with System.Version — so 3.10.0.0
beats 3.9.0.0, which string ordering would get wrong. Folders that do not parse as a version,
or that have no app.zip, are skipped. A version folder dropped in by hand works; without
metadata.json the size and date come from the file itself and there are no release notes.
stats.json sits beside the version folders and is the only file the server writes outside an
upload:
{
"appId": "race-timer",
"totalDownloads": 412,
"lastDownloadUtc": "2026-09-02T11:20:44+00:00",
"versions": {
"3.4.0.0": { "downloads": 388, "lastDownloadUtc": "2026-09-02T11:20:44+00:00" }
}
}One counter is added per request to the download endpoint, before the file is streamed — so an
aborted download still counts and a resumed range request counts twice. It is a popularity
signal, not billing. totalDownloads keeps counting versions that have since been deleted, which
is why it is stored rather than summed from versions.
Counting is best-effort: writes are serialised per app so two concurrent downloads cannot lose a
count, go through a temporary file so a crash cannot leave a truncated one, and any failure is
logged and swallowed rather than allowed to break the download. An unreadable stats.json starts
again from zero. Delete the file to reset an app's counters; nothing else depends on it.
The counts are shown on both pages: total per app on /, and per version on /admin once an app
is selected.
{
"appId": "race-timer",
"version": "3.4.0.0",
"downloadUrl": "https://updates.example.com/api/apps/race-timer/versions/3.4.0.0/download",
"releaseNotes": "Fixed lap import.",
"uploadedUtc": "2026-08-28T09:14:03+00:00",
"sizeBytes": 8412331,
"sha256": "9f2c…"
}404 when the app has no versions. 400 when the app id is not a valid segment.
Streams app.zip as application/zip with range requests enabled, so a large download can
resume rather than restart.
Plain text Healthy with 200, or Unhealthy with 503 — which is all a container healthcheck
needs. It checks that the data folder is present, since an unmounted volume is the one failure
the server cannot recover from on its own and cannot otherwise detect. The rest of the app being
up is implied by the endpoint answering at all.
The image installs curl for this; wire it up from compose, for example:
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 30s
timeout: 3s
retries: 3appId and version become path segments, so they are whitelisted against
^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ rather than sanitised. Anything else is rejected with 400
before touching the disk.
One page, built with MudBlazor: pick an existing app or type a new id, enter a version, optional release notes, choose the zip, upload. Success shows a snackbar; validation failures show inline.
The MudBlazor theme lives in Components/ClickWrapTheme.cs and is built from the same palette as
the installer and the store launcher (accent #2563EB, surface #FFFFFF, background #F3F4F6),
so the admin UI reads as part of the same family of tools.
There is no app-level auth by design — this is expected to sit behind a Cloudflare Tunnel with Cloudflare Access in front of it. Do not expose it directly.
Put the Access application on your-host/* and add a bypass policy for /api/*, which is
all the installers need. Do not scope Access to /admin alone.
The reason is Blazor, not Cloudflare. / and /admin are two routes of one interactive circuit:
once a browser has loaded any page, the router switches between them client-side over the
SignalR connection, with no further HTTP request. Access only sees HTTP requests, so it never
gets a chance to challenge — the admin page renders, upload control included, from a session that
only ever passed through the public page.
That is not merely a missed prompt on the nav button. Anyone able to load an unprotected page can reach the same UI directly:
history.pushState({}, '', '/admin');
window.dispatchEvent(new PopStateEvent('popstate'));Protecting the whole host closes it: the circuit itself cannot be established without passing
Access first. Nothing is lost by doing so — the pages are for you, and installers only ever call
/api/....
Uploads stream to a staging folder and are hashed on the way through, so nothing is buffered
whole in memory. The zip is then checked for setup.exe and a *.application at its root
before anything moves into place. A rejected or interrupted upload leaves nothing behind, and a
half-written version is never visible to /latest.
Rejections you will actually hit:
| Cause | Message |
|---|---|
| Version already exists | Version 3.4.0.0 of 'race-timer' already exists. Tick overwrite to replace it. |
| Not a version number | Enter a valid version number, for example 1.2.3.0. |
| Zipped the folder, not its contents | This does not look like a ClickOnce publish folder: setup.exe and *.application missing from the root of the zip. |
That last one is worth the check: the installer runs setup.exe from the root of what it
extracts, so a wrongly-shaped zip would otherwise produce a broken install on every machine.
All from environment variables, so it drops into a container with no appsettings.json. These
are every variable the app itself reads — the names are declared once as constants on
ServerOptions.
| Variable | Default | Purpose |
|---|---|---|
CLICKWRAP_DATA |
/data on Linux, <app>/data on Windows |
Root of the app/version tree. The only state; put it on a volume. |
CLICKWRAP_PUBLIC_BASE_URL |
(forwarded headers) | Public origin, e.g. https://updates.example.com. Used to build downloadUrl. |
CLICKWRAP_MAX_UPLOAD_MB |
512 |
Upload size cap, in megabytes. Values that are not a positive integer fall back to the default. |
ASPNETCORE_URLS |
http://0.0.0.0:8080 |
Addresses to bind. All interfaces, since it is reached through the tunnel, not loopback. |
Standard ASP.NET Core variables still work — ASPNETCORE_ENVIRONMENT,
Logging__LogLevel__Default and the rest — but nothing in ClickWrap requires them.
Every one of these can also be passed as a command-line argument, which is handy in development:
dotnet run --project src/ClickWrap.Server -- --CLICKWRAP_DATA /tmp/clickwrap --urls http://0.0.0.0:8090The server prints what it resolved, so a misconfigured deployment is obvious in the logs:
--------------------- ClickWrap server starting v1.0.0 ---------------------
Current user: app
Working directory: /app
Listening on: http://0.0.0.0:8080
Public base URL: https://updates.example.com
Data path: /data
Max upload: 512 MB
Apps published: 2 (7 versions, 412 downloads served)
With CLICKWRAP_PUBLIC_BASE_URL unset, that line says so and names the variable, because it is
the one setting that silently produces unreachable download URLs behind a tunnel.
Set CLICKWRAP_PUBLIC_BASE_URL in production. Without it the downloadUrl in /latest is
built from the inbound request host, which behind a tunnel is the tunnel's host, not yours —
installers would then be handed a URL they cannot reach.
TLS is terminated by Cloudflare, so there is no HTTPS redirection and no HSTS. X-Forwarded-*
headers are trusted from any proxy because cloudflared connects from inside the container
network and so is never in a known-proxy range.
Keys are persisted to a dataprotection-keys folder beside the binaries — /app/dataprotection-keys
in the container, next to the build output on Windows. Deliberately not under CLICKWRAP_DATA:
the key ring is not app content and has no business sharing a volume with the published packages.
You do not need to mount a volume for it. Nothing in this app outlives a request — no login,
no cookies, no sessions — so a key ring regenerated on redeploy costs at most one reload for
anyone who happened to have a page open at that moment, whose antiforgery token and Blazor
circuit were signed with keys that no longer exist. Everything worth keeping is in
CLICKWRAP_DATA.
Mount /app/dataprotection-keys only if you want redeploys to be seamless for open pages, or if
you ever run a second replica: two instances with separate key rings cannot validate each other's
tokens, so they would have to share this folder.
The remaining startup warning, "No XML encryptor configured", is expected on Linux: there is no DPAPI, and encrypting the key ring would mean managing a certificate. It is acceptable here because the keys protect nothing sensitive — authentication is Cloudflare Access's job at the edge, and this app has no login, cookies or sessions of its own. Silencing it would mean adding certificate management for no security gain.
dotnet run --project src/ClickWrap.ServerThen open http://localhost:8080/admin. Data lands in
src/ClickWrap.Server/bin/Debug/net10.0/data unless CLICKWRAP_DATA says otherwise.