Skip to main content
3Nsofts logo3Nsofts
iOS Architecture

CloudKit Private vs. Shared Database: Architecture Decisions for Multi-User iOS Apps

How to choose between CloudKit private, shared, and public databases for multi-user iOS apps, including CKShare, participant roles, NSPersistentCloudKitContainer stores, and conflict behavior.

By Ehsan Azish · 3NSOFTS·June 2026·12 min read

Multi-user iOS apps built on CloudKit face a structural decision early in architecture: which database container type handles which data, and what happens when those boundaries need to flex.

The wrong answer is not "use the shared database for shared data." That is the obvious answer. The problem: it collapses a nuanced access model into a binary, and apps built on that assumption accumulate conflict resolution debt that surfaces at scale.

This article covers the CKDatabase architecture decision in depth — the private, shared, and public containers, when each is the correct choice, and how NSPersistentCloudKitContainer exposes these distinctions at the Core Data layer.


The Three CloudKit Database Containers

CloudKit exposes three distinct database scopes within a CKContainer:

  • Private database (CKContainer.default().privateCloudDatabase) — data owned by the authenticated iCloud user. Only that user reads and writes it. Counts against the user's iCloud storage quota.
  • Shared database (CKContainer.default().sharedCloudDatabase) — data the owning user has explicitly shared with specific other users. The owner controls the share; participants have defined roles.
  • Public database (CKContainer.default().publicCloudDatabase) — data readable by any app user, writable only by the record owner. Counts against the app developer's CloudKit quota, not the user's.

Each container has a distinct access model, quota source, and conflict surface. Treating them as interchangeable is the design premise that causes problems.


The Private Database: Default Starting Point

The private database is the correct default for any data that belongs to a single user. Notes, preferences, personal records, app state — all of it lives here.

NSPersistentCloudKitContainer mirrors the private database automatically when you configure an NSPersistentStoreDescription with cloudKitContainerOptions. The sync is one-to-one: local Core Data records map to CKRecord instances in the user's private database.

let storeDescription = NSPersistentStoreDescription(url: privateStoreURL)
storeDescription.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
    containerIdentifier: "iCloud.com.yourapp.container"
)

The private database requires no share configuration. It works without explicit CKShare objects. No overhead, no participant management, no permission negotiation — this is the right architecture for single-user data.

The constraint that shapes every decision from here: the private database cannot be read by any other user, ever. If two users need to see the same record, the private database is the wrong container.


The Shared Database: Explicit Collaboration Model

The shared database does not store records directly. It stores CKShare objects that reference records in the owner's private database. Participants access those records through the share.

This is the architecture most developers misread. The shared data still lives in the owner's private database. The shared database holds the metadata that grants other users access to it.

CKShare and Participant Roles

A CKShare carries a list of participants, each with a defined role:

  • CKShare.ParticipantRole.owner — the creating user
  • CKShare.ParticipantRole.privateUser — a named iCloud user added explicitly
  • CKShare.ParticipantRole.publicUser — anyone with the share URL

Each participant also has a permission level: readOnly or readWrite. These are set on the CKShare.Participant object before saving the share.

let share = CKShare(rootRecord: rootRecord)
share[CKShare.SystemFieldKey.title] = "Project Alpha" as CKRecordValue
share.publicPermission = .readOnly

The owner saves the CKShare to their private database. CloudKit then makes the referenced records visible to participants through the shared database scope.

Accepting Shares in the App

Participants accept shares via UIApplicationDelegate.application(_:userDidAcceptCloudKitShareWith:) or the equivalent SceneDelegate method. The app must handle this callback and call CKContainer.accept(_:completionHandler:) to register the participant.

NSPersistentCloudKitContainer handles the shared database through a second NSPersistentStoreDescription configured with .shared as the database scope:

let sharedStoreDescription = NSPersistentStoreDescription(url: sharedStoreURL)
let sharedOptions = NSPersistentCloudKitContainerOptions(
    containerIdentifier: "iCloud.com.yourapp.container"
)
sharedOptions.databaseScope = .shared
sharedStoreDescription.cloudKitContainerOptions = sharedOptions

Both stores — private and shared — coexist in the same NSPersistentCloudKitContainer instance. Queries against the shared store return only records the current user has been granted access to through an accepted share.


Conflict Resolution in the Shared Database

The shared database introduces write conflicts the private database never sees. Two participants with readWrite permission can modify the same record concurrently. CloudKit uses a last-write-wins policy at the CKRecord field level — not at the record level.

Field-level conflicts resolve silently. If participant A writes to record.title and participant B writes to record.status simultaneously, both writes survive. If both write to record.title, the later server timestamp wins.

NSPersistentCloudKitContainer surfaces these conflicts through NSMergeByPropertyObjectTrumpMergePolicy by default. For most apps, this is acceptable. For apps where concurrent writes to the same field are structurally likely — collaborative editing, shared task lists, inventory systems — a custom merge policy is required before shipping.

The constraint that shaped the architecture in the 3Nsofts warehouse case study: multiple warehouse users writing dispatch records concurrently. The merge policy was designed before the first line of feature code was written, not after the first production conflict appeared.


The Public Database: Rarely the Right Choice for Multi-User Apps

The public database is readable by any authenticated CloudKit user without a share invitation. It is the correct container for app-wide reference data: configuration records, feature flags, shared content libraries.

It is not the correct container for user-generated collaborative data. The access model is too coarse — any user can read any record. Write access is limited to the record's creator, which means a true multi-user collaboration model on the public database requires a server-side intermediary.

The public database counts against the app developer's CloudKit quota, not the user's. For high-read reference data with low write frequency, that is an acceptable trade. For per-user operational data, it is not.


Choosing the Right Container: The Decision Matrix

| Data Type | Owner | Participants | Container | |---|---|---|---| | User preferences, private notes | Single user | None | Private | | Shared project, family list | One user shares with named others | Defined set | Shared (via CKShare) | | App-wide reference data | App developer | All users (read) | Public | | Collaborative document | One owner, multiple editors | Named, with write permission | Shared (readWrite participants) |

The decision is not about how many users touch the data. It is about who owns the record and how access is granted.


NSPersistentCloudKitContainer: Two Stores, One Container

The production architecture for a multi-user app using NSPersistentCloudKitContainer requires two persistent stores:

  1. A private store mapped to .private database scope
  2. A shared store mapped to .shared database scope

Both stores live in the same NSPersistentCloudKitContainer instance. The app queries each store independently. Moving a record from the private store to the shared database requires creating a CKShare, saving it, and accepting it — there is no direct migration path through Core Data alone.

The private store holds per-user data. The shared store holds operational data that participants access through accepted shares. The distinction is not incidental — it is the design premise.

For teams building this architecture from the start, the SwiftUI architecture guide covers how the data layer integrates with the view hierarchy without leaking CloudKit concerns into the UI layer.


Sharing UI: CloudKit's Built-In Components

CloudKit provides UICloudSharingController for managing shares without building custom UI. It handles participant invitations, permission changes, and share deletion. The controller requires a CKShare and a CKContainer reference.

For apps targeting iOS 16 and later, ShareLink combined with a custom Transferable implementation is the SwiftUI-native path. The underlying CKShare management still happens at the CloudKit layer — ShareLink handles the presentation, not the share lifecycle.


Offline Behavior and Sync Guarantees

The shared database syncs on the same schedule as the private database — background fetch, push notifications via CKSubscription, and foreground sync when the app becomes active.

Offline writes to shared records queue locally and sync when connectivity returns. The merge policy applies at sync time, not at write time. This means an offline participant can write a value that gets silently overwritten on reconnect, if another participant modified the same field while they were offline.

The correct architecture for offline-first collaborative apps acknowledges this explicitly. Writes that must survive conflict use append-only data structures — activity logs, event records, timestamped entries — rather than mutable fields. When the data model never requires two users to write to the same field, the field-level last-write-wins policy becomes irrelevant.

This is the same principle that drives local-first architecture more broadly. The AI-native iOS app architecture checklist covers offline-first data design as a first-class requirement, not an afterthought.


CloudKit Entitlements and Container Configuration

Every CloudKit-enabled app requires:

  • com.apple.developer.icloud-services entitlement including CloudKit
  • com.apple.developer.icloud-container-identifiers listing each container identifier
  • A matching container registered in the Apple Developer portal

The container identifier format is iCloud. followed by a reverse-domain string. The identifier must match exactly between the entitlement, the NSPersistentCloudKitContainerOptions initializer, and the Developer portal registration. A mismatch produces a silent sync failure — no crash, no error surfaced to the user, no data syncing.

Xcode Doctor, the macOS diagnostic tool built at 3Nsofts, catches this class of configuration error through static analysis before a build reaches the App Store. The tool runs 9 specialized checks in under 2 seconds, including entitlement-to-container-identifier consistency.


Schema Initialization and initializeCloudKitSchema()

NSPersistentCloudKitContainer requires that the CloudKit schema matches the Core Data model. In development, calling container.initializeCloudKitSchema(options: [.printSchema]) pushes the schema to the CloudKit Development environment.

Promoting to production requires a manual schema promotion step in CloudKit Console. The production schema is immutable — record types and fields cannot be deleted after promotion. This is not a limitation to work around. It is a constraint that shapes the data model design: get the schema right before promotion, because the rollback path is a new container.


When to Use CloudKit vs. a Custom Backend

CloudKit is the correct choice when:

  • The app targets Apple platforms exclusively
  • Data ownership maps cleanly to iCloud accounts
  • The share model fits the CKShare participant structure
  • Zero server infrastructure cost is a hard requirement

A custom backend is the correct choice when:

  • Cross-platform access (web, Android) is required
  • The access model requires server-side business logic
  • Fine-grained audit logging is a compliance requirement
  • The collaboration model involves more than 100 concurrent participants per record

The CloudKit shared database handles the collaboration use case for most consumer and small-team apps without a server. The constraint that makes it the wrong choice is usually cross-platform access, not scale.


FAQs

What is the difference between the CloudKit private database and the shared database?

The private database stores records owned by the authenticated iCloud user — only that user can read or write them. The shared database holds CKShare objects that grant named participants access to records still stored in the owner's private database. The shared database is an access layer, not a separate storage location.

Can two users write to the same CloudKit record simultaneously?

Yes, if both have readWrite permission on the CKShare. CloudKit resolves concurrent writes using last-write-wins at the field level. Two participants writing to different fields on the same record both succeed. Two participants writing to the same field produce a silent overwrite based on server timestamp.

How does NSPersistentCloudKitContainer handle the shared database?

Configure a second NSPersistentStoreDescription with databaseScope set to .shared on the NSPersistentCloudKitContainerOptions. Both the private and shared stores live in the same container instance. The app queries each store independently.

Does the shared database work offline?

Writes to shared records queue locally when offline and sync when connectivity returns. The merge policy applies at sync time. An offline participant's writes can be silently overwritten if another participant modified the same field in the interim.

When should I use the public CloudKit database instead of the shared database?

Use the public database for app-wide reference data that all users should read without an invitation — configuration records, feature flags, shared content libraries. It has no per-user access control beyond record ownership, which makes it the wrong container for user-generated collaborative data.

What happens if the CloudKit container identifier in the entitlement does not match the one in NSPersistentCloudKitContainerOptions?

Sync fails silently. No crash, no error surfaced to the user. The app writes locally but nothing reaches CloudKit. This is one of the configuration errors that static analysis tools like Xcode Doctor catch before the build reaches the App Store.

Can I move a record from the private store to the shared database without creating a CKShare?

No. The record stays in the owner's private database. Sharing it requires creating a CKShare referencing the record, saving the share, and having participants accept it. There is no direct migration path through Core Data alone.

Work With Me

The iOS Architecture Audit reviews data-layer structure, sync strategy, App Store compliance, and on-device AI readiness, then delivers a written recommendations report in 5 business days.

Related

References