Skip to content
DERKONLINE

Run Your Own Signed Release Channel for Self-Hosted App Updates

Build, Ed25519-sign, and publish update zips so self-hosted customers pull verified releases no attacker can forge.

Derrick S. K. Siawor8 min read

If you ship software that customers run on their own servers, you eventually face a question you cannot avoid: how does a customer's box go from version 1.4.2 to 1.4.3 without you SSHing into every one of them by hand. The lazy answer is "they download a zip from a URL and unzip it." The lazy answer is also how you turn one compromised download server, or one hijacked DNS record, into remote code execution on every customer you have.

The right answer is a signed release channel that you own. You build the release, sign it with a private key that never leaves your control, publish it, and the customer's installer refuses to apply anything that does not carry your signature. A man-in-the-middle can swap the file, poison the mirror, or spoof the host, and the update still will not run, because the bytes do not verify against your public key. This is the same pattern Tauri's updater, F-Droid, and Apache releases use, and it is well within reach for a self-hosted product.

The shape of the problem

An auto-update flow has four moving parts: build the artifact, sign it, publish it, and verify-then-apply on the customer side. The security of the whole chain lives entirely in step two and step four. If signing is solid and verification is mandatory, the publish step can run over plain HTTP on a server you do not fully trust and it still does not matter, because tampering is detected before anything executes.

Signed release pipeline: build, Ed25519 sign, publish, verify, apply with auto rollback

That last point is the one to internalise. You are not trying to keep the file secret. The release zip is meant to be downloaded by anyone. You are trying to make it impossible to forge. Those are different goals, and confusing them is how teams over-build the distribution side and under-build the verification side.

Why Ed25519

Use Ed25519 for the signatures. It is the modern default for this job for concrete reasons: public keys are 256 bits, signatures are 512 bits, verification is fast, and the algorithm has no parameter footguns the way RSA does. It is what OpenSSH, GnuPG, and OpenBSD's signify tool reach for. You are not inventing crypto here, you are using the same primitive that secures SSH logins for the whole industry, the same discipline that runs through issuing JWTs attackers cannot forge or replay.

The mechanism is a detached signature. You compute a signature over the release artifact with your private key and produce a separate small .sig file. The customer downloads both the zip and the .sig, and their installer verifies the signature against your public key before touching anything. Detached is the right choice because it keeps the artifact untouched and lets you verify without modifying the thing you are verifying.

Step one: a build that produces one canonical artifact

Before you can sign anything, the build has to be deterministic enough that "the release" is a single, well-defined set of bytes. Package the application into one zip, and alongside it write a small manifest: the version number, the file's hash, the date, and any release notes the customer-side installer will display. The manifest is what the update server hands out as "here is the latest version and where to get it." Sign the artifact, and also sign or hash-pin the manifest, so a tamperer cannot point a valid old signature at a different file.

Author the release notes as a real file in the release, not an afterthought. The customer-side update prompt reads from it, so a missing or bland note is what your customer sees in the update modal. This is one of those details that looks cosmetic and is actually part of the product surface.

Step two: sign with a key that never leaves your control

Generate an Ed25519 keypair once. The private key is the only secret that matters in this entire system. Protect it accordingly: it lives on your build box or in a hardware security module, never in the repo, never in CI logs, never pasted into a chat. Keep it walled off from your other credentials, because one leaked secret should never compromise the rest. The public key, by contrast, is meant to be distributed. It ships baked into the customer's installer, and it can be printed on your website, because knowing the public key gets an attacker nowhere.

Signing belongs in the release pipeline, automated, so a human never has to remember to do it and never has the opportunity to ship an unsigned build by accident. The signing step takes the built zip, signs it, and emits the .sig next to it. A release that is not signed simply does not get published, full stop.

# sign the release artifact with the private key, producing a detached signature
openssl pkeyutl -sign -inkey release_private.pem \
  -rawin -in cms-1.4.3.zip -out cms-1.4.3.zip.sig

We run exactly this pattern for CaveCMS, our self-hosted CMS: the release pipeline builds the zip, Ed25519-signs it, publishes it to an updates host, bumps the version, and the customer-side updater pulls and verifies it. The signing is not optional and not manual, it is one command in the deploy harness that knows about the app. That harness is built on the principle that your scripts are the source of truth and production is never fixed by hand, and on shipping updates with zero downtime so a publish never interrupts a running customer.

Step three: publish to a channel, not a random URL

The update server's job is small: answer "what is the latest version" with the manifest, and serve the artifact and its signature. Because verification is mandatory on the customer side, this server does not need to be heavily hardened against tampering, only kept available. You can run release channels (stable, beta) by publishing different manifests, so a customer who opts into beta points their installer at the beta manifest URL and a stable customer never sees it.

Keep the older releases around. The day a release has a bad bug, the customer needs to be able to pin or roll back to the previous signed version, and that only works if it is still published and still verifiable.

Step four: verify before you apply, and make verification non-skippable

This is the step that earns the whole design. On the customer's box, the updater:

  1. Fetches the manifest, sees a newer version is available.
  2. Downloads the artifact and its .sig.
  3. Verifies the signature against the embedded public key. On failure, it stops, deletes the download, and reports a clean error. There is no flag to skip this.
  4. Optionally re-checks the artifact's hash against the manifest as a second integrity gate.
  5. Only then applies the update, ideally to a fresh directory so the previous version stays intact for rollback.

The verification call is a single library function. In Node, crypto.verify with an Ed25519 key returns a boolean; in libsodium it is crypto_sign_verify_detached, returning 0 on success and -1 on failure. The whole security model rests on that boolean being checked and the update refusing to proceed when it is false.

import { verify } from "node:crypto";
const ok = verify(null, fileBytes, publicKeyPem, signatureBytes);
if (!ok) throw new Error("Release signature invalid, refusing to apply.");

The operational details that make it survivable

A signed channel is most of the work, but a few things separate a toy from a system customers trust with their production servers:

  • Apply to a fresh release directory and switch atomically, so a half-applied update never leaves the app in a broken state. Keep the previous release directory for instant rollback.
  • Gate the post-update restart on a health check. Pull, verify, apply, restart, then curl the app's health endpoint, and if it fails, roll back to the previous release automatically. An update that takes the customer's site down is worse than no update. This is the same logic behind a deploy script that rolls itself back when health checks fail.
  • Plan for key rotation before you need it. If your private key is ever compromised, you need a path to ship a new public key to every installer. The simplest version is shipping the next public key inside a release signed with the current key, so trust rolls forward. The mechanics here echo rotating production secrets without taking the app down, where the old and new key both stay valid through the cutover.
  • Never let an unsigned or skip-verification path exist "just for testing." That path is exactly the one an attacker uses. Test builds get signed with a test key the customer installers do not trust.

A signed update that fails to apply also needs to surface why, in a log line you can actually act on, which is where structured logging that turns noise into trustworthy alerts earns its keep. This is the kind of unglamorous infrastructure that decides whether a self-hosted product is something a serious customer will run, or something their security team vetoes. It sits at the intersection of server administration and product engineering, and it is the difference between "download this zip and trust us" and "verified, signed, and rolled back automatically if it breaks." If you are shipping software your customers run themselves and the update story is currently a manual SSH session, that is a conversation worth having before the first incident, not after.