{"id":28378300,"url":"https://github.com/fry69/bluesky-migration-guide","last_synced_at":"2026-02-14T18:32:24.417Z","repository":{"id":293313765,"uuid":"983626024","full_name":"fry69/bluesky-migration-guide","owner":"fry69","description":"Notes on migrating a Bluesky account ","archived":false,"fork":false,"pushed_at":"2025-10-07T20:25:06.000Z","size":67,"stargazers_count":1,"open_issues_count":0,"forks_count":1,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-10-07T20:29:30.789Z","etag":null,"topics":["atproto","atproto-pds","atprotocol","documentation","self-hosted"],"latest_commit_sha":null,"homepage":"","language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/fry69.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-05-14T17:02:00.000Z","updated_at":"2025-10-07T20:25:09.000Z","dependencies_parsed_at":"2025-05-14T18:59:00.654Z","dependency_job_id":null,"html_url":"https://github.com/fry69/bluesky-migration-guide","commit_stats":null,"previous_names":["fry69/bluesky-migration-guide"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/fry69/bluesky-migration-guide","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fry69%2Fbluesky-migration-guide","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fry69%2Fbluesky-migration-guide/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fry69%2Fbluesky-migration-guide/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fry69%2Fbluesky-migration-guide/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/fry69","download_url":"https://codeload.github.com/fry69/bluesky-migration-guide/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fry69%2Fbluesky-migration-guide/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29452371,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-14T15:52:44.973Z","status":"ssl_error","status_checked_at":"2026-02-14T15:52:11.208Z","response_time":53,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["atproto","atproto-pds","atprotocol","documentation","self-hosted"],"created_at":"2025-05-30T02:06:11.367Z","updated_at":"2026-02-14T18:32:24.411Z","avatar_url":"https://github.com/fry69.png","language":"Shell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# So You Want to Migrate...\n\nIf you have an account on a Bluesky mushroom PDS (`\u003cmushroom\u003e.*.bsky.network`) and want to migrate to another PDS (e.g., your own), this can currently be a daunting task. As of this writing, there are a few web-based migration tools available, but even with those you might end up in a situation where you have to resort to a manual migration process.\n\nHere is a writeup of my experience moving my account to my own PDS. At that point, it was already quite full. Here are some numbers for illustration:\n- ~10k posts\n- ~800 blobs, some of them short videos\n- A bit under 1 GB in total size\n\nThis led to some problems while trying to migrate with the recommended automatic migration function in the [goat](https://github.com/bluesky-social/goat) command-line tool. For posterity, I'll describe what I did, the problems I encountered, and how to work around them with explanations of what is happening.\n\n---\n\nFirst here are some useful links:\n\nWeb-based migration tools:\n- [ATP Airport](https://atpairport.com/) - \"Your terminal for seamless AT Protocol PDS migration and backup\" by [Roscoe Rubin-Rottenberg](https://bsky.app/profile/knotbin.com)\n- [PDS Moover](https://pdsmoover.com/) - Cow-themed migration tool by [Bailey Townsend](https://bsky.app/profile/baileytownsend.dev)\n- [Tektite](https://tektite.cc/) - Tektite Migration Service by [Blacksky](https://bsky.app/profile/tektite.cc)\n\nGeneral documentation:\n- [PDS Self-hosting](https://atproto.com/guides/self-hosting) - Official PDS self-host instructions\n- [GitHub PDS](https://github.com/bluesky-social/pds) - Official PDS reference implementation (Docker) with installation instructions\n- [Migration instructions](https://whtwnd.com/did:plc:44ybard66vv44zksje25o7dz/3l5ii332pf32u) by [Bryan Newbold](https://bsky.app/profile/bnewbold.net)\n- [Adding recovery keys guide](https://whtwnd.com/bnewbold.net/3lj7jmt2ct72r) also by [Bryan Newbold](https://bsky.app/profile/bnewbold.net)\n- [pdsls.dev](https://pdsls.dev) - Super handy tool to inspect basically everything in atproto by [juliet](https://bsky.app/profile/juli.ee)\n\t- For starters, just enter your handle\n\t- Feel free to click around; you cannot break anything without logging in first\n- [Handle Debugger](https://bsky-debug.app/handle) - Check if your handle is valid\n\nOptional links:\n- [GitHub PDS source](https://github.com/bluesky-social/atproto/tree/main/packages/pds) - TypeScript source code for the PDS reference implementation\n\t- [Environment variables](https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/config/env.ts) - All environment variables available for customization\n- [GitHub atproto scraper](https://github.com/mary-ext/atproto-scraping) - Scraped list of all known PDS, including self-hosted\n- [PDS directory](https://blue.mackuba.eu/directory/pdses) - Website listing known PDS instances with user counts\n- [atproto browser](https://atproto-browser.vercel.app/) - Bare-bones atproto browser\n- [atp tools](https://atp.tools/) - Another useful atproto browser/tools implementation\n\n---\n\nNow let's start. When I decided to migrate my account, I first looked at its current state with `pdsls.dev`:\n\n```\nID\n\tdid:plc:3zxgigfubnv4f47ftmqdsbal\n\nIdentities\n    at://fry69.dev\n\nServices\n    #atproto_pds\n    https://cordyceps.us-west.host.bsky.network\n\nVerification methods\n    #atprotoz Q3shgqQdc1Rr2A3dTJTAPF2Mu2Qe6BhSCyPFC4Uk5WNPkUsr\n```\n\n- `ID` - Points to the [DID](https://web.plc.directory/spec/v0.1/did-plc) for my account. This ID is fixed and must never change; all content (and followers) point to this ID\n\t- The DID points to a DID document that can be retrieved via [web.plc.directory](https://web.plc.directory/resolve)\n\t- The DID document contains information about where the account is hosted and which keys are valid (more about that later)\n- `Identities` - The first entry from the `alsoKnownAs` field of the DID document\n\t- Note the absence of `https://bsky.social` in this field; an account is always independent of Bluesky PBC\n- `Services` - Points to the PDS instance that hosts the account\n- `Verification methods` - Records for this account must be signed with this method (public key) to be valid\n\t- Invalid records might be ignored/discarded by the network\n\n---\n\nThe next step is installing the `goat` command-line tool. This requires a working Go environment. On my macOS laptop, I used these steps:\n\nAssuming [Homebrew](https://brew.sh) is installed, this installs the Go compiler/environment:\n```shell\nbrew install go\n```\nThis fetches, compiles, and installs the `goat` tool:\n```shell\ngo install github.com/bluesky-social/goat@latest\n```\nThis adds the path where compiled Go binaries are stored to the system lookup path:\n```shell\nexport PATH=$PATH:$HOME/go/bin\n```\n\n---\n\nNow we can get some information about the account. Before I started the migration process, I decided to add a recovery key to my account/DID document, just to be safe (and also to get a feel for how this works). See the [guide](https://whtwnd.com/bnewbold.net/3lj7jmt2ct72r) already mentioned above.\n\n\u003e [!NOTE]\n\u003e **Why are recovery keys important?**\n\u003e\n\u003e When you create an account, the PDS holds keys for signing your records. This means a rogue PDS operator could overtake your account, or more mundane things like the PDS losing all data including your signing keys could happen. In this case, a recovery key gives you at least control back over your identity (including your followers). In such a catastrophic scenario, you can restore a backup of your account on a different PDS and initiate a PLC operation to point it at that new PDS with such a recovery key.\n\nFirst, I have to log in to my account:\n```shell\ngoat account login -u fry69.dev -p '[old_pw]'\n```\n\u003e [!WARNING]\n\u003e Once logged in, destructive operations with `goat` are possible, like deleting records.\n\u003e PLC operations (changing the DID document) require a separate token via email.\n\nThis command gives an overview of the status of the account, including whether it is active and how many records/blobs it references:\n```shell\n$ goat account status\nDID: did:plc:3zxgigfubnv4f47ftmqdsbal\nHost: https://cordyceps.us-west.host.bsky.network\n{\n  \"activated\": true,\n  \"expectedBlobs\": 1279,\n  \"importedBlobs\": 1281,\n  \"indexedRecords\": 81070,\n  \"privateStateValues\": 0,\n  \"repoBlocks\": 102665,\n  \"repoCommit\": \"bafyreihie34syw5ripq6m2ynxhvnwqrwztw6drltp42dnrasini2rjupyu\",\n  \"repoRev\": \"3lnuu62camb26\",\n  \"validDid\": true\n}\n```\n\nThis command returns the current DID document for the account:\n```shell\n$ goat account plc current\ntrying to refresh auth from password...\n{\n  \"did\": \"did:plc:3zxgigfubnv4f47ftmqdsbal\",\n  \"verificationMethods\": {\n    \"atproto\": \"did:key:zQ3shgqQdc1Rr2A3dTJTAPF2Mu2Qe6BhSCyPFC4Uk5WNPkUsr\"\n  },\n  \"rotationKeys\": [\n    \"did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg\",\n    \"did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK\"\n  ],\n  \"alsoKnownAs\": [\n    \"at://fry69.dev\"\n  ],\n  \"services\": {\n    \"atproto_pds\": {\n      \"type\": \"AtprotoPersonalDataServer\",\n      \"endpoint\": \"https://cordyceps.us-west.host.bsky.network\"\n    }\n  }\n}\n```\n\nHere is the workflow for adding a recovery key to the DID document:\n\nSave the current DID document to compare later:\n```shell\n$ goat account plc current \u003e plc-current.json\n```\nGenerate a recovery key and (optionally) save it in a file:\n```shell\n$ goat key generate \u003e key.txt\n$ cat key.txt\nKey Type: P-256 / secp256r1 / ES256 private key\nSecret Key (Multibase Syntax): save this securely (eg, add to password manager)\n\t[secret key]\nPublic Key (DID Key Syntax): share or publish this (e.g., in DID document)\n\tdid:key:zDnaenr1u5hpX7AznPRZ2kgTzpoFdEYRiPrZMyzmXFGFgGkTY\n```\n\u003e [!WARNING]\n\u003e Keep the secret key safe. Whoever has control of this key can take over your account.\n\nNow I tried to add the key to my DID document, but the token I used was already expired (tokens may have less than an hour lifetime):\n```shell\n$ goat account plc add-rotation-key --token [via mail] did:key:zDnaenr1u5hpX7AznPRZ2kgTzpoFdEYRiPrZMyzmXFGFgGkTY\n400: ExpiredToken: Token is expired\n```\nThis command requests a fresh token:\n```shell\n$ goat account plc request-token\nSuccess; check email for token.\n```\nNow the command works:\n```shell\n$ goat account plc add-rotation-key --token [via mail] did:key:zDnaenr1u5hpX7AznPRZ2kgTzpoFdEYRiPrZMyzmXFGFgGkTY\nSuccess\n```\nGet the current, changed DID document and compare it to the old one to make sure the recovery key is in place and nothing else changed:\n```diff\n$ goat account plc current \u003e plc-current-20250429.json\n$ diff -u plc-current.json plc-current-20250429.json\n--- plc-current.json\t2025-04-29 07:56:56\n+++ plc-current-20250429.json\t2025-04-29 08:01:12\n@@ -5,7 +5,8 @@\n   },\n   \"rotationKeys\": [\n     \"did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg\",\n-    \"did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK\"\n+    \"did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK\",\n+    \"did:key:zDnaenr1u5hpX7AznPRZ2kgTzpoFdEYRiPrZMyzmXFGFgGkTY\"\n   ],\n   \"alsoKnownAs\": [\n     \"at://fry69.dev\"\n```\n\n---\n\nThat's it. The next step is the migration process:\n\nIf not already logged into the mushroom account:\n```shell\n$ goat account login -u fry69.dev -p '[old_pw]'\n```\nFirst, I tried the automated `goat account migrate` approach. For this, I wrote a little script:\n```shell\n#!/usr/bin/env bash\n\nNEWPDSHOST=\"https://altq.net\"\nNEWHANDLE=\"fry69.altq.net\" # not tested if existing handled @fry69.dev can get used\nNEWPASSWORD=\"[new_pw]\"\nNEWEMAIL=\"fry-altq@fry69.dev\" # not tested if old email address can get used\n\nNEWPLCTOKEN=\"[from email]\"\nINVITECODE=\"altq-net-...\"\n\ngoat account migrate \\\n    --pds-host $NEWPDSHOST \\\n    --new-handle $NEWHANDLE \\\n    --new-password $NEWPASSWORD \\\n    --new-email $NEWEMAIL \\\n    --plc-token $NEWPLCTOKEN \\\n    --invite-code $INVITECODE\n```\nThis did not work and stopped the migration process repeatedly at the same point:\n```shell\n$ ./migration.sh\n2025/04/28 16:14:55 INFO new host serviceDID=did:web:altq.net url=https://altq.net\n2025/04/28 16:14:55 INFO creating account on new host handle=fry69.altq.net host=https://altq.net\n2025/04/28 16:14:57 INFO migrating repo\n2025/04/28 16:15:35 WARN request failed subsystem=RobustHTTPClient error=\"Post \\\"https://altq.net/xrpc/com.atproto.repo.importRepo\\\": net/http: request canceled\" method=POST url=https://altq.net/xrpc/com.atproto.repo.importRepo\nerror: failed importing repo: request failed: Post \"https://altq.net/xrpc/com.atproto.repo.importRepo\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)\n```\nThe migration guide says `goat` commands can be repeated/retried if something fails, but this does not work for the automatic migration process:\n```shell\n$ ./migration.sh\n2025/04/28 16:43:57 INFO new host serviceDID=did:web:altq.net url=https://altq.net\n2025/04/28 16:43:57 INFO creating account on new host handle=fry69.altq.net host=https://altq.net\nerror: failed creating new account: XRPC ERROR 400: AlreadyExists: Repo already exists\n```\nEven if I delete the stale inactive account and try the automation process again, I run into the same `timeout` problem as above.\n\n\u003e [!NOTE]\n\u003e **How do I delete a stale account?**\n\u003e\n\u003e In my case, I deleted the stale account directly on my PDS server with this command:\n\u003e```shell\n\u003epdsadmin account delete did:plc:3zxgigfubnv4f47ftmqdsbal\n\u003e```\n\u003e The `did:plc` DID must be the real DID. This is safe since this is only a stale, inactive copy of my real account, which still resided on the mushroom PDS at this point.\n\nSo I have to use the manual migration process, which is a little more involved. First, make sure that any stale account is removed (see above). Also make sure you are logged into the mushroom account with `goat`.\n\nNow let's have a look at my PDS:\n```shell\n$ goat pds describe https://altq.net\n{\n  \"availableUserDomains\": [\n    \".altq.net\"\n  ],\n  \"contact\": {},\n  \"did\": \"did:web:altq.net\",\n  \"inviteCodeRequired\": true,\n  \"links\": {}\n}\n```\nCompare this to the official Bluesky mushroom PDS:\n```shell\n$ goat pds describe https://bsky.social\n{\n  \"availableUserDomains\": [\n    \".bsky.social\"\n  ],\n  \"did\": \"did:web:bsky.social\",\n  \"inviteCodeRequired\": false,\n  \"links\": {\n    \"privacyPolicy\": \"https://blueskyweb.xyz/support/privacy-policy\",\n    \"termsOfService\": \"https://blueskyweb.xyz/support/tos\"\n  },\n  \"phoneVerificationRequired\": true\n}\n```\nLooks good. Now let's start exporting the data from the mushroom PDS, starting with the repository. It contains all records (posts, likes, accounts you follow, and all other non-Bluesky records), but it does **not** contain blobs (binary large objects: images, short videos):\n```shell\n$ goat repo export fry69.dev\ndownloading from https://cordyceps.us-west.host.bsky.network to: fry69.dev.20250504094733.car\n```\nThe CAR file this generated is about 30 MB in size for my ~10k posts and other records.\n\u003e [!NOTE]\n\u003e **What the heck is CAR?**\n\u003e\n\u003e The standard file format for storing data objects is Content Addressable aRchives (CAR). The standard repository export format for atproto repositories is [CAR v1](https://ipld.io/specs/transport/car/carv1/), which have file suffix `.car` and MIME type `application/vnd.ipld.car`. See [here](https://atproto.com/specs/repository#car-file-serialization) for more details.\n\nNow it's time to download my ~800 blobs (~1 GB total size). This is a slow process—it took ~1 hour with a fast downlink. The limiting factor is the mushroom PDS. And of course, it failed in the middle of the process:\n```shell\n$ goat blob export fry69.dev\ndownloading blobs to: fry69.dev_blobs\nfry69.dev_blobs/bafkreia2gocqxxxx7amdujd6ycqhwlomnsoqtfrekef4mbblfg6cll7kve\tdownloaded\n[...]\n2025/05/04 10:11:50 WARN request failed subsystem=RobustHTTPClient error=\"Get \\\"https://cordyceps.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?cid=bafkreiet7zkowtbwaz7s3fdneuyrqbegv45uidbzvzre33su5j3xsq5w24\u0026did=did%3Aplc%3A3zxgigfubnv4f47ftmqdsbal\\\": net/http: request canceled\" method=GET url=\"https://cordyceps.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?cid=bafkreiet7zkowtbwaz7s3fdneuyrqbegv45uidbzvzre33su5j3xsq5w24\u0026did=did%3Aplc%3A3zxgigfubnv4f47ftmqdsbal\"\nerror: request failed: Get \"https://cordyceps.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?cid=bafkreiet7zkowtbwaz7s3fdneuyrqbegv45uidbzvzre33su5j3xsq5w24\u0026did=did%3Aplc%3A3zxgigfubnv4f47ftmqdsbal\": GET https://cordyceps.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?cid=bafkreiet7zkowtbwaz7s3fdneuyrqbegv45uidbzvzre33su5j3xsq5w24\u0026did=did%3Aplc%3A3zxgigfubnv4f47ftmqdsbal giving up after 1 attempt(s): context deadline exceeded (Client.Timeout exceeded while awaiting headers)\n\n```\nThankfully, this command is repeatable and will skip already-downloaded blobs found on disk. It proceeded to the end on the second attempt:\n```shell\n$ goat blob export fry69.dev\ndownloading blobs to: fry69.dev_blobs\nfry69.dev_blobs/bafkreia2gocqxxxx7amdujd6ycqhwlomnsoqtfrekef4mbblfg6cll7kve\texists\n[...]\n```\n\nWith the repository and the blobs safe on the local disk, only the proprietary preferences for the Bluesky AppView are missing. Compared to the other parts, this is a tiny object you can view with, e.g., [jq](https://jqlang.org/). This command downloads the preferences:\n```shell\n$ goat bsky prefs export \u003e prefs.json\n```\nJust to be sure my account repository did not get modified in the process, I requested another export and compared the second one to the first one. They were identical (no surprise):\n```shell\n$ goat repo export fry69.dev\ndownloading from https://cordyceps.us-west.host.bsky.network to: fry69.dev.20250504103731.car\n$ cmp fry69.dev.20250504094733.car fry69.dev.20250504103731.car # [no output -\u003e identical]\n```\n\nThe next step is to create a fresh (deactivated) account on my PDS. The AT Protocol requires requesting a service token for this. This can be requested and stored in an environment variable (it's a rather long token) with this command (requires login, but **not** on the new PDS—mushroom PDS login is fine):\n```shell\n$ SERVICEAUTH=$(goat account service-auth --lxm com.atproto.server.createAccount --duration-sec 3600 --aud \"did:web:altq.net\")\n```\nThis command creates the new account:\n```shell\n$ goat account create --service-auth $SERVICEAUTH --pds-host \"https://altq.net\" --existing-did \"did:plc:3zxgigfubnv4f47ftmqdsbal\" --handle fry69.altq.net --password \"[new_pw]\" --email \"fry-altq@fry69.dev\" --invite-code altq-net-[...]\nSuccess!\nDID: did:plc:3zxgigfubnv4f47ftmqdsbal\nHandle: fry69.altq.net\n```\n\u003e [!NOTE]\n\u003e It may be possible to reuse the existing handle (`fry69.dev`) and email address. I used different ones because I was unsure. I'd love feedback on this.\n\nWith this fresh account in place, it's time to log in to the new PDS and import the data:\n\u003e [!WARNING]\n\u003e Login change\n\n```shell\n$ goat account login --pds-host \"https://altq.net\" -u \"did:plc:3zxgigfubnv4f47ftmqdsbal\" -p \"[new_pw]\"\n```\n\nThe first step is to upload the repository with the posts, likes, etc. Of course, this produced an error:\n```shell\n$ goat repo import ./fry69.dev.20250504103731.car\n2025/05/04 10:58:18 WARN request failed subsystem=RobustHTTPClient error=\"Post \\\"https://altq.net/xrpc/com.atproto.repo.importRepo\\\": net/http: request canceled\" method=POST url=https://altq.net/xrpc/com.atproto.repo.importRepo\nerror: failed to import repo: request failed: Post \"https://altq.net/xrpc/com.atproto.repo.importRepo\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)\n```\nPanic! What happened? Let's check. This command shows the status of the new (inactive) account on the new PDS:\n```shell\n$ goat account status\nDID: did:plc:3zxgigfubnv4f47ftmqdsbal\nHost: https://altq.net\n{\n  \"activated\": false,\n  \"expectedBlobs\": 1329,\n  \"importedBlobs\": 0,\n  \"indexedRecords\": 84230,\n  \"privateStateValues\": 0,\n  \"repoBlocks\": 106702,\n  \"repoCommit\": \"bafyreiegjvpriioc4dqhrmoq2txz7jkpovtwnxunu2x6pbwirphmldydfi\",\n  \"repoRev\": \"3lodie5dgnc25\",\n  \"validDid\": false\n}\n```\nHmm, looks fine to me. To be safe, I re-ran the import command. This time there was no error, but the account repository also did not change in a noticeable way:\n```shell\n$ goat repo import ./fry69.dev.20250504103731.car\n$ goat account status\nDID: did:plc:3zxgigfubnv4f47ftmqdsbal\nHost: https://altq.net\n{\n  \"activated\": false,\n  \"expectedBlobs\": 1329,\n  \"importedBlobs\": 0,\n  \"indexedRecords\": 84230,\n  \"privateStateValues\": 0,\n  \"repoBlocks\": 106702,\n  \"repoCommit\": \"bafyreiegjvpriioc4dqhrmoq2txz7jkpovtwnxunu2x6pbwirphmldydfi\",\n  \"repoRev\": \"3lodimlm5gk25\",\n  \"validDid\": false\n}\n```\nWith the repository in place, it's possible to ask the PDS which blobs are missing with this command (huge output):\n```shell\n$ goat account missing-blobs\nbafkreia2gocqxxxx7amdujd6ycqhwlomnsoqtfrekef4mbblfg6cll7kve\tat://did:plc:3zxgigfubnv4f47ftmqdsbal/app.bsky.feed.post/3lmevjd26m22x\n[...]\n```\nTo upload the missing blobs from the local disk to the PDS, I wrote this command. This checks if it does what it should (assuming the blobs are located in the folder `fry69.dev_blobs`):\n```shell\n$ find fry69.dev_blobs -type f -exec echo goat blob upload {} \\;\ngoat blob upload fry69.dev_blobs/bafkreifjp4eprt6l43xlxzf7dj2ofv6apnb6loludnujaamv3sth7a5thq\n[...]\n```\nNow run this command without the `echo`. Double-check the output after running this—it will not automatically retry or list errors separately:\n```shell\n$ find fry69.dev_blobs -type f -exec goat blob upload {} \\;\n{\n  \"$type\": \"blob\",\n  \"ref\": {\n    \"$link\": \"bafkreifjp4eprt6l43xlxzf7dj2ofv6apnb6loludnujaamv3sth7a5thq\"\n  },\n  \"mimeType\": \"image/jpeg\",\n  \"size\": 994679\n}\n[...]\n```\nOf course, there was an error in the middle of the upload:\n```shell\n2025/05/04 11:12:14 WARN request failed subsystem=RobustHTTPClient error=\"Post \\\"https://altq.net/xrpc/com.atproto.repo.uploadBlob\\\": net/http: request canceled\" method=POST url=https://altq.net/xrpc/com.atproto.repo.uploadBlob\nerror: request failed: Post \"https://altq.net/xrpc/com.atproto.repo.uploadBlob\": POST https://altq.net/xrpc/com.atproto.repo.uploadBlob giving up after 3 attempt(s): context deadline exceeded (Client.Timeout exceeded while awaiting headers)\n```\nRetrying this command did not help:\n```shell\n$ goat blob upload fry69.dev_blobs/bafkreifh3ix2tgaqt6hkjp222kreejcpgypebanr5tj6aaqnpaw53upwda\n2025/05/04 11:21:59 WARN request failed subsystem=RobustHTTPClient error=\"Post \\\"https://altq.net/xrpc/com.atproto.repo.uploadBlob\\\": net/http: request canceled\" method=POST url=https://altq.net/xrpc/com.atproto.repo.uploadBlob\nerror: request failed: Post \"https://altq.net/xrpc/com.atproto.repo.uploadBlob\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)\n```\n\u003e [!NOTE]\n\u003e **Hitting the PDS Upload Limit**\n\u003e\n\u003e This led to a side quest where I found out that my PDS was still set to the original 50 MB upload limit, but the mushroom PDS raised this to 100 MB and 3-minute length for videos a while after I set up the PDS. The solution for this problem is changing the following line in the `/pds/pds.env` file on the PDS server:\n\u003e```shell\n\u003ePDS_BLOB_UPLOAD_LIMIT=104857600\n\u003e```\n\u003e Don't forget to restart the PDS afterward (reboot or `systemctl restart pds`).\n\nWith that fix in place, the upload of the missing blob worked fine:\n```shell\n$ goat blob upload fry69.dev_blobs/bafkreifh3ix2tgaqt6hkjp222kreejcpgypebanr5tj6aaqnpaw53upwda\n{\n  \"$type\": \"blob\",\n  \"ref\": {\n    \"$link\": \"bafkreifh3ix2tgaqt6hkjp222kreejcpgypebanr5tj6aaqnpaw53upwda\"\n  },\n  \"mimeType\": \"video/mp4\",\n  \"size\": 70418954\n}\n```\nEverything looks fine now:\n```shell\n$ goat account status\nDID: did:plc:3zxgigfubnv4f47ftmqdsbal\nHost: https://altq.net\n{\n  \"activated\": false,\n  \"expectedBlobs\": 1329,\n  \"importedBlobs\": 1329,\n  \"indexedRecords\": 84230,\n  \"privateStateValues\": 0,\n  \"repoBlocks\": 106702,\n  \"repoCommit\": \"bafyreiegjvpriioc4dqhrmoq2txz7jkpovtwnxunu2x6pbwirphmldydfi\",\n  \"repoRev\": \"3lodimlm5gk25\",\n  \"validDid\": false\n}\n$ goat account missing-blobs # no output means no blobs are missing, yay!\n```\n\n---\n\nThe final step is to change the DID document for the account to point to the new PDS (which also has a different verification method/signing key). This may require a bit of logging in and out between the two accounts if things don't work out. First, check the current state of the DID document for the new account on the PDS (note the `recommended`—it is not yet uploaded to the DID registry):\n\n```shell\n$ goat account plc recommended \u003e plc_new.json\n$ cat plc_new.json\n{\n  \"alsoKnownAs\": [\n    \"at://fry69.altq.net\"\n  ],\n  \"verificationMethods\": {\n    \"atproto\": \"did:key:zQ3shSuymtEUXUsN1pyZACZ6WGk3Tktxe4s1JyL4CSWLRWaZa\"\n  },\n  \"rotationKeys\": [\n    \"did:key:zQ3shcFsHHoawNae6vDx4HNamQVZEVrcQ1Uc2gwi5f9qxR6Xi\"\n  ],\n  \"services\": {\n    \"atproto_pds\": {\n      \"type\": \"AtprotoPersonalDataServer\",\n      \"endpoint\": \"https://altq.net\"\n    }\n  }\n}\n```\nCompare this to the official DID document at this point:\n```shell\n$ goat account plc current\n{\n  \"did\": \"did:plc:3zxgigfubnv4f47ftmqdsbal\",\n  \"verificationMethods\": {\n    \"atproto\": \"did:key:zQ3shgqQdc1Rr2A3dTJTAPF2Mu2Qe6BhSCyPFC4Uk5WNPkUsr\"\n  },\n  \"rotationKeys\": [\n    \"did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg\",\n    \"did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK\",\n    \"did:key:zDnaenr1u5hpX7AznPRZ2kgTzpoFdEYRiPrZMyzmXFGFgGkTY\"\n  ],\n  \"alsoKnownAs\": [\n    \"at://fry69.dev\"\n  ],\n  \"services\": {\n    \"atproto_pds\": {\n      \"type\": \"AtprotoPersonalDataServer\",\n      \"endpoint\": \"https://cordyceps.us-west.host.bsky.network\"\n    }\n  }\n}\n```\nNow edit the `plc_new.json` with your favorite editor and add, e.g., the additional recovery key to the proposed new DID document (note that I got carried away and changed the `alsoKnownAs` field—do not do this):\n```shell\n$ cat plc_new.json\n{\n  \"alsoKnownAs\": [\n    \"at://fry69.dev\"\n  ],\n  \"verificationMethods\": {\n    \"atproto\": \"did:key:zQ3shSuymtEUXUsN1pyZACZ6WGk3Tktxe4s1JyL4CSWLRWaZa\"\n  },\n  \"rotationKeys\": [\n    \"did:key:zQ3shcFsHHoawNae6vDx4HNamQVZEVrcQ1Uc2gwi5f9qxR6Xi\",\n    \"did:key:zDnaenr1u5hpX7AznPRZ2kgTzpoFdEYRiPrZMyzmXFGFgGkTY\"\n  ],\n  \"services\": {\n    \"atproto_pds\": {\n      \"type\": \"AtprotoPersonalDataServer\",\n      \"endpoint\": \"https://altq.net\"\n    }\n  }\n}\n```\nNow it's necessary to log in to the mushroom account, which has a pointer to the valid DID document, request a token from the PLC (you'll receive it via email), and sign the new DID document:\n```shell\n$ goat account login -u fry69.dev -p '[old_pw]'\n$ goat account plc request-token\nSuccess; check email for token.\n$ goat account plc sign --token [token] ./plc_new.json \u003e plc_new_signed.json\n```\nThe signed DID document should look like this:\n```shell\n$ cat plc_new_signed.json\n{\n  \"prev\": \"bafyreic6drt4uv43zrsd54lexmwwvg72dnlswjyqizstjttkuovlyqq4n4\",\n  \"type\": \"plc_operation\",\n  \"services\": {\n    \"atproto_pds\": {\n      \"type\": \"AtprotoPersonalDataServer\",\n      \"endpoint\": \"https://altq.net\"\n    }\n  },\n  \"alsoKnownAs\": [\n    \"at://fry69.dev\"\n  ],\n  \"rotationKeys\": [\n    \"did:key:zQ3shcFsHHoawNae6vDx4HNamQVZEVrcQ1Uc2gwi5f9qxR6Xi\",\n    \"did:key:zDnaenr1u5hpX7AznPRZ2kgTzpoFdEYRiPrZMyzmXFGFgGkTY\"\n  ],\n  \"verificationMethods\": {\n    \"atproto\": \"did:key:zQ3shSuymtEUXUsN1pyZACZ6WGk3Tktxe4s1JyL4CSWLRWaZa\"\n  },\n  \"sig\": \"zPhjYO_DMby4Ky-mHhIjLTAv4hrhiGQtofn0QoLMjRtj_s64-dZPVZ8kQSe1WOgzScwHVa5jL6dy-NzIIjzaww\"\n}\n```\nNow log in to the new PDS before submitting (otherwise you'll get an error about rotation keys):\n```shell\n$ goat account login --pds-host \"https://altq.net\" -u \"did:plc:3zxgigfubnv4f47ftmqdsbal\" -p \"[new_pw]\"\n```\nNow let's submit the new DID document. Of course, it failed because I made a mistake:\n\u003e [!WARNING]\n\u003e PLC Operation / DID Document Update Ahead\n```shell\n$ goat account plc submit ./plc_new_signed.json\nerror: failed submitting PLC op via PDS: XRPC ERROR 400: InvalidRequest: Incorrect handle in alsoKnownAs\n```\nAnd no, just changing the `alsoKnownAs` field to the correct value doesn't work, as this invalidates the signature (as expected, but good to see this working as intended):\n```shell\n$ goat account plc submit ./plc_new_signed.json\nerror: failed submitting PLC op via PDS: XRPC ERROR 400: InvalidRequest: Invalid signature on op: {\"type\":\"plc_operation\",\"rotationKeys\":[\"did:key:zQ3shcFsHHoawNae6vDx4HNamQVZEVrcQ1Uc2gwi5f9qxR6Xi\",\"did:key:zDnaenr1u5hpX7AznPRZ2kgTzpoFdEYRiPrZMyzmXFGFgGkTY\"],\"verificationMethods\":{\"atproto\":\"did:key:zQ3shSuymtEUXUsN1pyZACZ6WGk3Tktxe4s1JyL4CSWLRWaZa\"},\"alsoKnownAs\":[\"at://fry69.altq.net\"],\"services\":{\"atproto_pds\":{\"type\":\"AtprotoPersonalDataServer\",\"endpoint\":\"https://altq.net\"}},\"prev\":\n```\nTo fix this, I had to sign the fixed `plc_new.json` again, with a new requested token from the PLC. This finally worked:\n```shell\n$ goat account plc submit ./plc_new_signed.json\n$ goat account status\nDID: did:plc:3zxgigfubnv4f47ftmqdsbal\nHost: https://altq.net\n{\n  \"activated\": false,\n  \"expectedBlobs\": 1329,\n  \"importedBlobs\": 1329,\n  \"indexedRecords\": 84230,\n  \"privateStateValues\": 0,\n  \"repoBlocks\": 106702,\n  \"repoCommit\": \"bafyreiegjvpriioc4dqhrmoq2txz7jkpovtwnxunu2x6pbwirphmldydfi\",\n  \"repoRev\": \"3lodimlm5gk25\",\n  \"validDid\": true\n}\n```\n🎉 `validDid: true` yay! 🎉\n\nBut my handle was now `@fry69.altq.net`. This was easily solvable by using the change handle feature in the official web client to set it back to `@fry69.dev`.\n\n\u003e [!NOTE]\n\u003e **What happens with the old account on the mushroom PDS?**\n\u003e\n\u003e I'm glad you asked. This is currently unclear. If you log in to your mushroom account with the official https://bsky.app/ web client (this is still possible—choose Bluesky Social as your host during login), you will notice that the timeline will not load. But you can get to the settings page and from there to the account and try to delete your account. This will not work, probably because the deletion process will try to delete your chats, bookmarks, and mutes (which you certainly want to keep), or for some other reason.\n\nYou can deactivate your mushroom account in the web client or with `goat` like this:\n```shell\n$ goat account login -u fry69.dev -p '[old_pw]' --pds-host \"https://cordyceps.us-west.host.bsky.network\"\n$ goat account deactivate\n```\n\nIf you read until here and spotted a problem, typo, or want to leave a different comment/suggestion: Please file an [issue](https://github.com/fry69/bluesky-migration-guide/issues/new) in the GitHub [repository](https://github.com/fry69/bluesky-migration-guide) for this guide, or [ping me](https://bsky.app/profile/fry69.dev) on Bluesky.\n\nThank you and happy, less troublesome, migration!\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffry69%2Fbluesky-migration-guide","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffry69%2Fbluesky-migration-guide","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffry69%2Fbluesky-migration-guide/lists"}