Skip to main content

Scaling Update Checks with Edge + S3 Response Cache

· 9 min read

Update checks are the noisiest traffic an update server ever sees. Every running copy of your app polls /checkVersion on a timer, almost always with the same parameters, and each call validates the app, channel, platform, and architecture before answering. At a few hundred clients that's nothing. At a hundred thousand, it's the same handful of answers recomputed millions of times a day — and the usual fix is database clustering, replication, and heavier ops you'd rather not run for an update server.

Performance Mode already takes the first bite out of this with a Redis cache at the API layer. The next step moves the hot read path off the origin entirely: serve repeated checks from object storage at the edge, and let the API handle only cache misses and the control plane.

This post explains how faynoSync's Edge + S3 response cache works, how it differs from Performance Mode, and how telemetry stays accurate when checks no longer reach the API.


The problem: load scales with clients, not with answers

The number of distinct answers your server can give is small. It's bounded by your dimension matrix:

N ≈ client_versions × channel × platform × arch × updater × package

The number of requests is bounded by your install base times polling frequency. Those two numbers diverge hard as you grow. One hundred thousand clients spread across five client versions still only have a handful of real answers — but they generate one hundred thousand round trips anyway.

Edge mode changes the economics. After warm-up, origin load is driven by distinct cache keys, not by raw client count. Five client versions across five dimension combinations produce on the order of twenty-five origin calls to build coverage — not one hundred thousand direct checks.


Two supported modes — no forced migration

faynoSync deliberately keeps both read paths. You don't have to move every client at once.

ModeHow clients checkBest for
Dynamic APIGET /checkVersion on the API originExisting integrations, scripts, and tools that call the API directly
Edge + S3 cacheOfficial SDK with EdgeURL → cached manifests on object storageHigh-volume desktop, server, and auto-update clients using SDK edge-first behavior

Dynamic API behavior is unchanged. Edge mode adds a parallel, cache-friendly read path without forking the core update API.


How edge mode fits together

What you turn on

Edge response caching is enabled per application with the cdn flag when you create or update an app (see Create Application and Update application).

Two pieces of supporting infrastructure:

  • A public object storage bucket for response manifests — metadata only; binaries stay on the existing artifact flow. Configure S3_BUCKET_NAME_CDN as described in the Environment Variables Overview.
  • An official faynoSync SDK configured with BaseURL (API origin) and optionally EdgeURL (edge/CDN base). Edge-first orchestration, fallback, cache fill, and telemetry are built for SDK clients. See the SDK overview and the Go SDK.
client := faynosync.NewClient(faynosync.Config{
BaseURL: "http://localhost:9000",
EdgeURL: "http://faynosync-cdn-edge.web.garage.localhost:3902",
})

If EdgeURL is omitted, the SDK behaves exactly as before and calls the API directly via BaseURL.

Request flow

SDK Client (BaseURL + optional EdgeURL)

If EdgeURL is set, SDK checks edge/S3 first

├─ HIT → return cached response to client
└─ MISS → call Origin API (/checkVersion) via BaseURL

API returns normal response

SDK persists response to S3 cache path

Future checks → edge/S3 HIT
  • HIT — a manifest object exists for the request dimensions; the SDK returns the cached JSON body to the application.
  • MISS — no valid manifest; the SDK calls /checkVersion, returns that response, and persists it to S3 so later checks become HITs.

The API stays the source of truth. Edge storage is a derived, disposable cache of responses the API already knows how to produce. Rare or brand-new dimension combinations fill themselves naturally through the fallback path the first time something asks — no pre-warm job required.

What a manifest contains

Each cached file is the full faynoSync JSON response for that client context — the same fields the Dynamic API returns:

{
"update_available": true,
"update_url_deb": "https://<bucket>.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.2.deb",
"update_url_rpm": "https://<bucket>.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.2.rpm",
"changelog": "### Changelog\n\n- Added feature X\n- Fixed bug Y",
"critical": true,
"is_intermediate_required": true
}

Objects live at a deterministic path keyed by dimensions:

/responses/{owner}/{app_name}/{channel}/{platform}/{arch}/{client_version}.json

There is intentionally no latest.json on the CDN. Version comparison stays on the server (or in precomputed per–client-version manifests). A single "latest" object at the edge would push comparison logic to clients and break faynoSync's model of server-side update decisions.

Cached objects carry standard cache headers, for example:

Cache-Control: public, max-age=60, must-revalidate

so CDNs and browsers revalidate frequently while still serving hot paths cheaply.

Publish, unpublish, and invalidation

When you publish or unpublish any version for an app, faynoSync deletes the response manifests for that application's scope. The next checks for affected dimensions are MISSes; the SDK falls back to /checkVersion, repopulates S3, and traffic HITs again. No manual flushing, no risk of pinning users to an old build — the same correctness guarantee Performance Mode gives you, applied to the edge layer.

Updater-specific behavior

For default JSON update checks, the cached body is the ordinary faynoSync response, and "no update" results are cached under the same key rules as "update available" results. Updater-specific contracts (Electron Builder, Tauri, Squirrel on Windows) still receive the payloads the API produces; only the lookup path changes from "always origin" to "edge first, origin on MISS." The JavaScript SDK maps cached JSON to updater-facing HTTP semantics — for example {"status": "no_content"} becomes 204 No Content, and {"status": "redirect", "url": "..."} becomes the redirect the updater expects.


Edge cache vs Performance Mode

They solve overlapping problems at different layers, and they're independent — run one, the other, or both.

AspectPerformance ModeEdge + S3 cache
Where it cachesRedis at the APIObject storage manifests at the edge
Who benefitsAny client calling /checkVersionSDK clients with EdgeURL and app cdn enabled
Cache keyAPI request signaturePer-dimension manifest path
InvalidationRedis TTL / cache policiesPublish/unpublish clears app manifest scope
CouplingPERFORMANCE_MODE=trueApp cdn + SDK + S3_BUCKET_NAME_CDN

Use Performance Mode to shave latency and DB load on the origin. Use edge mode when check volume is dominated by many clients repeating the same dimension keys and you want the origin to see MISS traffic only — not every poll.


Keeping telemetry accurate at the edge

Here's the catch with moving checks off the origin: today, telemetry is collected inside /checkVersion when clients send X-Device-ID. In edge mode, many checks never reach that endpoint — so without a change, your statistics would silently drop exactly as you scale up.

The fix is a dedicated, fast path. SDK clients that skip /checkVersion send a /telemetry/beacon request through the edge to the API instead:

Classic path:  Client → (optional X-Device-ID) inline telemetry → /checkVersion
Edge path: Client → edge manifest HIT → (optional X-Device-ID) → /telemetry/beacon

The beacon is built for volume, so it avoids per-request database lookups. Instead, the API keeps a compact in-memory allow-list index built from names only when metadata changes — owners, applications, channels, platforms, architectures, and versions. Changelog, artifacts, and other heavy fields stay out of memory. On each relevant change (app created, channel added, version published), the service rebuilds the index from a projection and atomically swaps the pointer handlers use:

var allowList atomic.Pointer[TelemetryAllowList]

func ReloadAllowList() {
next := BuildAllowListFromDB() // projection: names only
allowList.Store(next)
}

Beacon validation checks that (owner, app, channel, platform, arch) exists in the index. The reported client version is recorded but not required to match — clients may still report a version you've since removed in the admin UI. Invalid dimension combinations are rejected cheaply, and counters flow to Redis exactly as today's pipeline, so dashboards stay meaningful under edge load.

Internal benchmarks (local development)

These numbers were measured on a local Mac during development. Production hardware, network, and Redis will report different absolute values — treat them as order-of-magnitude guidance, not a SLA. They still show the design goal: validate dimensions from an in-memory snapshot instead of hitting the database on every beacon.

ApproachAverage time to persistenceRequests/sec (wrk, 4t/100c/30s)Avg latency
Previous path (DB-backed validation per request)~20.8 ms~359~287 ms
Beacon path (in-memory allow-list snapshot)~0.15 ms~1,192~88 ms

Under this setup the beacon path handled roughly more requests per second at lower latency.


When to reach for edge mode

  • Your check volume is dominated by many clients repeating the same dimension keys.
  • You want the origin to see MISS traffic only, not every poll.
  • Your clients can adopt the official SDK with EdgeURL.

If you're still serving a small or low-traffic deployment, Performance Mode alone is usually enough. The two stack cleanly when you outgrow it.


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, and set the cdn flag on the app: 📦 API Docs: https://faynosync.com/docs/api 🖥️ Dashboard UI: https://github.com/ku9nov/faynoSync-dashboard

  3. Configure S3_BUCKET_NAME_CDN and point the SDK at both BaseURL and EdgeURL.

  4. Read the full reference: Edge and S3 Response Cache.



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.


Rollout Health Reports — Catch Failed Updates Before They Spread

· 9 min read

Telemetry tells you who's running which version. It doesn't tell you when an update fails — when the download checksum doesn't match, the installer hits a full disk, or the app panics on first launch after a release. Those failures are exactly the ones you want to catch in the first hour of a rollout, before they reach the rest of your install base.

faynoSync's report ingestion fills that gap. Clients send short operational failure reports to a public endpoint; the server validates, groups, and aggregates them into a rollout-health picture by app, version, channel, platform, and architecture. The design is deliberately narrow: cheap to ingest, stable for aggregation, and bounded in storage — not a logging pipeline, an APM, or a Sentry replacement.

This post covers what reports are, how ingestion works, the privacy boundary, and how to read rollout health from the aggregated groups. Everything here is verified against the Reports Management docs.


What reports are (and aren't)

Reports give you:

  • A simple public endpoint for clients to send update/install/runtime failure events.
  • An aggregated view of rollout health grouped by stable technical dimensions.
  • Optional, size-bounded debug details stored separately for support investigations.
  • Groundwork for a future auto-pause / rollback decision engine.

Reports are explicitly not a general-purpose logging pipeline, a full APM, or a crash-analytics platform — and they avoid collecting user identity by design. Reports also never feed update-metadata trust decisions: signing, expiration, version monotonicity, and rollback protection are untouched.


Enabling reports

Ingestion is gated by a dedicated environment flag, similar to TUF routes. When REPORTS_ENABLED=false, neither the ingestion endpoint nor the read API is registered.

REPORTS_ENABLED=true
REPORTS_MAX_BODY_BYTES=262144
REPORTS_MAX_DETAILS_COMPRESSED_BYTES=131072
REPORTS_MAX_DETAILS_DECOMPRESSED_BYTES=1048576
REPORTS_BLOB_RETENTION_DAYS=30
REPORTS_MAX_BLOBS_PER_GROUP=10
REPORTS_STORAGE_PREFIX=reports
REPORTS_RATE_LIMIT_PER_KEY_PER_MINUTE=100

Two things to know:

  • Enabling reports forces a Redis connection, since rate limits depend on Redis.
  • Detail blobs reuse the existing STORAGE_DRIVER / S3_* configuration but are written to the private bucket (S3_BUCKET_NAME_PRIVATE) and are only retrievable through short-lived presigned URLs.

Full variable reference: Environment Variables Overview.


Two auth paths: clients ingest, admins read

Reports split cleanly into a public write path and an authenticated read path.

PathEndpointAuth
Ingestion (clients)POST /reports/ingestPer-app report key rpk_<64 hex> as Authorization: Bearer rpk_... — no JWT
Read (admins/team)GET /reports/groups, GET /reports/groups/:groupHash/blobsJWT, gated by CheckPermission(download, apps)

Report keys are managed through the existing report_keys lifecycle — one key per app with reports enabled. They're effectively public client credentials shipped inside your app, so abuse protection comes from the REPORTS_ENABLED gate, the per-app reports flag, and rate limits — not from key secrecy.

On the read side, admins see every app under their account; team users only see apps in their allowed_apps. Scoping is enforced in the repository: every query filters on the requester's accessible app_ids, so cross-owner access is impossible even when two owners' reports share a hash.


Sending a report

A minimal report is self-contained and costs exactly one MongoDB upsert:

curl -i -X POST 'http://localhost:9000/reports/ingest' \
--header 'Authorization: Bearer rpk_87077831a3c0c3f5a3cca1b1a5441e36033550708e92b832166d8550ba847315' \
--header 'X-Device-ID: 7f3c9a2e-1b4d-4c8a-9f12-abc123def456' \
--header 'Content-Type: application/json' \
--data '{
"application": { "name": "test", "version": "1.4.2", "channel": "stable" },
"system": { "platform": "windows", "arch": "amd64" },
"event": { "type": "update_failure", "reason": "checksum_mismatch" }
}'
{ "status": "accepted", "group_hash": "9f2b...", "stored_details": false }

Every field except details is required: application.name/version/channel, system.platform/arch, and event.type/reason. The server validates name, channel, platform, arch, and version with the same validators telemetry ingestion uses — and verifies the report key actually belongs to application.name, so you can't accidentally point one app's key at another.

Event type is a strict enum

Clients cannot invent event types. New types require explicit server support, which keeps analytics consistent across SDKs:

ValueMeaning
crashApplication crashed at runtime
startup_failureApplication failed to start
update_failureAn update failed to apply
install_failureAn install failed
rollback_failureA rollback failed

Reason is an identifier, not a message

event.reason is a short machine-readable identifier matching ^[a-zA-Z0-9._-]{1,128}$ — used for grouping, filtering, and alerting. It must not contain stack traces, HTML, logs, or binary data. Good values: checksum_mismatch, disk_full, access_denied, missing_dependency, panic_nil_pointer, signature_verification_failed.

The split matters: reason drives grouping and charts; human-readable debugging context goes into optional details, which never affects grouping. Unlike event.type, SDKs can introduce new reasons without a server deploy — only new event types need server support.


Optional details: bounded, private, debug-only

When you need more than a reason, attach a details blob. It's the JSON debug object, gzip-compressed then base64-encoded:

PAYLOAD=$(printf '{"message":"sha mismatch","stack":"..."}' | gzip | base64 -w0)

curl -i -X POST 'http://localhost:9000/reports/ingest' \
--header 'Authorization: Bearer rpk_8707...7315' \
--header 'X-Device-ID: 7f3c9a2e-1b4d-4c8a-9f12-abc123def456' \
--header 'Content-Type: application/json' \
--data "{
\"application\": { \"name\": \"test\", \"version\": \"1.4.2\", \"channel\": \"stable\" },
\"system\": { \"platform\": \"windows\", \"arch\": \"amd64\" },
\"event\": { \"type\": \"crash\", \"reason\": \"panic_nil_pointer\" },
\"details\": { \"encoding\": \"gzip+base64\", \"content_type\": \"application/json\", \"payload\": \"$PAYLOAD\" }
}"
{ "status": "accepted", "group_hash": "1ab3...", "stored_details": true }

The server checks the compressed size, base64-decodes, then decompresses the gzip stream under a hard decompressed-size limit (REPORTS_MAX_DETAILS_DECOMPRESSED_BYTES) enforced with io.LimitReader. The client-declared size is never trusted — that's the zip-bomb guard. The compressed bytes land in the private bucket; metadata goes to report_blobs.

stored_details is true only when the blob was actually written. On a storage outage the response is still 202, the base count is preserved, and stats.detailsRejected is incremented — the fact of the failure is never lost just because the debug payload couldn't be stored.

Storage stays bounded: faynoSync keeps the latest REPORTS_MAX_BLOBS_PER_GROUP blobs per group, and each blob has a TTL of REPORTS_BLOB_RETENTION_DAYS. No unbounded arrays in Mongo, no uncontrolled blob growth.


How grouping works

Every report is reduced to a deterministic groupHash built only from stable dimensions:

sha256(name | version | channel | platform | arch | event.type | event.reason)

Stack traces, logs, timestamps, client IP, and device identifiers are excluded from the hash. That's the whole point: if details affected grouping, every unique stack trace would spawn its own group and the signal would drown in noise. Instead, ten thousand clients hitting the same checksum_mismatch on 1.4.2/stable/windows/amd64 collapse into one group with count: 10000.

Because app names are unique only per (app_name, owner), the grouping identity stored in Mongo is the composite (app_id, groupHash) — two different owners can both have an app named test without ever seeing each other's reports.


Reading rollout health

Admins and team users query aggregated groups, sorted by most-recently-seen:

curl -s 'http://localhost:9000/reports/groups?app=test&type=update_failure&from=2026-05-01T00:00:00Z&to=2026-06-01T00:00:00Z' \
--header 'Authorization: Bearer <jwt_token>'
{
"items": [
{
"group_hash": "9f2b...",
"application": { "name": "test", "version": "1.4.2", "channel": "stable" },
"system": { "platform": "windows", "arch": "amd64" },
"event": { "type": "update_failure", "reason": "checksum_mismatch" },
"stats": {
"count": 182,
"first_seen": "2026-05-20T10:00:00Z",
"last_seen": "2026-05-20T12:00:00Z",
"details_stored": 17,
"details_rejected": 3
}
}
],
"total": 1, "page": 1, "limit": 20
}

All filters are optional — app, version, channel, platform, arch, type, reason, plus RFC3339 from/to on stats.lastSeen and page/limit. The stats block is the headline: count is the blast radius, first_seen/last_seen tell you whether a failure is fresh or already winding down, and details_stored tells you how many debug blobs you can pull for investigation.

To inspect those blobs, call GET /reports/groups/:groupHash/blobs — it returns blob metadata plus a short-lived (15 min) presigned URL per blob against the private bucket. (owner and storage.bucket are never serialized in either response.)


Rate limits keep a public endpoint safe

Because ingestion is public, rate limits are mandatory. They're evaluated after groupHash is built and before the Mongo upsert, using fixed-window counters in Redis:

DimensionLimit
Per report keyREPORTS_RATE_LIMIT_PER_KEY_PER_MINUTE requests/minute (default 100)
Per X-Device-ID + groupHash1 request/hour
Per groupHash30 requests/minute

The per-device limit is scoped by groupHash, not globally per device — that's deliberate. A single device can legitimately emit a startup_failure, then an update_failure, then a crash within the same hour; only repeats of the same failure are suppressed, which is the intended dedup behavior. On a Redis error the limiter fails open and logs, since rate limiting is abuse control, not a trust boundary, and a Redis blip must not drop legitimate reports.


The privacy boundary

Reports share the anonymous X-Device-ID concept with telemetry, used here only for rate limits and dedup. Critically, X-Device-ID is never part of groupHash and never written to report_groups or report_blobs.

Allowed dimensions are purely technical: app name, version, channel, platform, arch, event type, reason, and an optional debug blob. Avoid email, username, hostname, device serials, stored IPs, and full filesystem paths that may leak usernames. Stripping secrets and personal data from details is the client/SDK's responsibility — the server does not run a redaction pipeline.


Where this is headed

Today, reports give you the data. The aggregated report_groups are also the natural input for a future rollout decision engine: pause a rollout when update_failure rate crosses a threshold for a version/channel/platform, alert on a crash spike after a release, or suggest a rollback when rollback_failure shows up. That's a later layer — the ingestion path itself never mutates rollout state.


How to try faynoSync?

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

  2. Set REPORTS_ENABLED=true (plus the REPORTS_* limits) and make sure S3_BUCKET_NAME_PRIVATE is configured: 👉 Environment Variables Overview

  3. Enable reports on an app to get its rpk_ report key, then send a failure from your client: 📡 Ingest Report

  4. Watch rollout health in the read API or dashboard: 📊 List Report Groups



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.


Telemetry Insights — Understand Your Users Better 📊

· 6 min read

Once your app is in users' hands, the obvious questions follow: How many people actually run it? Which version are they on? Which platforms should you prioritize? Shipping updates blind is guesswork — faynoSync's built-in telemetry turns those questions into data.

The best part: it's privacy-first and you already have most of the plumbing. Telemetry is collected from the same /checkVersion calls your clients already make — you just add one header. This post covers what's tracked, the critical X-Device-ID header, how to enable it, the beacon endpoint for SDK/edge flows, and how to read the data.


What is Telemetry? 🤔

Telemetry is faynoSync's built-in analytics layer that:

  • 📈 Tracks user engagement and active clients
  • 🔍 Monitors version distribution and adoption
  • 🌍 Analyzes platform and architecture usage
  • 📊 Aggregates it all into daily stats

It helps you make data-driven decisions about your application's future. Full reference: Telemetry System docs.


What You Can Track 🎯

1. Client Analytics

  • 👥 Unique clients and active users
  • 📱 Platform distribution
  • 💻 Architecture breakdown

2. Version Insights

  • 📦 Version adoption rates
  • 🎯 Clients on the latest version vs. outdated clients
  • 📈 Version distribution trends

3. Channel Analytics

  • 🎨 Channel popularity (stable, beta, nightly, alpha)
  • 📊 Per-channel client counts

How it works

Telemetry data is collected automatically when clients call the /checkVersion endpoint — as long as they send the X-Device-ID header. For each request faynoSync records the device ID, app name, platform, channel, architecture, and reported version.

The data is stored in Redis with a 30-day retention period, aggregated daily, and organized by administrator → application → date → metric. No separate database, no extra service.


The X-Device-ID header — the part that matters most

Telemetry is only collected when the client sends an X-Device-ID header. Without it, the request still works — but it won't count toward your stats. This ID is what lets faynoSync count unique devices instead of raw requests.

GET /checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=darwin&arch=arm64
X-Device-ID: 3f6c1a8e-9b2d-4c77-bc1e-2a9f0d5e7a11

Implementation guidelines

  • Generate a UUID v4 (or similar) on first launch.
  • Persist it in secure, durable storage on the device.
  • Reuse the same ID across app restarts and updates.
  • Never change it for an existing installation, or you'll double-count that user.

Keep it anonymous

The device ID must not contain any personal data:

  • ❌ No names, emails, or account identifiers
  • ❌ No IP addresses
  • ❌ No sensitive data

It's an opaque identifier and nothing more. This is what keeps faynoSync telemetry privacy-first by design — you control the ID, and a random UUID reveals nothing about the user.


How to Enable It 🔌

Add this to your .env file:

ENABLE_TELEMETRY=true

That's the server side. Then make sure your clients send X-Device-ID on their /checkVersion calls, and stats start accumulating automatically.


Collecting telemetry without checkVersion: the beacon endpoint

If your client doesn't call /checkVersion — for example, SDK or edge flows where updates come from edge infrastructure — use the dedicated /telemetry/beacon endpoint instead. It records the same data but is optimized for speed, validating requests against an in-memory snapshot rather than reading full metadata per call.

curl --location 'http://localhost:9000/telemetry/beacon?app_name=myapp&version=1.0.0&channel=stable&platform=linux&arch=amd64&owner=admin' \
--header 'X-Device-ID: 3f6c1a8e-9b2d-4c77-bc1e-2a9f0d5e7a11'
  • Requires no auth token, but all query parameters and X-Device-ID are required.
  • Returns 204 No Content on success.
  • If you already call /checkVersion, you do not need the beacon.

See Telemetry beacon and Edge and S3 Response Cache for the architecture behind it.


How to Access Insights 📊

Dashboard

Log in to your admin dashboard and open the Telemetry section to explore charts for clients, versions, platforms, and channels.

API

GET /telemetry?range=week

Available query parameters:

ParameterDescription
dateSpecific date (YYYY-MM-DD)
rangeTime range: week or month
appsFilter by application(s)
channelsFilter by channel(s)
platformsFilter by platform(s)
architecturesFilter by architecture(s)

Reading the response

The API returns aggregated stats. The summary block is where the headline numbers live:

{
"date": "2026-06-16",
"admin": "ku9n",
"summary": {
"total_requests": 1765,
"unique_clients": 1751,
"clients_using_latest_version": 167,
"clients_outdated": 1584,
"total_active_apps": 5
},
"platforms": [
{ "platform": "windows", "client_count": 229 },
{ "platform": "linux", "client_count": 215 },
{ "platform": "darwin", "client_count": 207 }
],
"channels": [
{ "channel": "stable", "client_count": 448 },
{ "channel": "beta", "client_count": 446 },
{ "channel": "nightly", "client_count": 435 }
],
"daily_stats": [
{ "date": "2026-06-15", "total_requests": 400, "unique_clients": 400, "clients_using_latest_version": 37, "clients_outdated": 363 }
]
}

What the key fields tell you:

  • unique_clients — real device count (thanks to X-Device-ID), not inflated by repeat polling.
  • clients_using_latest_version vs clients_outdated — your update adoption rate at a glance. In the example above, most clients are still on older builds — a clear signal to investigate why updates aren't landing.
  • platforms / architectures / channels — where to focus QA and build effort.
  • daily_stats — trends over time: growing user base, adoption after a release, lingering outdated clients.

The full response also includes a versions block with per-version client counts. See the complete example in the docs.


Access control & privacy 🔒

  • Per-admin isolation — each administrator sees telemetry only for their own apps.
  • Team users — can view all statistics but can only filter/sort resources they have access to; access is verified by the API.
  • Anonymous by design — identification is via the opaque X-Device-ID you generate; no PII, no IP addresses.
  • 30-day retention — data ages out automatically.

Limitations 📝

  1. Data is retained for 30 days.
  2. Statistics are only collected when X-Device-ID is provided.
  3. Team users have limited filtering capabilities.
  4. Data is aggregated daily (not real-time per request).

Best Practices 💡

  • Enable from day one — telemetry is only as good as its history; you can't backfill.
  • Generate device IDs correctly — UUID v4, persistent, never reused or mutated.
  • Review trends weekly — watch adoption after each release and chase down outdated-client spikes.
  • Let the data drive the roadmap — prioritize the platforms and channels your users actually run.

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. Set ENABLE_TELEMETRY=true and send X-Device-ID from your clients.

  4. Open the Telemetry dashboard and start collecting insights. 📊



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 💚


How to Setup Auto Update for Electron App

· 10 min read

Shipping an Electron app is easy. Keeping every user on the latest version is the hard part. Unlike apps on the App Store or Google Play, a desktop app you distribute yourself has no built-in update channel — so you have to build one.

This guide shows how to add automatic updates to an Electron app using faynoSync, a self-hosted, open-source update server. We'll cover two approaches:

  • A custom update flow — you call faynoSync's checkVersion API yourself and control the UX end to end.
  • Native electron-updater integration — faynoSync speaks the electron-builder protocol, so the standard updater works out of the box.

We'll also cover the parts most tutorials skip: code signing, critical updates, changelogs, and what to do when updates silently don't work.


Two ways to auto-update an Electron app

Custom flowelectron-updater (electron-builder)
Control over UXFullLimited to autoUpdater events
Install handlingYou implement itAutomatic (download + relaunch)
Code signing requiredNo (for download links)Yes (macOS/Windows)
Best forCustom installers, portable appsStandard packaged apps (.dmg/.nsis/.AppImage)
faynoSync supportmanual updaterelectron-builder updater

If you just want the app to update itself with minimal code, go with electron-updater. If you need full control over the download and install step, use the custom flow. Both talk to the same faynoSync instance.


Prerequisites 📋

Before we begin, make sure you have:

  • A running faynoSync instance — see Getting Started
  • Published versions 0.0.1 and 0.0.2 of your app
  • Created an app in faynoSync named "myapp"
  • Created a channel in faynoSync named "nightly"
  • Created a platform in faynoSync named (darwin/linux/windows)
  • Created an architecture in faynoSync named (amd64/arm64)

Approach A: Custom update flow

This approach gives you complete control: you ask faynoSync whether an update exists and decide how to present it. Under the hood it uses the default manual updater, which returns plain JSON with direct download URLs.

1. Initialize Your Project

First, let's create a new project with the following package.json:

{
"name": "myapp",
"version": "0.0.1",
"description": "Hello world app for testing faynoSync",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"keywords": [],
"author": "Example Author",
"license": "ISC",
"dependencies": {
"node-fetch": "2.6.9"
},
"devDependencies": {
"electron": ">=23.3.13"
},
"engines": {
"npm": ">=8.19.3",
"node": ">=18.13.0"
}
}

2. Create Basic Files

index.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title id="title"></title>
</head>
<body>
<h1 id="label">Hello, world!</h1>
</body>
</html>

config.js

const packageJson = require('./package.json');

module.exports = {
app_name: packageJson.name,
version: packageJson.version,
channel: "nightly",
owner: "example",
};

The Magic of Auto-Updates ✨

Let's create our main.js file step by step:

1. Import Required Modules

const { app, BrowserWindow, dialog, shell } = require('electron');
const fetch = require('node-fetch');
const os = require('os');
const { version, app_name, channel, owner } = require('./config.js');
const fs = require('fs');

2. Platform Detection

function getLinuxDistributionFamily() {
let distroFamily = 'Linux';
try {
const releaseInfo = fs.readFileSync('/etc/os-release', 'utf8');
const match = releaseInfo.match(/^ID(?:_LIKE)?=(.*)$/m);
if (match) {
const idLike = match[1].trim().toLowerCase();
if (idLike.includes('rhel') || idLike.includes('fedora') || idLike.includes('centos')) {
distroFamily = 'RHEL';
} else if (idLike.includes('debian') || idLike.includes('ubuntu') || idLike.includes('kali')) {
distroFamily = 'Debian';
}
}
} catch (err) {
console.error('Error getting Linux distribution family:', err);
}
return distroFamily;
}

3. Update Choice Window

function createChoiceWindow(updateOptions) {
const win = new BrowserWindow({
width: 600,
height: 400,
webPreferences: {
nodeIntegration: true,
},
});

win.loadURL(`data:text/html,
<html>
<body>
<h2>Choose an update package:</h2>
<ul>
${updateOptions
.map(
(option, index) =>
`<li><a id="option-${index}" href="${option.url}">${option.name}</a></li>`
)
.join('')}
</ul>
<script>
const { shell } = require('electron');
document.addEventListener('click', (event) => {
if (event.target.tagName === 'A') {
event.preventDefault();
shell.openExternal(event.target.href);
}
});
</script>
</body>
</html>`
);

return win;
}

4. Update Check Function

function checkUpdates() {
let url = `http://localhost:9000/checkVersion?app_name=${app_name}&version=${version}&platform=${os.platform()}&arch=${os.arch()}&owner=${owner}`;

if (channel !== undefined) {
url += `&channel=${channel}`;
}

fetch(url, { method: 'GET' })
.then((res) => res.json())
.then((data) => {
console.log(data);
if (data.update_available) {
const message = data.critical
? `A critical update is available. Please update now.`
: `You have an older version. Would you like to update your app?`;
dialog.showMessageBox({
type: 'question',
title: data.critical ? 'Critical update' : 'Update available',
message: message,
detail: data.changelog || undefined,
buttons: ['Yes', 'No'],
defaultId: 0,
}).then(({ response }) => {
if (response === 0) {
const updateOptions = [];
for (const key in data) {
if (key.startsWith('update_url_')) {
updateOptions.push({ name: key.substring(11).toUpperCase(), url: data[key] });
}
}
const choiceWindow = createChoiceWindow(updateOptions);
}
});
}
})
.catch(() => {});
}

Notice we now read two extra fields from the response: critical and changelog. faynoSync returns these automatically — critical lets you force the prompt, and changelog shows users what changed. We'll cover them in detail below.

5. Main Window Creation

function createWindow() {
let osName = os.platform();
let pcArch = os.arch();
if (osName === 'linux') {
osName = getLinuxDistributionFamily();
}
const title = `${app_name} - v${version} (${osName}-${pcArch})`;

let win = new BrowserWindow({
width: 400,
height: 300,
webPreferences: {
nodeIntegration: true,
},
});

win.setTitle(title);
win.loadFile('index.html');
win.on('closed', () => {
win = null;
});

checkUpdates();
}

app.whenReady().then(createWindow);

Running Your App 🏃‍♂️

To start your app, simply run:

npm start

If a newer version exists, you'll see something like this in your logs:

{
"critical": false,
"update_available": true,
"update_url_dmg": "http://localhost:9010/cb-faynosync-s3-public/myapp-example/nightly/darwin/arm64/myapp-0.0.2.0.dmg"
}

And in your app's UI, you'll see a notification about the available update. After agreeing, the user downloads and installs the new version.


Approach B: Native electron-updater integration

Most production Electron apps are packaged with electron-builder and use its electron-updater module. The good news: faynoSync ships an electron-builder updater that returns the exact YAML feed (latest.yml, latest-mac.yml, latest-linux.yml) the updater expects — so you don't need GitHub Releases or an S3 generic provider.

1. Upload builds for the electron-builder updater

When uploading, pass the updater parameter so faynoSync isolates the artifacts and generates the matching *.yml:

curl -X POST --location 'http://localhost:9000/upload' \
--header 'Authorization: Bearer <jwt_token>' \
--form 'file=@"/path/to/myapp-0.0.2.dmg"' \
--form 'file=@"/path/to/latest-mac.yml"' \
--form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.2\",\"channel\":\"nightly\",\"publish\":true,\"platform\":\"darwin\",\"arch\":\"arm64\",\"updater\":\"electron-builder\"}"'

2. Point electron-updater at faynoSync

In your app, configure the feed to use the generic provider and faynoSync's electron-builder endpoint:

const { autoUpdater } = require('electron-updater');

autoUpdater.setFeedURL({
provider: 'generic',
url: 'http://localhost:9000/checkVersion?app_name=myapp&channel=nightly&platform=darwin&arch=arm64&owner=example&updater=electron-builder',
});

faynoSync responds with electron-builder-compatible YAML:

version: 0.0.2
files:
- url: myapp-0.0.2.dmg
sha512: <sha512_hash>
size: <file_size>
path: myapp-0.0.2.dmg
sha512: <sha512_hash>
releaseDate: '2026-01-15T10:00:00.000Z'

See the Updaters Support doc for the full list of supported updaters (squirrel_windows, squirrel_darwin, tauri, and more).

3. Wire up the autoUpdater events

const { app, dialog } = require('electron');
const { autoUpdater } = require('electron-updater');

app.whenReady().then(() => {
autoUpdater.checkForUpdates();
});

autoUpdater.on('update-available', (info) => {
console.log('Update available:', info.version);
});

autoUpdater.on('download-progress', (progress) => {
console.log(`Downloaded ${Math.round(progress.percent)}%`);
});

autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox({
type: 'info',
title: 'Update ready',
message: 'A new version has been downloaded. Restart to apply it?',
buttons: ['Restart', 'Later'],
}).then(({ response }) => {
if (response === 0) autoUpdater.quitAndInstall();
});
});

autoUpdater.on('error', (err) => {
console.error('Update error:', err);
});

That's the whole flow: electron-updater downloads the package in the background and quitAndInstall() swaps the binary and relaunches.


Code signing and notarization

electron-updater will refuse to install unsigned updates on macOS and Windows. This is the single most common reason auto-updates "work in dev but not in production." Before shipping:

  • macOS — sign with a Developer ID certificate and notarize the app, otherwise Gatekeeper blocks it and the updater silently fails. With electron-builder, set mac.notarize and provide APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID in your build environment.
  • Windows — sign the installer with an Authenticode certificate (EV or OV). Unsigned installers trigger SmartScreen warnings and break differential updates. If you don't want to buy a traditional certificate, Microsoft's Azure Trusted Signing offers a fully managed signing service for about $9.99/month — a cheap, modern alternative for individual developers and small teams.
  • Linux — AppImage updates don't require signing, which makes Linux the easiest target to test the flow first.

The custom flow (Approach A) sidesteps the updater's signature checks because you open download links yourself — but you still owe your users signed binaries.


Critical updates, changelogs, and required intermediate builds

faynoSync's checkVersion response carries three fields that make updates smarter:

  • critical — mark a release as critical at upload time, and the API flags it here. Use it to make the update prompt non-dismissible.
  • changelog — markdown release notes returned alongside the update, so you can show "What's new" without a second request.
  • is_intermediate_required — an informational flag. When a mandatory intermediate build exists between the user's version and latest, faynoSync returns the download link for that intermediate version (not latest) and sets this field to true. The intermediate build must be installed before the latest one — it's used for migrations that can't be skipped, such as database schema changes.
{
"update_available": true,
"critical": true,
"is_intermediate_required": true,
"changelog": "### Changelog\n\n- Added feature X\n- Fixed bug Y",
"update_url_dmg": "https://<bucket>.s3.amazonaws.com/myapp/stable/darwin/amd64/myapp-0.0.5.dmg"
}

If is_intermediate_required is true, prompt the user to install the returned version, then check again — repeat until they reach latest. Read more in Required Intermediate Build.


Troubleshooting

SymptomLikely cause
update_available is always falseversion in the request matches the latest, or wrong channel/platform/arch
electron-updater says "No published versions"The *.yml feed wasn't uploaded, or updater=electron-builder was omitted on upload
Update downloads but won't install (macOS/Windows)App or installer isn't code signed / notarized
Works locally, fails in packaged appsetFeedURL points to localhost — use your public faynoSync URL in production
401 on uploadMissing or expired JWT — re-authenticate against /login

Tip: run faynoSync's /info/latest endpoint manually with curl to confirm the server returns what you expect before debugging the client.


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. Upload at least two versions of your application.

  4. Check for updates with this simple request: 📡 /info/latest



Need Help? 🤝

If you have any questions or need assistance:

  1. Check out our documentation
  2. Create an issue on GitHub

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 💚

Performance Mode — Speed Up Your API

· 6 min read

Update checks are bursty and repetitive. Every running copy of your app polls the same /checkVersion endpoint with nearly identical parameters, and each call validates the app, channel, platform, and architecture against MongoDB. At a few hundred clients that's fine. At thousands it's the same handful of database queries, repeated thousands of times a minute.

Performance Mode fixes that with caching. This post explains how it works, how to configure Redis, how cache invalidation keeps results correct, and how to stack an optional Nginx microcache on top for a multi-layer strategy.


What is Performance Mode? 🤔

Performance Mode is a Redis-backed caching layer for the API that:

  • 🚀 Reduces response times
  • 📉 Lowers database load
  • 💾 Optimizes resource usage

When enabled, faynoSync caches the result of /checkVersion in Redis and serves repeat requests straight from memory instead of re-querying MongoDB. See the Performance Mode docs for the reference.


How It Works 🛠️

                 ┌─────────────────────────────┐
client ──────▶ │ GET /checkVersion │
└──────────────┬──────────────┘

PERFORMANCE_MODE=true?

┌──────────────┴──────────────┐
▼ ▼
look up in Redis (disabled) query
│ MongoDB every time
┌────────┴────────┐
▼ ▼
cache HIT cache MISS
return from query MongoDB,
Redis (fast) store in Redis,
then return
  1. Request arrives — the API checks whether PERFORMANCE_MODE=true.
  2. Cache check — it looks for cached data in Redis for that request.
    • HIT → return immediately.
    • MISS → query MongoDB, store the result in Redis, then return.
  3. Next request — the same query is now a cache hit.

What gets cached?

Performance Mode is currently implemented for the /checkVersion endpoint — by far the most frequently called one, since every client polls it for updates.


Why Do We Need It? 🎯

  • Database load reduction — far fewer MongoDB queries for the same repeated update checks.
  • Faster response times — cached responses come from memory, no database round-trip.
  • Resource optimization — lower CPU and DB pressure means the same hardware scales further.

This matters most exactly when it's hardest: traffic spikes right after you publish a release and every client checks in at once.


How to Enable It 🔌

Enable the mode and point faynoSync at your Redis instance in .env:

PERFORMANCE_MODE=true
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
VariableDefaultDescription
PERFORMANCE_MODEfalseMaster switch for Redis caching
REDIS_HOSTlocalhostRedis hostname or IP
REDIS_PORT6379Redis port
REDIS_PASSWORD(empty)Leave empty if Redis has no auth
REDIS_DB0Redis database number

That's it — once PERFORMANCE_MODE=true and Redis is reachable, the API starts caching automatically. Full reference in the environment overview.


Cache invalidation — staying correct 🔄

A cache is only useful if it never serves stale "latest version" info. faynoSync handles this for you:

When you upload a new application with publish set to true, the Redis keys for that app are removed. The next /checkVersion request repopulates the cache with fresh data.

So the moment you publish a release, the old cached answer for that app is gone — the next client to check gets the new version, and everyone after them gets it from cache again. No manual flushing, no risk of pinning users to an old build.


Going further: add an Nginx microcache 🧱

For very high traffic you can stack a second cache layer in front of the API. Nginx can microcache /checkVersion responses for a few seconds, absorbing bursts before they ever reach faynoSync.

# In the http block
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=checkversion_cache:10m inactive=60s max_size=100m;

# In the server block
location = /checkVersion {
proxy_pass http://127.0.0.1:9000;

proxy_cache checkversion_cache;
proxy_cache_valid 200 60s;
proxy_cache_key "$scheme$request_method$host$request_uri";

# Serve stale cache if the backend is slow or erroring
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
proxy_cache_background_update on;

add_header X-Cache-Status $upstream_cache_status;
}

This gives you a two-layer strategy: Nginx absorbs identical requests for ~60s at the edge of your server, and Redis (Performance Mode) handles everything that gets through. The X-Cache-Status header tells you what happened:

  • HIT — served from Nginx
  • MISS — went to the backend
  • UPDATING — cache being refreshed in the background
  • STALE — backend was unhealthy, stale copy served

The full Nginx block (with rate limiting and proxy headers) is in the Performance Mode docs.


Measuring the difference 🧪

Don't guess — measure on your own hardware. This script fires 1000 sequential /checkVersion requests and reports timing:

start_time=$(date +%s);
for i in {1..1000}; do
response=$(curl -s -w "Connect Time: %{time_connect}s, Start Transfer Time: %{time_starttransfer}s, Total Time: %{time_total}s\n" -o /dev/stdout --location 'http://localhost:9000/checkVersion?app_name=myapp&version=0.0.3&channel=nightly&platform=null&arch=null');
echo "$response";
done;
end_time=$(date +%s);
total_time=$((end_time - start_time));
echo "Total execution time: $total_time seconds"

Run it twice — once with PERFORMANCE_MODE=false, once with true — and compare Total execution time and Start Transfer Time (time to first byte). Absolute numbers depend on your hardware, network, and Redis setup, so treat your own measurements as the source of truth.


When to use it ⚡

Use Performance Mode when:

  • You expect high or bursty traffic (especially right after publishing releases).
  • Many clients poll /checkVersion frequently.
  • Response time is critical.

It matters less when:

  • You have a small, low-traffic deployment.
  • Update checks are rare.

In production with real users, the recommendation is simple: turn it on.


Performance Mode is not the same as Edge caching. Performance Mode caches at the API layer in Redis; edge mode stores full JSON responses in object storage closer to users. They're independent — you can run one, the other, or both. If you're scaling globally, read Edge caching.


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. Upload at least two versions of your application.

  4. Check for updates with this simple request:
    📡 /info/latest



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 💚


Fetch Latest Version of App — Smart Update Links

· 6 min read

Ever needed a download link that always points to the latest version of your app — one you can put on a landing page, in a README, or inside a CI pipeline and never touch again? That's exactly what faynoSync's /apps/latest endpoint is for.

Instead of hardcoding myapp-1.2.3.dmg and updating it on every release, you point users at a stable URL and faynoSync resolves it to the newest matching build. This post walks through the endpoint with copy-paste examples for landing pages, OS auto-detection, shell scripts, and Docker.

Looking to check for updates from inside a running app (Electron, Tauri, Squirrel)? That's a different endpoint — /checkVersion. See How to Setup Auto Update for Electron App. /apps/latest is for download links; /checkVersion is for in-app update checks.


Meet Our Example App 🎮

Let's imagine we have an app called "SpaceExplorer" with the following setup:

Channels:

  • 🚀 stable - Production releases
  • 🧪 beta - Testing versions
  • 🌙 nightly - Daily builds

Platforms & Architectures:

  • 💻 Linux (amd64, arm64)
  • 🍎 macOS (amd64, arm64)
  • 🪟 Windows (amd64)

Package Types:

  • 📦 .deb / .rpm (Linux)
  • 📦 .dmg (macOS)
  • 📦 .exe (Windows)

Endpoint reference

GET /apps/latest?app_name=<app_name>&channel=stable&platform=linux&arch=amd64&owner=admin
ParameterRequiredDescription
app_nameThe name of the app to fetch
channel✅*Release channel (stable, beta, nightly)
platformPlatform (linux, darwin, windows)
archArchitecture (amd64, arm64)
packagePackage type (deb, rpm, dmg, exe)
ownerYour admin user name

* channel becomes mandatory once you've defined channels for the app. If your app has no channels configured, you can omit it.

Two behaviors make this endpoint convenient:

  • Dynamic responses — the more parameters you supply, the narrower the result. Supply only app_name + channel and you get every build in that channel; add platform/arch/package to drill down.
  • Automatic redirection — when your filters resolve to a single build, faynoSync responds with an HTTP redirect straight to the download URL. Perfect for "Download" buttons.

Let's Fetch Some Versions! 🎯

Example 1: Everything in a channel

curl --location 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=stable&owner=admin'

Returns all builds in the stable channel, grouped by platform → arch → package:

{
"stable": {
"linux": {
"amd64": {
"deb": { "url": "https://<bucket>.s3.amazonaws.com/SpaceExplorer/stable/linux/amd64/SpaceExplorer-1.2.3.deb" },
"rpm": { "url": "https://<bucket>.s3.amazonaws.com/SpaceExplorer/stable/linux/amd64/SpaceExplorer-1.2.3.rpm" }
},
"arm64": {
"deb": { "url": "..." },
"rpm": { "url": "..." }
}
},
"darwin": {
"amd64": { "dmg": { "url": "..." } },
"arm64": { "dmg": { "url": "..." } }
},
"windows": {
"amd64": { "exe": { "url": "..." } }
}
}
}

Example 2: Platform-Specific

curl --location 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&owner=admin'

Only Linux builds:

{
"stable": {
"linux": {
"amd64": {
"deb": { "url": "..." },
"rpm": { "url": "..." }
},
"arm64": {
"deb": { "url": "..." },
"rpm": { "url": "..." }
}
}
}
}

Example 3: Exact match (auto-redirect to download)

curl -L --location 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&arch=amd64&package=deb&owner=admin'

Because this resolves to a single build, faynoSync redirects straight to:

https://<bucket>.s3.amazonaws.com/SpaceExplorer/stable/linux/amd64/SpaceExplorer-1.2.3.deb

The -L flag tells curl to follow the redirect and download the file.


Smart Features 🧠

  1. Automatic Redirection 🔄

    • If only one build matches your filters, you get an HTTP redirect straight to the download.
    • Ideal for landing pages and direct-download links.
  2. Flexible Responses 🎯

    • Get all builds in a channel, or filter by platform, architecture, and package type.
  3. Stable URLs, moving targets 🎯

    • The link never changes; the file it resolves to advances with every release.

Real-World Use Cases 🌍

1. Landing page download buttons

<!-- For Linux users -->
<a href="https://updates.yourdomain.com/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&arch=amd64&package=deb&owner=admin">
Download for Linux (.deb)
</a>

<!-- For macOS users -->
<a href="https://updates.yourdomain.com/apps/latest?app_name=SpaceExplorer&channel=stable&platform=darwin&arch=arm64&package=dmg&owner=admin">
Download for macOS (.dmg)
</a>

2. One "Download" button that auto-detects the OS

Detect the visitor's platform in the browser and build the right link on the fly:

<a id="download" href="#">Download SpaceExplorer</a>

<script>
const base = 'https://updates.yourdomain.com/apps/latest';
const ua = navigator.userAgent;
let platform = 'linux', arch = 'amd64', pkg = 'deb';

if (ua.includes('Mac')) { platform = 'darwin'; arch = 'arm64'; pkg = 'dmg'; }
else if (ua.includes('Win')) { platform = 'windows'; arch = 'amd64'; pkg = 'exe'; }

const params = new URLSearchParams({
app_name: 'SpaceExplorer', channel: 'stable', platform, arch, package: pkg, owner: 'admin',
});
document.getElementById('download').href = `${base}?${params}`;
</script>

3. Install the latest build in a shell script

#!/usr/bin/env bash
set -euo pipefail

URL="https://updates.yourdomain.com/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&arch=amd64&package=deb&owner=admin"

curl -L "$URL" -o spaceexplorer.deb
sudo dpkg -i spaceexplorer.deb

4. Always pull the newest build in CI / Docker

FROM ubuntu:24.04
ARG FAYNOSYNC=https://updates.yourdomain.com/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&arch=amd64&package=deb&owner=admin
RUN apt-get update && apt-get install -y curl \
&& curl -L "$FAYNOSYNC" -o /tmp/app.deb \
&& dpkg -i /tmp/app.deb || apt-get -f install -y

5. Beta and nightly channels for testers

# Beta testers
curl -L 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=beta&platform=darwin&arch=arm64&package=dmg&owner=admin'

# Nightly build enthusiasts
curl -L 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=nightly&platform=linux&arch=amd64&package=deb&owner=admin'

Notes and gotchas

  • Channel is required once defined. If your app has channels, every /apps/latest request must include channel — otherwise the request is rejected.
  • owner is mandatory for access control; it must be your admin user name.
  • Package types are auto-detected from what's actually uploaded — you only get a dmg/deb/exe key if such a file exists for that platform/arch.
  • Redirect only fires for a single match. If your filters still leave multiple builds (e.g. both .deb and .rpm), you get JSON instead of a redirect — add package to force a direct download.
  • Use HTTPS in production and put faynoSync behind your own domain (e.g. updates.yourdomain.com) so download links stay stable and trusted.

What's Next? 🚀


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. Upload at least two versions of your application.

  4. Build a smart download link with /apps/latest.


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 💚


Local Development with faynoSync — Choose Your Path

· 3 min read

Getting started with faynoSync development? Great! You have two main options for setting up your local environment. Whether you prefer a traditional local setup or the convenience of Docker, we've got you covered.


Option 1: Traditional Local Development 🖥️

Perfect for developers who want full control over their environment and need to make frequent code changes.

What You'll Need:

Setting Up:

  1. Install all required services
  2. Create your .env file with necessary configurations
  3. Run faynoSync:
    # First run (with migrations)
    go run faynoSync.go migrate up

    # Subsequent runs
    go run faynoSync.go

Benefits:

  • ⚡ Fast development cycle
  • 🔧 Direct access to all components
  • 🧪 Easy debugging
  • 🔄 Quick code changes

Testing Your Setup:

Want to make sure everything is working correctly? Just run:

go test

This will verify that all components are properly configured and your local environment is ready for development.


Option 2: Docker Development 🐳

Ideal for quick setup and testing, or when you want to avoid installing dependencies locally.

What You'll Need:

Two Ways to Use Docker:

1. Full Container Setup

docker compose up --build

Perfect for:

  • 🚀 Quick testing
  • 🧪 Initial setup
  • 🔄 Testing API functionality

2. Hybrid Approach

docker compose -f docker-compose.yaml -f docker-compose.development.yaml up

This starts only the dependencies (MongoDB and MinIO) while you run faynoSync locally.

Benefits:

  • 🚀 Quick setup
  • 🧩 Isolated environment
  • 🔄 Consistent across machines
  • 🧪 Easy testing

Testing Your Setup:

After running docker compose up --build, wait until the s3-service successfully creates the bucket, then run:

docker exec -it faynoSync_backend "/usr/bin/faynoSync_tests"

This will run the test suite inside the container to verify everything is working correctly.


Environment Configuration ⚙️

Both approaches need proper environment configuration. Here are the key variables you'll need:

# Storage Configuration
STORAGE_DRIVER=minio
S3_ACCESS_KEY=your_access_key
S3_SECRET_KEY=your_secret_key
S3_BUCKET_NAME=your_bucket

# Database Configuration
MONGODB_URL=mongodb://root:password@127.0.0.1/faynosync_db

# API Configuration
API_URL=http://localhost:9000
PORT=9000

For a complete list of environment variables, check out our Environment Configuration Guide.


Which Option Should You Choose? 🤔

Choose Local Development if you:

  • 🔧 Need to modify the code frequently
  • 🐛 Want to debug easily
  • ⚡ Need fast development cycles
  • 💻 Prefer direct control over your environment

Choose Docker if you:

  • 🚀 Want quick setup
  • 🧪 Need to test the API
  • 💻 Work on multiple machines
  • 🧩 Prefer isolated environments

What's Next? 🚀

In our next posts, we'll explore:

  • How to properly use Fetch latest version of app request
  • Performance optimization tips

Stay tuned for more faynoSync tips and tricks! 💚


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. Upload at least two versions of your application.

  4. Check for updates with this simple request:
    📡 /info/latest


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


Team-Based Authorization in faynoSync — Manage Your Team Like a Pro

· 8 min read

Managing a team of developers? Need to control who can do what in your faynoSync instance? Starting from version 1.4.0, faynoSync introduces a powerful Team Based Authorization Matrix that gives you complete control over your team's access and permissions.

This is role-based access control (RBAC) built for an update server: one administrator owns an isolated set of team users, each team user gets granular permissions per resource type, and no administrator can ever see another's data. This post walks through the model end to end, with copy-paste API calls verified against the Team Based Authorization docs.


What is Team-Based Authorization? 🤔

Think of it as your team's digital headquarters! The Team Based Authorization Matrix is a sophisticated system that lets you:

  • 👨‍💼 Create and manage team users
  • 🔐 Assign specific permissions
  • 🏢 Keep data isolated between different teams
  • 📊 Track who can do what

Every team user belongs to exactly one administrator, and everything a team user creates is automatically owned by that administrator. There is no shared global namespace — isolation is the default, not an add-on.


Meet the Players 🎭

The Administrator 👑

  • You're the boss! Each admin is unique
  • You own your team of users
  • You can create, update, and delete your team members
  • You have full control over passwords and permissions
  • You can only access your own team's resources

The Team User 👤

  • A unique member of your team
  • Belongs only to you (their admin)
  • Can't manage other users — the entire user-management API is off-limits to them
  • Can only work with resources you've allowed
  • Everything they create belongs to you

When a team user with the Create permission creates a resource, that resource is automatically added to their list of allowed resources — so they can immediately work with what they just made, and nothing else.


What Can Your Team Do? 🛠️

Each team member can have different permissions for different types of resources:

Resource Types:

  • 📱 Applications (apps)
  • 📊 Channels (channels)
  • 💻 Platforms (platforms)
  • 🏗️ Architectures (archs)

Available Permissions:

  • ➕ Create
  • ✏️ Edit
  • 🗑️ Delete
  • ⬆️ Upload (apps only)
  • ⬇️ Download (apps only)

Permissions reference

upload and download apply only to apps; channels, platforms, and architectures expose just create/edit/delete. Each resource block also carries an allowed array — the list of specific resource IDs the user may act on.

Resourcecreateeditdeleteuploaddownloadallowed
apps
channels
platforms
archs

Smart Resource Management 🧠

  • 🔒 Team users can only see and use resources they've been given access to
  • 🏢 Each admin's resources are completely separate
  • 👀 You (as admin) can see everything your team creates
  • 📝 Different teams can have resources with the same names

That last point matters in practice: two different administrators can both own an app called myapp, a stable channel, or a linux platform without any collision — names are unique only within an administrator's scope, never globally.


The owner query parameter

Because resource names are no longer globally unique, the public read endpoints need to know whose myapp you mean. Two endpoints now require an owner query parameter — the administrator's account name:

  • GET /checkVersion
  • GET /apps/latest
curl 'http://localhost:9000/checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=darwin&arch=arm64&owner=admin'

If you build update-check URLs in your client or CI, make sure owner is part of them — otherwise the server can't resolve the right scope. See Fetch Latest Version of App for how these links are constructed.


How to Manage Your Team? 🎯

Option 1: Using the API

Creating a Team User

POST /user/create takes a username, a password, and a full permissions object. Here's a complete, valid request — teamuser1 can create and edit apps and download their artifacts, but can't delete or upload:

curl -X POST 'http://localhost:9000/user/create' \
-H 'Authorization: Bearer <jwt_token>' \
-H 'Content-Type: application/json' \
-d '{
"username": "teamuser1",
"password": "password123",
"permissions": {
"apps": { "create": true, "edit": true, "delete": false, "upload": false, "download": true, "allowed": [""] },
"channels": { "create": true, "edit": true, "delete": false, "allowed": [""] },
"platforms": { "create": true, "edit": true, "delete": false, "allowed": [""] },
"archs": { "create": true, "edit": true, "delete": false, "allowed": [""] }
}
}'
{ "message": "Team user created successfully" }

The allowed array holds the resource IDs the user can act on. Leaving it empty means "no pre-granted resources" — but with create: true, anything the user creates is added to allowed automatically.

Updating Permissions

POST /user/update replaces the user's credentials and permission set. Here we revoke create on apps but grant delete and upload, and widen the allowed list:

curl -X POST 'http://localhost:9000/user/update' \
-H 'Authorization: Bearer <jwt_token>' \
-H 'Content-Type: application/json' \
-d '{
"username": "teamuser1",
"password": "password1234",
"permissions": {
"apps": { "create": false, "edit": true, "delete": true, "upload": true, "download": true, "allowed": ["<app_id_1>", "<app_id_2>"] },
"channels": { "create": true, "edit": true, "delete": true, "allowed": [""] },
"platforms": { "create": true, "edit": true, "delete": false, "allowed": [""] },
"archs": { "create": true, "edit": true, "delete": false, "allowed": [""] }
}
}'
{ "message": "Team user updated successfully" }

Permission and password changes take effect immediately. Full reference: Create User and Update User.

Option 2: Using the Web Dashboard 🖥️

Prefer a more visual approach? The faynoSync dashboard makes team management a breeze!

For Administrators:

  1. Click the Settings button in the dashboard
  2. A modal window will open where you can:
    • 👥 Create new team users
    • 🔑 Set passwords (or auto-generate them)
    • ✅ Assign permissions with simple checkboxes
    • ✏️ Update usernames and passwords
    • 🗑️ Remove team users

For Team Users:

  1. Go to your Profile section
  2. View your current permissions at a glance:
    • 📱 Which applications you can access
    • 📊 What actions you can perform
    • 🔒 What resources are available to you

This visual interface makes it much easier to:

  • 🎯 Understand your permissions
  • 🔄 Manage team access
  • 📊 Track resource availability
  • ⚡ Make quick changes

Expected behavior at a glance

ActionTeam UserAdministrator
Create resources✅ (within admin scope)
Edit resources✅ (only own admin's)
Delete resources✅ (only own admin's)
Manage users
Access user API
Access other admins' data

How team permissions reach the rest of faynoSync

The same apps.download permission and the team user's allowed apps gate more than artifact downloads. The report ingestion read APIGET /reports/groups — is also gated by CheckPermission(download, apps), so a team user only sees rollout-health reports for apps in their allowed_apps. The same scoping idea applies across telemetry views: team users can read stats but only filter resources they have access to. One permission model, enforced everywhere.


Common questions

A team user can't see an app they should have access to. Check the allowed array for apps — visibility is opt-in. Either add the app's ID to allowed via /user/update, or have the user create the resource themselves (which auto-adds it).

Two teams need an app with the same name. That's fully supported — names are unique per administrator, not globally. Just remember to pass owner on /checkVersion and /apps/latest.

A team user got a 403 on user management. Expected. The entire user API is admin-only; team users cannot create, edit, or delete users, including themselves.


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. Upload at least two versions of your application.

  4. Check for updates with this simple request:
    📡 /info/latest



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


Private Apps in faynoSync — Keep Your Software Secure

· 6 min read

When developing software, sometimes you need to keep things private. Whether it's internal tools, beta versions, or enterprise applications — not everything should be publicly accessible. That's why faynoSync comes with built-in support for private applications.

This post covers what private apps are, how to create one, how downloads are gated, and how private apps combine with team access control. Every flag and endpoint here is verified against the Create Application and private download docs.


What are Private Apps? 🤔

Private apps in faynoSync are applications that are stored in a separate, private S3 bucket. This gives you an extra layer of security and control over who can access your software.

Once an app is marked as private, it stays private forever — this is a one-way decision to ensure consistency and security.


How to Create a Private App? 🛠️

There are two ways to create a private app:

1. Using the Web Dashboard 🖥️

Simply check the "Private" option when creating your app through the dashboard interface. It's that simple!

2. Using the API 📡

When making a POST request to /app/create, include the private parameter. The endpoint uses multipart/form-data, so the JSON goes in a data form field:

curl --location 'http://localhost:9000/app/create' \
--header 'Authorization: Bearer <jwt_token>' \
--form 'data="{\"app\":\"appName\", \"private\": true}"'
{ "createAppResult.Created": "641459ffb8760d74164e7e3c" }

The private field sits alongside the other creation flags — description, a logo file, tuf, and cdn (the edge cache toggle). Only app is required.

Heads up: private is irreversible. Once it's true, the app cannot be made public later. Decide before you create, not after.


How Private Apps Work? 🔐

  1. Storage: Private apps are stored in a separate S3 bucket (defined by S3_BUCKET_NAME_PRIVATE in your environment)
  2. Access Control: You control who can download private apps through the ENABLE_PRIVATE_APP_DOWNLOADING setting:
    • If true: apps in the private bucket can be downloaded through the public API — the request redirects straight to the file
    • If false: download links require authentication, and the API returns a short-lived signed URL instead

Both settings live in your .env; see the Environment Variables Overview for the full storage configuration (the private bucket is configured per provider — MinIO, AWS, DigitalOcean Spaces, or GCS).


Downloading from a private bucket

Artifacts in the private bucket aren't served by a plain public URL. You fetch them through GET /download, passing the object key:

curl -X GET --location 'http://localhost:9000/download?key=secondapp%2Fstable%2Flinux%2Famd64%2Fsecondapp-0.0.1.deb'

The key is URL-encoded and follows the layout {app_name}/{channel}/{platform}/{arch}/{filename}. What comes back depends on ENABLE_PRIVATE_APP_DOWNLOADING:

  • true — the request is redirected directly to the file.
  • false — authentication is required, and the response is a JSON download_url: a signed URL with a limited lifetime (typically 15 minutes).
{
"download_url": "https://<bucket>.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.1.deb?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=..."
}

Short-lived signed URLs are the key property: even if a link leaks, it expires in minutes, so it can't be reshared as a permanent public download. Full contract in the download docs.


Security Features 🛡️

Think of private apps in faynoSync as your software's VIP section! Here's what makes them special:

  • 🔒 Separate Storage: Your private apps live in their own secure S3 bucket, like having a private vault for your most valuable assets
  • 🔐 One-Way Privacy: Once you mark an app as private, it stays private forever. This might sound strict, but it's actually a good thing — it ensures your security settings can't be accidentally changed
  • 👥 Smart Access Control: You're in charge! You can decide whether your private apps need authentication to download, giving you perfect control over who gets access
  • 🚫 Download Protection: Want to make sure only your team can download the app? Just set ENABLE_PRIVATE_APP_DOWNLOADING=false, and only authenticated requests will get a signed URL

Private apps and team access control

Privacy and team-based authorization stack cleanly. The private bucket decides where artifacts live and whether downloads need authentication; team permissions decide who on your team can manage and download them via the apps.download permission and their allowed apps. Use ENABLE_PRIVATE_APP_DOWNLOADING=false plus scoped team users when you need both restricted distribution and internal least-privilege access.

The same private bucket (S3_BUCKET_NAME_PRIVATE) is reused by report ingestion for storing debug blobs — sensitive payloads never touch the public/CDN bucket, and are only retrievable through the same short-lived presigned-URL mechanism.


Best Practices 💡

  1. Use private apps for:

    • Internal tools and utilities
    • Beta versions of your software
    • Enterprise-specific applications
    • Software requiring license validation
  2. Decide on private before creating the app — it can't be undone.

  3. Set ENABLE_PRIVATE_APP_DOWNLOADING=false when you need strict access control, and always send an auth token when fetching from /download in that mode.

  4. Treat signed URLs as ephemeral — generate them on demand rather than caching or sharing them; they expire by design.


Common questions

Can I make a private app public later? No. The private flag is irreversible by design. Create a new public app instead.

Why does /download return a URL instead of the file? You're running with ENABLE_PRIVATE_APP_DOWNLOADING=false, so the API hands back a short-lived signed URL that requires authentication — that's the strict mode.

Where do private artifacts actually live? In the bucket named by S3_BUCKET_NAME_PRIVATE, separate from your public artifact bucket, on whichever provider your STORAGE_DRIVER points to.


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. Upload at least two versions of your application.

  4. Check for updates with this simple request:
    📡 /info/latest



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


Self-Hosted API for Automatic App Updates

· 3 min read

In today's fast-paced world of AI and digitalization, more and more people, companies, and startups are creating their own applications. Depending on the type of app, you might publish it on platforms like Google Play, the App Store, and so on. These services not only handle publishing but also provide automatic versioned updates to end users.

But there's a small catch.

Bureaucracy. Registrations. The 9 circles of hell you have to go through before your app finally goes live on those platforms.
Just think — where do you actually download apps like Discord, Brave Browser, or Docker Desktop?
Exactly. The list goes on.

That's where faynoSync comes in.


What is faynoSync?

faynoSync is a lightweight, open-source API server that gives you full control over application updates. Whether it's desktop software, mobile apps, browser extensions, or custom binaries for metrics or monitoring — faynoSync helps you deliver updates easily, securely, and reliably.

No external dependencies. Just pure control.


Why faynoSync?

Sure, each programming language or OS has its own update tools. But they're often non-transparent, overly complicated, or just a pain to integrate.

faynoSync is a universal solution — it allows you to check for updates from any language, with a centralized API and minimal setup.


What can faynoSync do?

  • 🔓 Create public or private applications
  • 🔐 Support signed and unsigned builds (perfect for development environments)
  • 👥 Manage team users and assign fine-grained permissions
  • 🚀 Publish, roll back, or deactivate specific releases
  • ⚠️ Mark applications as critical to notify users immediately
  • 🌍 Manage channels (dev, prod, nightly, stable), platforms, and architectures
  • 📝 Add, edit, and display changelogs so users know what's new
  • ⚡ Built with Go — fast, simple, and open-source

And it's only the start; there will be more in the future.


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. Upload at least two versions of your application.

  4. Check for updates with this simple request:
    📡 /info/latest


This article is just an introduction. In the next posts, we'll explore the full functionality of faynoSync, integration examples, and development tips.

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