- backend
- cloudkit
- infrastructure
Why CloudKit is amazing and why we're leaving it
CloudKit is one of the best-kept secrets in the Apple platform stack. For years it has quietly powered sync, storage, and sharing for our apps — for free, with zero servers to run, and with end-to-end encryption we didn’t have to design ourselves. And yet, we’re moving off it.
What we love about CloudKit
- Free at our scale. Apple gives every app a generous storage and traffic allowance tied to the user’s iCloud account, not to us. We’ve never paid a cent for infrastructure.
- End-to-end encryption for free. Private databases are encrypted with keys we never see. We didn’t have to design a key-management system or convince users to trust us with their data.
- No servers to run. No deploys, no on-call, no scaling drama. The SDK talks to Apple, Apple handles the rest.
- Authentication is automatic. The user’s iCloud account on the device is the identity CloudKit talks to. There’s no signup flow, no password reset emails, no account recovery tickets.
- Sharing primitives built in.
CKSharegives us multi-user collaboration without us inventing an invitation system.
For an indie team like ours, this is enormous. CloudKit let us ship real apps with real sync without ever standing up a backend.
So why are we leaving?
After years of shipping on it, a handful of specific frictions piled up. Not theory — bugs we’d filed and lived with. Here are the ones that finally moved us.
We can’t see what the server sees
When a user’s data won’t sync, we have no view into what happened on Apple’s side. We’ve spent years bolting telemetry onto NSPersistentCloudKitContainer.eventChangedNotification just to find out why a save failed — and even with that, we’re guessing from client-side error codes. There are no server logs we can pull, no admin view into the user’s zone.
A favourite example: a user’s app icon and name once went missing in iCloud, and the resolution was a months-long email thread with Apple while they investigated (FB20684746). When CloudKit breaks, our only debugger is Radar.
Schema deploys are a separate manual step
“Deploy schema to production” is a button in the CloudKit dashboard, not part of our app’s release pipeline. Last summer I forgot to press it and Meal Plans silently stopped syncing for everyone — we didn’t notice until users reported it. Across the apps we ship, “switch to production schema” has its own little graveyard of commits.
Each Apple target brought its own bugs
CloudKit is supposed to “just work” across Apple platforms. In practice every target has been its own debugging project: macOS only synced on app restart for a while, Apple Watch silently stopped syncing because a user hadn’t accepted a new iCloud ToS — a failure mode we couldn’t even surface to them — and one of our entitlement bugs was reported to us by Apple. AppleTV sync is still flaky in user reports today.
Sharing requires an iCloud account on the sender side
We have open bugs going back to 2024 about this: to create a CKShare, the sender has to be signed in to iCloud. If they aren’t, sharing simply doesn’t work — even though the recipient could open the resulting URL just fine. Our workaround is to check for an iCloud account up front and hide the feature entirely for users without one, which is the opposite of what sharing is for.
Five OSes, five “not signed in to iCloud” UIs
iCloud signed-out, iCloud full, family-sharing edge cases — CloudKit hands all of this to the client. We’ve built distinct account-state UI for iOS, macOS, watchOS, tvOS, and visionOS, with localizations for each. “Warn the user when their iCloud is full” has been an open ticket of ours since 2025 because we can’t reliably detect it.
Images don’t fit in a Core Data row
Core Data + CloudKit stores binary attributes inline, which ballooned our meal-plan storage to a critical level. We ended up writing our own “upload to a public zone, store the identifier, lazy-download on demand” pipeline — basically reinventing object storage.
Our users aren’t all on Apple devices
This is the one that actually moved the needle. People want to share a recipe with a friend on Android. They want to peek at their lists in a browser without installing anything. With CloudKit, none of that is possible — not “hard,” impossible.
What we’re moving to
We’ve already done this migration once. Lysten moved to Supabase in January, and Ambre is now replaying the same playbook.
The stack
The new sync stack is just Supabase Postgres behind the official supabase-swift SDK, talked to directly from the app — no API tier in between. Each table is scoped by userId, and Supabase Realtime is subscribed on Postgres change events to nudge the client to re-sync. Auth is email + OTP via Supabase Auth (no Sign in with Apple, for now). The local store is still Core Data; a single SyncEngine actor coalesces overlapping sync calls into “one in-flight, one queued,” with last-write-wins conflict resolution keyed on a server-stamped serverUpdatedAt cursor.
What got better
- Real telemetry. We added Mixpanel events for every stage of sync —
Sync Started,Sync Succeededwith duration and per-stage record counts,Sync Failedwith the stage that broke. That class of visibility was effectively impossible against CloudKit. And withmixpanel mcpwired up, we can point Claude straight at those events when a user reports a sync issue — what used to be a multi-hour investigation is now a one-line prompt. - Whole bug classes evaporated. Our duplicate-cleanup code now carries comments like “this can still occur on iCloud, but is impossible with Supabase” — uniqueness is a Postgres constraint instead of a hope. Same story for orphaned rows.
- The cutover was staged, not a flag day. Existing CloudKit data was uploaded to Supabase on first launch via an
uploadedToSupabaseAtflag on each Core Data row, and reads switched to Supabase only after that completed. Users moved across without doing anything. And because we never delete the data from iCloud, the original copy is still there as a safety net — if anything ever went wrong with the migration, we could dig into the iCloud-side data to recover it. No users have reported losing anything. - Web is finally on the table. We haven’t built a web client yet, but for the first time it’s something we could build without rewriting the sync layer. With CloudKit it wasn’t a tradeoff, it was a wall.
- Anyone can share, not just iCloud users. The CKShare bug we mentioned earlier exists because creating a share requires the sender to be signed in to iCloud — so users of our app without an iCloud account can’t share at all. On a Postgres-backed stack, sharing is just a row with a URL: any user of our app can create one, regardless of which account they’re signed into.
What we gave up
None of this came free. The list at the top of this post is real, and Supabase doesn’t replace all of it.
- Authentication isn’t automatic anymore. With CloudKit, the user’s iCloud account on the device was the auth — they opened the app and they were signed in. Now we ask new users to sign in explicitly (email + OTP today). We own account recovery, deletion, and the support burden that comes with all of it.
- We pay for hosting now. The Supabase project URL is hardcoded into the app — its uptime is our problem, not Apple’s.
- End-to-end encryption is gone. Data lands in plain Postgres rows readable with the service key. CloudKit’s private database was at least E2E for the iCloud account; this isn’t.
- There’s an ongoing operational tail. Several post-migration PRs are work we wouldn’t have written against CloudKit — threading bugs around the new sync path, user identification across Mixpanel/Sentry on auth-state changes, orphan-download cleanup. Owning the stack means owning its edges.
Closing thoughts
None of this is a takedown. For years CloudKit let a two-person team ship sync, sharing, and end-to-end encryption across iOS, macOS, watchOS, tvOS, and visionOS with zero infrastructure and zero servers to operate. That’s a deal you can’t get anywhere else, and we’d take it again for the right product.
CloudKit is perfect for an app that’s single-user, Apple-only, with no realistic need for a web surface, no cross-user queries, and a willingness to absorb the occasional sync mystery as a cost of doing business. If that describes what you’re building, it will save you years of work. Genuinely — start there.
Our apps grew out of that shape. Users want to share with friends who don’t own iPhones. We want to see what the server saw when sync goes wrong. We want a web page. The longer we stayed, the more our codebase filled up with workarounds for things a normal database hands you for free. So now we pay for hosting, we own auth, and we run a backend — and in exchange we got our roadmap back. That’s a trade we’d make every time.
Written by
César Pinto Castillo