Skip to main content

Self-Hosted Sparkle Update Server: Appcast Hosting for macOS

Β· 13 min read

Sparkle is the standard way to auto-update a native macOS app. You link the framework, set a feed URL, and Sparkle checks an appcast β€” an RSS file that lists your versions β€” downloads the newest archive, checks its EdDSA signature and installs it.

Sparkle tells you how to generate that appcast (generate_appcast), but not where to host it. Most teams end up with a bucket or a GitHub Pages site and a script that copies files around. Then come the questions: how do I pull a bad release? How do I mark an update as critical without rebuilding? Which feed do the beta testers get?

This post shows how to use faynoSync as a self-hosted Sparkle update server. The stock Sparkle client stays untouched β€” faynoSync hosts the appcast and the archives, and you manage releases from a dashboard or CI. Everything below was run end to end on a local faynoSync with a real macOS app before writing it down.


What you get​

Plain bucket / GitHub PagesfaynoSync
Appcast (appcast.xml)You upload it and keep it in sync by handRebuilt automatically on every change
Pull a bad releaseEdit the XML by handUncheck Publish in the dashboard
Critical updateRebuild the appcast with --critical-update-versionCheck Critical (or --critical on upload)
Release notesHTML files next to the archivesChangelog field, rendered into the appcast
Channels, architecturesFolder naming conventionsOne appcast per channel / platform / arch
EdDSA signaturesYoursStill yours β€” passed through byte for byte
Delta updatesWork if the files are in the right placeUploaded with the version, URLs rewritten for you
CI uploadsCustom scriptsfaynoSync CLI, GitHub Action, Jenkins step

Your private signing key never touches the server. faynoSync reads the sparkle:edSignature from the appcast you upload and serves it unchanged, so Sparkle verifies every archive exactly as it would against any static host.


The example app​

We'll use a small native AppKit app that already has Sparkle wired in:

github.com/ku9nov/faynoSync-sparkle-example

  • Sources/UpdaterManager.swift β€” starts the stock SPUStandardUpdaterController and builds the feed URL.
  • Sources/Config.swift β€” reads owner, app name and channel from Info.plist, so one codebase can target any channel or architecture.
  • project.yml β€” the XcodeGen project, including the version numbers.
  • Makefile + scripts/ β€” make keys, make build, make appcast on top of Sparkle's own tools.

It opens one window, checks for updates on start and shows the standard Sparkle dialogs. There's no faynoSync SDK inside β€” the only "integration" is the feed URL. How the client is built is described in the Sparkle example docs.

What you need installed​

  • Docker β€” to run faynoSync locally.
  • Xcode command line tools and XcodeGen (brew install xcodegen).
  • Sparkle's CLI tools (generate_keys, generate_appcast) in the example's tools/ folder. The repo README has a three-line snippet that downloads them from a Sparkle release.

You don't need an Apple Developer ID for this walkthrough. Without one, the build is ad-hoc signed, which is fine for local testing.


Step 1. Run faynoSync​

git clone https://github.com/ku9nov/faynoSync.git
cd faynoSync
docker compose up --build

In a second terminal, run the database migrations:

docker compose exec -T backend /usr/bin/faynoSync migrate up

The API is now on http://localhost:9000, the dashboard on http://localhost:3000, and a public S3-compatible bucket serves the appcast and archives at http://cb-faynosync-s3-public.web.garage.localhost:3902 β€” the same way a CDN would in production. More details: Local Development Setup.


Step 2. Prepare the app in the dashboard​

Open http://localhost:3000 and register (for a local setup the secretKey is UHp3aKb40fwpoKZluZByWQ). Then create:

  1. Application β€” faynosyncSparkleExample. The example reads this name from FaynoSyncApp in Info.plist, and it becomes part of the feed URL.
  2. Channel β€” nightly, the default channel in project.yml.
  3. Platform β€” darwin. In the Updaters section, add sparkle. This is what tells faynoSync to accept and serve Sparkle appcasts for this platform.
  4. Architecture β€” arm64 on Apple Silicon, amd64 on Intel.

faynoSync builds one appcast per channel, platform and architecture, at a predictable path:

sparkle/{owner}/{app}/{platform}/{arch}/appcast.{channel}.xml

The example composes exactly this URL at runtime. If you registered with a username other than admin, change FaynoSyncOwner in Resources/Info.plist.


Step 3. Create an upload token and install the CLI​

In the dashboard, open Settings β†’ CI/CD Tokens, create a token, and in Select allowed apps pick faynosyncSparkleExample. Copy it β€” it's shown only once. See Create Token for the API behind it.

Install the faynoSync CLI (Sparkle support needs v1.1.0 or newer):

curl -L -o faynosync https://github.com/ku9nov/faynoSync-cli/releases/latest/download/faynosync-cli-darwin-arm64
chmod +x faynosync
sudo mv faynosync /usr/local/bin/

export FAYNOSYNC_TOKEN=fns_your_token_here
export FAYNOSYNC_URL=http://localhost:9000
export FAYNOSYNC_ACCOUNT=admin

The environment variables are the same ones you'd set as secrets in CI. Run faynosync init instead if you prefer a config file β€” see the CLI docs.


Step 4. Generate your signing keys​

Sparkle verifies every update with an Ed25519 key pair. The private key signs archives on your machine, the public key ships inside the app as SUPublicEDKey.

git clone https://github.com/ku9nov/faynoSync-sparkle-example.git
cd faynoSync-sparkle-example
make keys

generate_keys stores the private key in your login Keychain and prints the public key. Paste it into Resources/Info.plist, replacing the PASTE_SUPublicEDKey_FROM_make_keys placeholder. If you already have a Sparkle key in your Keychain, it prints that one instead of creating a new key.

This is the only trust anchor in the whole setup. faynoSync never sees the private key, and it can't produce a valid signature β€” so even a compromised update server can't push an archive your app will accept.


Step 5. Build and publish version 1.0.0​

Set the version in project.yml:

MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"

MARKETING_VERSION is what users see. CURRENT_PROJECT_VERSION becomes CFBundleVersion β€” this is the number Sparkle compares, so it must go up with every release.

make build
make appcast

make build produces build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.0.zip. make appcast signs it and writes build/dist/darwin/arm64/appcast.nightly.xml:

<item>
<title>1.0.0</title>
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>1.0.0</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>11.0</sparkle:minimumSystemVersion>
<enclosure url="faynosyncSparkleExample-1.0.0.zip" length="1027212"
type="application/octet-stream" sparkle:edSignature="kW77V9W4..."/>
</item>

Notice the enclosure URL is just a file name. You don't need --download-url-prefix β€” faynoSync rewrites every enclosure URL to where it actually stores the file.

Upload the appcast and the archive:

faynosync upload \
--app faynosyncSparkleExample \
--version 1.0.0 \
--channel nightly \
--platform darwin \
--arch arm64 \
--updater sparkle \
--file build/dist/darwin/arm64/appcast.nightly.xml \
--file build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.0.zip \
--publish \
--changelog "First release"
INFO  Upload completed
app faynosyncSparkleExample
files 2
uploaded_id 6abe76633daf3c60d93cb5a2
version 1.0.0

There's no --signature flag here β€” the signature already lives inside the appcast. Fetch the feed the way Sparkle will:

curl http://cb-faynosync-s3-public.web.garage.localhost:3902/sparkle/admin/faynosyncSparkleExample/darwin/arm64/appcast.nightly.xml
<item>
<title>1.0.0</title>
<pubDate>Thu, 01 Oct 2026 18:05:15 +0300</pubDate>
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>1.0.0</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>11.0</sparkle:minimumSystemVersion>
<enclosure url="http://cb-faynosync-s3-public.web.garage.localhost:3902/sparkle/admin/faynosyncSparkleExample/darwin/arm64/faynosyncSparkleExample-1.0.0.zip"
length="1027212" type="application/octet-stream" sparkle:edSignature="kW77V9W4..."/>
<description><![CDATA[<p>First release</p>]]></description>
</item>

Three things changed, and only these: the enclosure URL points to the stored file, pubDate is the moment you published, and the description is your faynoSync changelog. sparkle:version, length and edSignature are byte-for-byte what generate_appcast wrote. The full list of managed vs. pass-through fields is in the Sparkle updater docs.


Step 6. Install the app​

curl -o faynosyncSparkleExample-1.0.0.zip \
http://cb-faynosync-s3-public.web.garage.localhost:3902/sparkle/admin/faynosyncSparkleExample/darwin/arm64/faynosyncSparkleExample-1.0.0.zip
ditto -x -k faynosyncSparkleExample-1.0.0.zip /Applications
open /Applications/faynosyncSparkleExample.app

The app checks for updates on start, and Sparkle answers with its standard dialog: "You're up to date! faynoSync Sparkle Example 1.0.0 is currently the newest version available."

Files downloaded with curl don't get the macOS quarantine flag, so Gatekeeper doesn't complain about the unsigned build. For a real release you'd sign with your Developer ID and notarize β€” set DEVELOPER_ID and DEVELOPMENT_TEAM in the example's .env and the build switches to manual signing with hardened runtime.


Step 7. Ship an update​

Bump both numbers in project.yml to 1.0.1 and 2, then:

make build
make appcast

Because the 1.0.0 archive is still in the folder, generate_appcast now also creates a delta: faynosyncSparkleExample2-1.delta, a binary diff from build 1 to build 2. In our run the full archive was about 1 MB and the delta 1,078 bytes β€” a hello-world app barely changes between builds, but for a real app the delta is where most of the bandwidth saving is.

Upload the new archive together with its delta:

faynosync upload \
--app faynosyncSparkleExample --version 1.0.1 --channel nightly \
--platform darwin --arch arm64 --updater sparkle \
--file build/dist/darwin/arm64/appcast.nightly.xml \
--file build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.1.zip \
--file build/dist/darwin/arm64/nightly/faynosyncSparkleExample2-1.delta \
--publish --changelog "Second release"

The appcast you upload now lists both 1.0.0 and 1.0.1, but faynoSync only takes the items for the files in this upload. Older versions are kept in its database and stay in the feed until you unpublish or delete them β€” so in CI you can upload an appcast generated from just the new build.

The served feed now has a <sparkle:deltas> block under 1.0.1, with the delta's URL rewritten and its own edSignature untouched. Sparkle picks the delta when the installed build matches sparkle:deltaFrom and falls back to the full archive otherwise.

Restart the app. Sparkle shows "A new version of faynoSync Sparkle Example is available! faynoSync Sparkle Example 1.0.1 is now availableβ€”you have 1.0.0." with "Second release" as the release notes. Click Install Update: Sparkle downloads the update, verifies it (OK: EdDSA signature is correct for update in the system log), replaces the app and relaunches it as 1.0.1.


Step 8. Ship a critical update​

Build 1.0.2 (build 3) the same way and upload it with --critical:

faynosync upload \
--app faynosyncSparkleExample --version 1.0.2 --channel nightly \
--platform darwin --arch arm64 --updater sparkle \
--file build/dist/darwin/arm64/appcast.nightly.xml \
--file build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.2.zip \
--file build/dist/darwin/arm64/nightly/faynosyncSparkleExample3-2.delta \
--file build/dist/darwin/arm64/nightly/faynosyncSparkleExample3-1.delta \
--publish --critical --changelog "Security fix"

faynoSync adds <sparkle:criticalUpdate/> to the 1.0.2 item. On 1.0.1 the app now shows "An important update to faynoSync Sparkle Example is ready to install" β€” with only an Install Update button. No "Skip This Version", no "Remind Me Later".

The flag is a faynoSync setting, not part of the build. Uncheck Critical in the dashboard and the tag disappears from the appcast without rebuilding or re-signing anything.


Step 9. Pull a bad release​

Say 1.0.2 is broken. In the dashboard, edit version 1.0.2 and uncheck Publish. faynoSync rebuilds the appcast without it:

curl -s http://cb-faynosync-s3-public.web.garage.localhost:3902/sparkle/admin/faynosyncSparkleExample/darwin/arm64/appcast.nightly.xml | grep '<title>'
        <title>faynosyncSparkleExample</title>
<title>1.0.1</title>
<title>1.0.0</title>

The item and its deltas are gone together, and a client on 1.0.1 gets "You're up to date!" again.

Be clear about what this does: unpublishing stops the spread, it doesn't downgrade anyone. Sparkle never installs a lower CFBundleVersion, so clients that already got 1.0.2 stay on it. The fix is to ship 1.0.3 with a higher build number. To catch a bad release before it reaches many users, combine this with Rollout Health Reports or Sparkle's own sparkle:phasedRolloutInterval, which faynoSync passes through unchanged.


Uploading from CI​

The upload is one command, so it fits any pipeline. With the GitHub Action:

- name: Upload to faynoSync
uses: ku9nov/faynoSync-cli@v1
with:
app: faynosyncSparkleExample
version: 1.0.3
channel: nightly
platform: darwin
arch: arm64
updater: sparkle
file: |
build/dist/darwin/arm64/appcast.nightly.xml
build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.3.zip
publish: true
env:
FAYNOSYNC_TOKEN: ${{ secrets.FAYNOSYNC_TOKEN }}
FAYNOSYNC_URL: ${{ secrets.FAYNOSYNC_URL }}
FAYNOSYNC_ACCOUNT: ${{ secrets.FAYNOSYNC_ACCOUNT }}

A CI runner has no Keychain with your key. Export the private key once with generate_keys -x private-key-file, store it as a secret, and let generate_appcast read it from a file with --ed-key-file (the example's release.sh maps ED_KEY_FILE to that flag). If you also want deltas in CI, download the previous archive into the dist folder before running generate_appcast β€” Sparkle can only diff against an archive it can see.


What's different from Velopack​

If you read the self-hosted Velopack update server post, a few things work differently with Sparkle:

  • No intermediate builds yet. The Sparkle appcast is a static file, the same for every client, so faynoSync can't say "you, on 1.0.1, stop at 1.0.2 first". Intermediate builds need a per-client feed and are planned as a dynamic Sparkle route.
  • No faynoSync percentage rollout. Same reason β€” use Sparkle's phased rollout instead. See Rollout.
  • No rollback by downgrade. Velopack can be told to allow downgrades; Sparkle doesn't install lower versions.

What you get in return: update checks never touch the faynoSync API. Sparkle reads a static file from the bucket, so you can put a CDN in front of it.


Troubleshooting​

The app crashes at launch with "Library not loaded: @rpath/Sparkle.framework … different Team IDs". The app was built with hardened runtime but ad-hoc signed, so macOS refuses to load the embedded Sparkle framework. The example's build.sh turns hardened runtime off when DEVELOPER_ID is empty. In your own project, either sign with a Developer ID or disable hardened runtime for local builds.

Upload fails: "sparkle appcast has no matching <enclosure> for uploaded file(s)". You uploaded an archive that isn't in the appcast β€” usually because you ran make build but not make appcast. Regenerate the appcast and upload again.

Upload fails: "sparkle updater requires exactly one appcast*.xml feed file". Every Sparkle upload needs the appcast plus at least one full archive (.zip, .dmg, .tar.* or .aar). Also check that the darwin platform has sparkle in its updaters.

Sparkle refuses the update with a signature error. The SUPublicEDKey in the installed app doesn't match the key that signed the archive. Run make keys, paste the printed key into Info.plist, and rebuild β€” older installs signed with a different key can't verify new updates.

The app says it's up to date, but you just uploaded a new version. Check that you bumped CURRENT_PROJECT_VERSION (Sparkle compares that, not the marketing version), that the version is published, and that channel, platform and architecture match the feed URL the app uses.

Don't enable SURequireSignedFeed. faynoSync rewrites enclosure URLs, so a signature over the whole appcast can't stay valid. Security comes from the per-archive edSignature, which faynoSync never changes.


Wrapping up​

Sparkle handles the client: checking, verifying, installing, deltas. faynoSync handles the server: hosting the appcast, channels, critical flags, release notes, pulling bad releases and uploads from CI. Your signing key stays on your machine, and the client stays stock Sparkle β€” if you ever move away, you just change the feed URL.

Clone the example, run the steps above, and watch your first Sparkle update come from your own server.


How to try faynoSync?​

  1. Follow the Getting Started guide: https://faynosync.com/docs/getting-started

  2. Create your app using the REST API or web dashboard: API Docs: https://faynosync.com/docs/api Dashboard UI: https://github.com/ku9nov/faynoSync-dashboard

  3. Clone the Sparkle example app and follow its README.

  4. Read the full reference: Sparkle Updater.



If you find this project helpful, please consider subscribing, leaving a comment, or giving it a star, create an Issue or feature request on GitHub. Your support keeps the project alive and growing.