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

stable.json
{
  "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-cache does 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 304 with no body.

The Caddyfile

The whole download site is one block. The two header lines hold the cache rules.

Caddyfile
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
}
Note Caddy adds the 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.

Sample quote

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.

bash
$ 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-cache

The endpoint does four things:

  1. It checks every part of the path against an allowlist, so ../ never reaches the disk.
  2. It reads stable.json to get the current version.
  3. It reads release.json of that version to find the file name.
  4. It answers with a 302 to the versioned file.
C# · LatestEndpoint.cs
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}");
});
Warning Do not use 301 for this redirect. Browsers keep a permanent redirect in their cache, so users get the old installer after the next release.
Fig. 1 · The update check. Only the link to "latest" reaches the .NET service. Caddy serves everything else from disk.

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.