Zero-Login SwiftData + CloudKit: A Private Diary App#
A personal diary is probably the worst possible category for a sign-up screen. The user opens the app to write about the most vulnerable day of their month, and the first thing they see is "Create an account," "Choose a password," "Verify your email." That's not a hypothesis — that is exactly the step where I would lose part of the audience for Lanternly, a diary app currently in development as a Day One competitor.
The solution I landed on is zero-login: the app has no concept of an "account" at all. The profile lives locally, and sync between iPhone and iPad happens through the private CloudKit database of whatever iCloud account is already signed in on the device. No login screen, no password, no server to check that password against. This article covers the architecture, the constraints SwiftData imposes when paired with CloudKit, and how zero-login cascades into product decisions like export and import.
Why zero-login, not just "Sign in with Apple"#
The first question colleagues asked me was: "Sign in with Apple is one button — why complicate things?" The difference is fundamental. Even the lightest authentication implies an account — an entity that has to be registered somewhere, linked to data, handled when the iCloud ID changes, and supported in tickets ("I can't log in," "my data disappeared after switching phones").
Zero-login removes the entire category of problem: no account means no login, no forgotten password, no migration on device change, because "device" and "user identity" are never separated in the first place. CloudKit already knows who owns the data — it's whoever is signed into iCloud on that device. I don't need to re-solve a problem Apple already solved at the OS level.
This choice wasn't free. It means Lanternly has no "sign in with a different iCloud account and see your data" path — only an explicit transfer. For a diary, where the data is already privacy-sensitive by nature, I consider that a reasonable trade-off rather than a missing feature.
Architecture: SwiftData locally, CloudKit as the sync transport#
SwiftData (the Core Data successor announced at WWDC23) gives you local storage out of the box, and a ModelConfiguration with a cloudKitDatabase parameter turns it into offline-first sync without writing a single line of server code:
import SwiftData
enum JournalStore {
static let cloudContainerID = "iCloud.com.yourteam.yourapp"
static let schema = Schema([
JournalEntry.self,
JournalAttachment.self,
Profile.self,
])
/// Test/CI builds pass `-localStoreOnly` to skip CloudKit entirely —
/// there is no entitlement to sign against in CI, and simulator
/// screenshots would otherwise crash trying to reach a private database.
private static var localStoreOnly: Bool {
#if DEBUG
CommandLine.arguments.contains("-localStoreOnly")
#else
false
#endif
}
@MainActor
static func makeContainer() -> ModelContainer {
guard !localStoreOnly else {
return makeLocalContainer()
}
let cloudConfig = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private(cloudContainerID)
)
// CloudKit can be unavailable for reasons that have nothing to do
// with the schema: no iCloud sign-in, restricted account, missing
// entitlement in a dev build. Falling back to a local-only store
// keeps the app usable instead of crashing on first launch.
if let container = try? ModelContainer(for: schema, configurations: cloudConfig) {
return container
}
return makeLocalContainer()
}
@MainActor
private static func makeLocalContainer() -> ModelContainer {
let localConfig = ModelConfiguration(schema: schema, cloudKitDatabase: .none)
guard let container = try? ModelContainer(for: schema, configurations: localConfig) else {
fatalError("Could not create local ModelContainer: schema is invalid")
}
return container
}
}Notice -localStoreOnly: it's not a theoretical flag. CloudKit is unavailable in CI and on simulator screenshots — there's no signed entitlement, and sometimes no live iCloud account either. Without an explicit local fallback, the app crashes on the very first test-runtime launch rather than in production — which is worse, because it disguises a real problem as "tests are flaky."
What CloudKit compatibility actually forces you to rewrite in @Model#
This is the less pleasant part of a zero-login architecture. CloudKit imposes hard requirements on the schema, and SwiftData can't work around them — it either silently disables an incompatible feature or fails at container startup. Three concrete constraints I hit before writing a single line of sync code:
- No unique constraints.
@Attribute(.unique)works in a purely local SwiftData store, but becomes unavailable the moment a configuration attaches CloudKit — because CloudKit cannot atomically enforce uniqueness across devices that may be offline at any given moment. - Every non-optional attribute must have a default value. A schema with a required field and no default fails at container startup rather than degrading gracefully.
- Every relationship must be optional — including to-many relationships you'd normally make non-optional with a default empty array in a purely local app.
import SwiftData
@Model
final class JournalEntry {
var id: UUID = UUID()
var createdAt: Date = Date.now
var text: String = ""
var mood: Int?
var tags: [String] = []
// CloudKit requires every relationship to be optional — even
// to-many ones you would normally default to an empty array.
@Relationship(deleteRule: .cascade, inverse: \JournalAttachment.entry)
var attachments: [JournalAttachment]? = []
// No @Attribute(.unique) here: CloudKit cannot enforce atomic
// uniqueness across devices, so SwiftData disables it the moment
// a CloudKit-backed configuration is used. Uniqueness, if you need
// it, becomes an application-level check instead of a schema one.
init(id: UUID = UUID(), text: String = "", mood: Int? = nil) {
self.id = id
self.text = text
self.mood = mood
}
}The practical takeaway: business invariants that used to live at the schema level (a unique id, a required field) move into application-level validation instead. This isn't a CloudKit bug — it's a consequence of sync being distributed: atomic uniqueness checks over the network between offline devices are physically impossible without a central arbiter, and a central arbiter is exactly the server a zero-login architecture is trying to avoid.
CloudKit's private database: what "your own iCloud" actually means#
It's important not to oversimplify the claim "the data lives in the user's iCloud, so it's E2E out of the box" — that's not quite accurate, and I'd rather state it honestly than as a marketing slogan.
CloudKit's private database (.private(cloudContainerID)) physically places data in the individual user's own iCloud storage — it never passes through a server I control. Every record is encrypted with keys generated on the user's trusted device before anything is uploaded. But full end-to-end encryption, where even Apple has no access to the keys, only kicks in once the user turns on Advanced Data Protection in their iCloud settings — that's an opt-in setting, not the default behavior. Without it, Apple can technically hand over data under a lawful request; with it, Apple physically doesn't hold the keys to hand over.
For Lanternly, this means being honest in the privacy description rather than promising "military-grade encryption" without the caveat. CloudKit's private database is substantially better than a server I fully control myself, but it isn't zero-knowledge by default — and a developer who claims otherwise is misleading users.
Unlocked export and Day One import: how zero-login shapes product decisions#
Zero-login isn't just a sync architecture choice — it's a constraint that cascades through the entire product. If there's no server-side account, there's no "cloud" export through a backend API either — export has to be a local, on-device operation, where the data physically already lives.
For Lanternly, that turned into a concrete product decision: printing the diary into a book (PDF and EPUB) is generated entirely on-device, with no text uploaded to a third-party rendering service. Just as important from a product standpoint: unlocked export — no "only 10 pages free" premium gate, no subscription requirement to get your own data back out in an open format. The data belongs to the user, not to a subscription.
Importing from Day One follows the same logic: the app reads the competitor's local export and moves entries into its own on-device SwiftData model — again with no intermediary server temporarily holding someone else's personal entries during migration.
Your own backend vs. Firebase vs. CloudKit + SwiftData#
| Criterion | Your own backend | Firebase | CloudKit + SwiftData |
|---|---|---|---|
| User registration | Your own (email/OAuth) — required | Firebase Auth — required | Not needed: identity = signed into iCloud on-device |
| Where data physically lives | Your server/database | Google's servers | User's private iCloud database |
| E2E encryption by default | Depends on your implementation | No (data readable on Google's server) | Not by default; enabled by the user's Advanced Data Protection |
| Infrastructure and DevOps | Full developer responsibility | Managed, but scales with cost | None — an app container, not a server |
| Cross-platform reach | Any | iOS/Android/Web | Apple ecosystem only |
| Offline-first out of the box | Must be built manually | Partial Firestore offline cache | Built into SwiftData + ModelConfiguration |
| Schema constraints | None (your own database) | Flexible NoSQL schema | No unique constraints; optional/default required |
Conclusion and a pre-release checklist#
Zero-login on SwiftData and CloudKit isn't a universal recipe — it's a deliberate trade-off: giving up cross-platform reach outside Apple and some schema flexibility in exchange for no server, no login screen, and data that physically belongs to the user. For an app like a personal diary, living entirely within the Apple ecosystem, that trade-off doesn't read to the user as a limitation — it reads as a promise: "only you see these entries."
Before shipping this architecture to production, it's worth clearing a short checklist:
- The SwiftData schema is CloudKit-compatible: no
@Attribute(.unique), every non-optional field has a default, every relationship is optional - There's an explicit local fallback when CloudKit is unavailable (no iCloud sign-in, restricted account, dev build without an entitlement)
- Test/CI builds use a flag like
-localStoreOnlyinstead of reaching for a real CloudKit container - The privacy description honestly states when full E2E kicks in (Advanced Data Protection), rather than implying it by default
- Data export is a local, on-device operation that doesn't require an intermediary server
- Product gates (paywalls, limits) never block a user's access to their own data
For more on conflict resolution and change subscriptions in CloudKit, see "CloudKit without a server: offline-first sync in production", where I cover the same architecture applied to MeteoHealth's health data.



