August 15, 2026 · 11 min read
CloudKit Attachment Migration: The Server Holds a Manifest, Not Bytes
Attachment bytes moved from our server into each account’s iCloud, leaving the server with a manifest. Two decisions were withdrawn and two defects were fixed during verification.
I had been storing attachments on our server. The cost increased as the number of users grew.
I measured it again on 2026-08-10.
| What | Bytes | After migration |
|---|---|---|
| Attachment ciphertext | 93,345,511 | To the user's iCloud |
| Record ciphertext | 1,362,433 | Remains on our server |
| Objects remaining before migration | 212.3MB | To be cleaned up |
Attachments accounted for 98.6% of the billable bytes. I did not move records. The server had to synchronize them, and they were small.
I decided to move them. The server would not hold the bytes, only where they were located. The bytes would be in the user's iCloud.
I removed the file field from the server schema
One migration changed the entire structure.
// platform/backend/migrations/0063_attachments_become_manifests.go
// attachments.file (FileField, B2) → 제거
// attachments.cloud_record_name (Text 100) → 추가
// users.cloudkit_user_hash (Text 64) → 추가
cloud_record_name is a pointer to a CloudKit record. The server does not interpret this value. The app writes it and the app reads it.
cloudkit_user_hash is a hash of the iCloud user identifier from the device that uploaded the first file. I explain why this was necessary below.
The basis for this decision was written in twenty-five lines of migration comments. Two of those lines explained why I did not leave the file field behind.
// migrations/0063_attachments_become_manifests.go
// `file` goes with them. A FileField nothing writes would keep the S3 wiring
// load-bearing and leave the 212.3 MB of pre-cutover objects looking live.
Leaving a field that nobody writes creates two consequences. The S3 wiring remains something that appears necessary, and the 212.3MB accumulated before the migration appears to be live data. I removed the unused field.
I did not make it required
I could have made cloud_record_name a required field. I did not.
I had learned something from an earlier migration. When I made the storage-capacity column required, PocketBase rejected the zero value for that field. That blocked cases where 0 bytes was a valid value.
This value was the same. The handler validated it anyway. Adding another check at the schema level would block valid cases.
The endpoint stopped accepting bytes
The upload changed from multipart to JSON.
이전: upload endpoint (multipart, 파일 바이트)
이후: upload endpoint (JSON manifest)I deleted the download endpoint.
single download endpoint → 라우트와 핸들러 모두 제거Bytes now came only from iCloud. Since they were not on the server, the server could not provide them.
I checked after deployment.
single download endpoint → 404Previously, it returned 401. That meant authentication was required. Now it returned 404. That meant the route was gone.
I kept idempotency unchanged. I determined duplicates using the file-content hash. Uploading the same file twice still produced one record.
I bound one iCloud account to each account
The user proposed “one iCloud account per ID.” During the investigation, I found stronger grounds for it.
Without the binding, attachments became scattered.
A device uploaded five files using Apple ID X. The device switched to Y. The next five went to Y's iCloud.
The server manifest contained ten files. Neither device could read more than half of them.
Worse, there was no reason recorded anywhere in the data. The user only saw that files would not open. The app did not know why they would not open either.
When the first file was uploaded, I hashed the iCloud user identifier from that device and stored it on the account. If another iCloud account arrived later, its hash differed from the stored hash.
I added a unique index to that hash later
I touched the same column again two migrations later. The value I had created to bind attachments was now also used for identity determination.
A guest user with no account used this hash to find or create an account. Without a unique index, that find-or-create was subject to a race condition. If two devices belonging to the same person were turned on at the same time, each could create an account, leaving that person with two accounts containing half of their records each.
However, I could not simply make it unique.
// migrations/0065_users_cloudkit_hash_unique.go
users.AddIndex(
"idx_users_cloudkit_user_hash", true, "`cloudkit_user_hash`",
"`cloudkit_user_hash` != ''") // 부분 인덱스
An account registered by email had an empty string in this field until its first attachment was uploaded. SQLite counted an empty string as a value as well. If I added an unconditional unique index, only one such account could exist.
WHERE cloudkit_user_hash != '' avoided that. Uniqueness was required only for accounts that actually claimed an iCloud identity.
The same column came to serve two purposes, and the second purpose required a constraint that the first did not. Duplicates were not a problem when binding attachments, but they were a problem when determining identity.
The order in which I touched the same column was as follows.
| Migration | What it did | Why |
|---|---|---|
| 0062 | Made tiers.quota_bytes required |
Learned that the zero value was rejected |
| 0063 | Added cloudkit_user_hash, not required |
Bound attachments to accounts |
| 0065 | Added a partial unique index | Prevented races in guest identity determination |
The first row explains the second. I had already paid the cost of making a field required in an earlier migration, so I did not do it this time. The third row addressed the shortcoming of the second. The contract for one column was defined across three migrations.
I rejected one of the user's proposals
The user proposed prompting the user to sign up for another account when a mismatch was detected. I rejected it.
I considered someone using a personal iPhone (Apple ID X) and a company iPad (Apple ID Y).
Prompting them to create a new account would not only split their attachments. It would split all of their records.
Our server handled record synchronization. It was unrelated to iCloud. It was working normally on both devices. Giving that up would have caused greater harm.
I narrowed the scope of the adopted approach.
| On the mismatched device | |
|---|---|
| Attachments | Local-only (not synchronized) |
| Records and summaries | Continue synchronizing |
| App display | State the reason explicitly |
I blocked only attachments. Everything else continued to work as before.
I withdrew one of my statements
During the investigation, I wrote this sentence:
Users who signed up by email are unrelated to Apple
It was wrong.
The CloudKit container used the iCloud account signed in on the device. It did not depend on how the user signed in to the app.
An email-registered user also used the iCloud account on the device if one was signed in. The binding applied to every user.
I set the upload order
Uploading one file took two steps. I put the bytes in iCloud, then put the manifest on the server.
Only one order was correct.
올바름: blob put → manifest PUT
잘못됨: manifest PUT → blob putReversing the order opened a window. There could be a manifest with no blob.
If another device synchronized during that window, it would see that the file existed and try to retrieve it. It would not be there.
Putting the bytes first created the opposite state. There could be a blob with no manifest. That was safe. Since nobody knew about the file, nothing happened.
I fixed the record name to the local UUID
I needed to give the CloudKit record a name. I used the attachment's local UUID.
Creating a new one each time caused a problem.
I assumed the upload response was lost. The app treated the upload as failed and retried it. If it created a new record name, iCloud would contain two copies of the same file.
Nobody would know about the first one. Orphaned blobs would accumulate in the user's iCloud.
Using the local UUID made retries write to the same location. They overwrote the existing record.
Two defects appeared during validation
After deploying and running it in practice, I found two things that were wrong.
Deletion received 404 and retried forever
This happened after I reset the data. Two old items remained, but they no longer existed on the server.
When the app tried to delete one, it received 404. The app treated that as a failure and retried. The next synchronization also received 404.
The deletion queue was serial. If it blocked at the front, nothing behind it could proceed.
Every deletion after that was blocked as well. Whatever the user deleted, it was not deleted from the server.
The fix was one line. I treated 404 as success.
If there was nothing left to delete, the objective had been achieved. After redeployment, there were zero 404s.
The iCloud blob remained after deletion
The server record was deleted, but the bytes in iCloud remained.
The cause was that the deletion queue carried only the server row ID. It did not know where the blob was.
If I missed this, our garbage would remain permanently in the user's iCloud. The user would delete it in the app, but their iCloud storage would not decrease.
I added the CloudKit record name to the deletion-queue item and wired the callback from storage to the app.
I withdrew one more decision
During this work, I removed the recovery-key backup UI. This was something I had initially opposed.
Only the user had the encryption key. If they lost it, recovery was impossible. That was why I had added the backup UI.
When the user proposed removing it, my basis for opposing the change was:
A user with iCloud disabled could accumulate records on the server and be unable to find only the key
After checking the user's data model, I learned that this basis did not hold.
| State | Server synchronization | Key required |
|---|---|---|
| Guest, no iCloud | None (fully local) | Not required |
| Guest, iCloud available | 100 temporary stored items | Required |
| Signed in | Normal | Required |
In this model, iCloud was a prerequisite for server synchronization.
Without iCloud, no records were created on the server. If there was nothing to recover, the key was not needed either.
This was a state my reasoning had failed to account for. I withdrew it and removed the UI.
The automatic path remained. When issuing a key, the app quietly stored it in the iCloud Keychain and quietly restored it after a reinstall or on a new device. Only the UI visible to the user disappeared.
I found that the documentation and code disagreed
Two comments in the same file said opposite things.
// InstallIdentity.swift:5
// reinstalling the app is the boundary that changes identity
// InstallIdentity.swift:66
// Keychain은 앱 삭제와 무관하게 남는다
Line 5 said that deleting the app changed the identity. Line 66 said it did not.
Line 66 was correct and line 5 was wrong. Keychain items did not disappear when the app was deleted. That was how iOS worked.
Leaving this unchanged would make the product promise false. I had said, “Deleting the app removes temporary records,” but in reality they remained.
The fix was to place a liveness marker in the app container. If only the Keychain item remained without the marker, the app treated it as a reinstall.
The reason nobody noticed that the two comments contradicted each other was that the code worked even if both were assumed to be correct.
I cleaned up the old data
After the migration was complete, I deleted the bytes remaining on the server.
| Target | Before | After |
|---|---|---|
| Number of B2 objects | 197 | 1 |
| B2 size | 94,381,308B deleted | — |
| PVC | 286.6MB | 75.5MB |
The one remaining object was an asset for shared links. It used a different path from attachments.
The PVC cleanup encountered a guard. rm -rf was blocked, so I used find -type f -delete followed by rmdir. The recursive-deletion protection on the production volume was working as intended.
The deployment also did not follow the normal path. The GitHub Actions workflow was manually disabled, so CI did not run. I built directly with docker buildx, pushed the image to the registry, and rolled it out.