· 3 min read
Self-hosting update feeds with Caddy
Droplet checks for updates in one static JSON file. There is no update server and no database. This post shows how Caddy serves that file, and why two cache headers do most of the work.
How Droplet finds an update
Every few hours, Droplet asks for one file: https://dl.nkryp.dev/droplet/updates/stable.json. This is the Tauri updater manifest. It holds the newest version, the release notes, and a signed download URL for each platform.
{
"version": "1.2.0",
"notes": "Faster discovery on busy networks.",
"pub_date": "2026-10-27T09:12:00Z",
"platforms": {
"windows-x86_64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6…",
"url": "https://dl.nkryp.dev/droplet/releases/1.2.0/Droplet_1.2.0_x64-setup.exe"
}
}
}When the version is newer than the installed one, Droplet downloads the file and checks the signature with the public key inside the app. The private key never touches the server. I wrote about that part in Signing Tauri updates in GitHub Actions.
Two kinds of files, two cache rules
The release folder holds two kinds of files. Each kind gets its own cache rule.
| Path | Changes | Cache-Control |
|---|---|---|
/droplet/releases/1.2.0/… |
Never | public, max-age=31536000, immutable |
/droplet/updates/stable.json |
On every release | no-cache |
- Release files never change. A new version gets a new folder, so a browser or a proxy can keep a file for a year.
- The manifest changes on every release.
no-cachedoes not mean “do not store”. It means “ask the server first”. - The check is cheap. When the manifest did not change, the answer is a
304with no body.
The Caddyfile
The whole download site is one block. The two header lines hold the cache rules.
dl.nkryp.dev {
root * /mnt/nkryp-releases/public
# Release files never change
@releases path /*/releases/*
header @releases Cache-Control "public, max-age=31536000, immutable"
# Manifests change on every release
@manifests path /*/updates/*
header @manifests Cache-Control "no-cache"
handle /*/latest/* {
reverse_proxy localhost:8100
}
file_server
}ETag and Last-Modified headers by itself when it serves a file. You do not configure them.
A static file and two headers do the work of an update server.
A stable link to the latest installer
The website needs one link that always gives the newest installer. The link cannot point to a file, because the file name contains the version. So a small .NET endpoint reads the manifest and redirects.
$ curl -I https://dl.nkryp.dev/droplet/latest/windows-x64.exe
HTTP/2 302
location: /droplet/releases/1.2.0/Droplet_1.2.0_x64-setup.exe
cache-control: no-cacheThe endpoint does four things:
- It checks every part of the path against an allowlist, so
../never reaches the disk. - It reads
stable.jsonto get the current version. - It reads
release.jsonof that version to find the file name. - It answers with a
302to the versioned file.
app.MapGet("/{product}/latest/{platform}.{ext}", async (
string product, string platform, string ext, ReleaseIndex index) =>
{
// Allowlist first: no path traversal.
if (!Slug.IsValid(product) || !Platforms.IsKnown(platform, ext))
return Results.NotFound();
var file = await index.FindLatestAsync(product, platform, ext);
return file is null
? Results.NotFound()
: Results.Redirect($"/{product}/releases/{file.Version}/{file.Name}");
});301 for this redirect. Browsers keep a permanent redirect in their cache, so users get the old installer after the next release.
What it costs
The update path is one Caddy block, two headers and one small endpoint. There is no database and no queue. Caddy serves the large files directly and supports range requests, so a broken download continues where it stopped.
Next, I count the downloads from the Caddy access log. That is the next post.