Warning
I migrated and have been running my main account on this PDS for months now without issue, however, I am still not responsible if things go awry, particularly during account migration. Please use caution.
Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use.
Warning
Atproto Spaces is an experimental alpha. It is disabled by default: leave COCOON_SPACES_ENABLED=false (or omit the flag) unless you are deliberately testing it. The implementation is pinned to the atproto reference SHA 89deb9faca20e56fa2a262fe9746ed52bc1095ba. Spaces provide access control, not encryption or confidentiality; they are unsuitable for sensitive data and unsuitable for production data. This alpha is not a claim of complete external interoperability or production readiness.
Spaces are permissioned, host-local PDS routes. They are not a replacement for the ordinary public repository/sync surface. Enable them only for a controlled experiment:
COCOON_SPACES_ENABLED=trueWhen the flag is false, Cocoon does not register the alpha handlers. The com.atproto.space.* and com.atproto.simplespace.* namespace guards return 501 NotSupported instead of allowing an unknown method to fall through to the generic proxy.
These are the routes currently registered when Spaces are enabled (the HTTP method is shown first):
com.atproto.spacePOST createRecord,POST putRecord,POST deleteRecord,POST applyWritesGET listSpaces,GET getDelegationTokenGET getRecord,GET listRecords,GET getBlob,GET listBlobsGET getLatestCommit,GET getRepo,GET listRepoOps,GET listReposPOST getSpaceCredentialPOST registerNotify,POST unregisterNotifyPOST notifyWrite,POST notifySpaceDeleted
com.atproto.simplespacePOST createSpace,POST updateSpace,POST deleteSpaceGET getSpacePOST addMember,POST removeMember,GET listMembers
OAuth scopes authorize the OAuth routes. Space credentials and their DPoP proofs authorize credential routes; delegation exchange uses a short-lived delegation token and DPoP proof. Service-auth JWTs are used by the notification receiver and are checked for the expected audience (this PDS) and lexicon method (lxm). At a high level, DPoP binds a credential to a client key and protects each request against proof replay; it does not encrypt the record or blob.
- Permissioned records sync directly with an authorized PDS client through the
com.atproto.spaceread/CAR routes. Space data does not use a relay or a public firehose: permissioned records never enter Cocoon's public event manager. Ordinary public repositories retain their separate public sync/firehose behavior. com.atproto.space.getRepois an authenticated full current-state CAR recovery path.listRepoOpsreads the retained, append-only Space oplog by revision and cursor; Space records retain the current value separately. There is no complete import API and no Space-specific pruning, so a CAR is an export/recovery representation, not a general migration import contract.- Permissioned blob references are checked against the authorized Space record.
com.atproto.space.getBlobis an authenticated PDS proxy only and never redirects to a CDN, including when public S3/CDN storage is configured. A private CDN is not a permission boundary. Publiccom.atproto.sync.listBlobsandcom.atproto.sync.getBlobexpose only blobs referenced by a public repository; a Space-only reference is not a public reference. - Writes enqueue metadata-only notification outbox rows after the Space transaction's state changes. The outbox carries Space/repo/revision/hash metadata, not record values or blob bytes. A host must configure/inject the outbound
SpaceNotificationSenderand any target resolver/service-auth transport; there is no automatic outbound delivery when that sender is absent. The worker retries with idempotency and expires registrations after 24 hours and deliveries after 7 days.
Deleting a Space creates a durable tombstone, removes the authority's local Space rows, marks members removed, and queues deletion notifications; Space URIs are not reusable. Account deletion removes the account's authored permissioned records, refs, repos, oplog rows, and credentials-related account state while preserving the tombstone/deletion outbox semantics and any remote data that other hosts already retained. Remote consumers can retain copies.
Space credentials live for two hours; delegation/client-attestation tokens live for 60 seconds, and DPoP proofs are accepted for at most 60 seconds (with clock skew). A local tombstone check rejects credential use immediately, but there is no global revocation protocol for already-cached remote credentials or data. Plan for this residual credential-expiry/notification window: registrations can remain until their 24-hour expiry and queued deliveries until their 7-day expiry unless explicitly handled by the configured worker.
The normal server uses PostgreSQL/SQLite persistence for Space state and durable replay JTIs. Replay JTIs are single-use and carry an expiry deadline, but this alpha has no complete export/import or replay-compaction API. PostgreSQL backups are an operator responsibility (pg_dump or the provider); SQLite backup covers the local database, while externally stored S3 blob bytes still require their own backup. See the detailed Spaces alpha guide and the pinned compatibility fixture before updating the reference.
The documentation contract test is deterministic and checks only key claims rather than snapshotting this README. Run the normal suite with:
go test ./space -run 'TestSpaces(ReadmeContract|FixtureManifestMatchesProtocolTypes|AlphaReferenceCommitPinned)$'
go test ./...The reference PDS test harness creates TypeScript PDS instances internally and cannot be pointed at an external PDS. To exercise Cocoon with the pinned atproto generated client and Lexicons, run the Cocoon-backed interop harness:
ATPROTO_ROOT=~/worktrees/atproto/private-spaces-reference \
./interop/atproto/run.shThis starts a disposable Cocoon instance, seeds test accounts, obtains real session tokens, and validates Space creation, membership, record CRUD, JSON wire shapes, signed commits, CAR retrieval, oplog cursors/nullability, Space discovery, and owner-only SimpleSpace authorization over HTTP.
The PostgreSQL concurrency/durability tests are opt-in and use an isolated schema. With a reachable PostgreSQL database, pass its DSN as COCOON_TEST_POSTGRES_DSN:
COCOON_TEST_POSTGRES_DSN='postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable' \\
go test ./server -run 'TestPostgresSpaceRepo'- Docker and Docker Compose installed
- A domain name pointing to your server (for automatic HTTPS)
- Ports 80 and 443 open in i.e. UFW
-
Clone the repository
git clone https://github.com/haileyok/cocoon.git cd cocoon -
Create your configuration file
cp .env.example .env
-
Edit
.envwith your settingsRequired settings:
COCOON_DID="did:web:your-domain.com" COCOON_HOSTNAME="your-domain.com" COCOON_CONTACT_EMAIL="you@example.com" COCOON_RELAYS="https://bsky.network" # Generate with: openssl rand -hex 16 COCOON_ADMIN_PASSWORD="your-secure-password" # Generate with: openssl rand -hex 32 COCOON_SESSION_SECRET="your-session-secret"
-
Start the services
# Pull pre-built image from GitHub Container Registry docker-compose pull docker-compose up -dOr build locally:
docker-compose build docker-compose up -d
For PostgreSQL deployment:
# Add POSTGRES_PASSWORD to your .env file first! docker-compose -f docker-compose.postgres.yaml up -d -
Get your invite code
On first run, an invite code is automatically created. View it with:
docker-compose logs create-invite
Or check the saved file:
cat keys/initial-invite-code.txt
IMPORTANT: Save this invite code! You'll need it to create your first account.
-
Monitor the services
docker-compose logs -f
The Docker Compose setup includes:
- init-keys: Automatically generates cryptographic keys (rotation key and JWK) on first run
- cocoon: The main PDS service running on port 8080
- create-invite: Automatically creates an initial invite code after Cocoon starts (first run only)
- caddy: Reverse proxy with automatic HTTPS via Let's Encrypt
The following directories will be created automatically:
./keys/- Cryptographic keys (generated automatically)rotation.key- PDS rotation keyjwk.key- JWK private keyinitial-invite-code.txt- Your first invite code (first run only)
./data/- SQLite database and blockstore- Docker volumes for Caddy configuration and certificates
By default, Cocoon uses SQLite which requires no additional setup. For production deployments with higher traffic, you can use PostgreSQL:
# Database type: sqlite (default) or postgres
COCOON_DB_TYPE="postgres"
# PostgreSQL connection string (required if db-type is postgres)
# Format: postgres://user:password@host:port/database?sslmode=disable
COCOON_DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable"
# Or use the standard DATABASE_URL environment variable
DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable"For SQLite (default):
COCOON_DB_TYPE="sqlite"
COCOON_DB_NAME="/data/cocoon/cocoon.db"Note: When using PostgreSQL, database backups to S3 are not handled by Cocoon. Use
pg_dumpor your database provider's backup solution instead.
COCOON_SMTP_USER="your-smtp-username"
COCOON_SMTP_PASS="your-smtp-password"
COCOON_SMTP_HOST="smtp.example.com"
COCOON_SMTP_PORT="587"
COCOON_SMTP_EMAIL="noreply@example.com"
COCOON_SMTP_NAME="Cocoon PDS"Cocoon supports S3-compatible storage for both database backups (SQLite only) and blob storage (images, videos, etc.):
# Enable S3 backups (SQLite databases only - hourly backups)
COCOON_S3_BACKUPS_ENABLED=true
# Enable S3 for blob storage (images, videos, etc.)
# When enabled, blobs are stored in S3 instead of the database
COCOON_S3_BLOBSTORE_ENABLED=true
# S3 configuration (works with AWS S3, MinIO, Cloudflare R2, etc.)
COCOON_S3_REGION="us-east-1"
COCOON_S3_BUCKET="your-bucket"
COCOON_S3_ENDPOINT="https://s3.amazonaws.com"
COCOON_S3_ACCESS_KEY="your-access-key"
COCOON_S3_SECRET_KEY="your-secret-key"
# Optional: CDN/public URL for blob redirects
# When set, com.atproto.sync.getBlob redirects to this URL instead of proxying
COCOON_S3_CDN_URL="https://cdn.example.com"Blob Storage Options:
COCOON_S3_BLOBSTORE_ENABLED=false(default): Blobs stored in the databaseCOCOON_S3_BLOBSTORE_ENABLED=true: New blobs are stored under an immutable generation-specific key such asblobs/{did}/{cid}/{generation}. Legacy rows with an empty persisted key continue to useblobs/{did}/{cid}.
Blob Serving Options:
- Without
COCOON_S3_CDN_URL: Blobs are proxied through the PDS server - With
COCOON_S3_CDN_URL:getBlobredirects to the blob's persisted object key (legacy rows use{CDN_URL}/blobs/{did}/{cid})
Tip: For Cloudflare R2, you can use the public bucket URL as the CDN URL. For AWS S3, you can use CloudFront or the S3 bucket URL directly if public access is enabled.
The default image is based on Debian. You can use the Alpine-based image if you prefer.
Note
Currently, we do not have pre-built Alpine-based image on the GitHub Container Registry. You have to build them locally.
In the compose file, replace every dockerfile: Dockerfile by dockerfile: Dockerfile.alpine, e.g.
services:
cocoon:
build:
context: .
dockerfile: Dockerfile.alpineYou can also build the image locally with
docker build -f Dockerfile.alpine -t cocoon:alpine .Create an invite code:
docker exec cocoon-pds /cocoon create-invite-code --uses 1Reset a user's password:
docker exec cocoon-pds /cocoon reset-password --did "did:plc:xxx"docker-compose pull
docker-compose up -dNote
Just because something is implemented doesn't mean it is finished. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that.
-
com.atproto.identity.getRecommendedDidCredentials -
com.atproto.identity.requestPlcOperationSignature -
com.atproto.identity.resolveHandle -
com.atproto.identity.signPlcOperation -
com.atproto.identity.submitPlcOperation -
com.atproto.identity.updateHandle
-
com.atproto.repo.applyWrites -
com.atproto.repo.createRecord -
com.atproto.repo.putRecord -
com.atproto.repo.deleteRecord -
com.atproto.repo.describeRepo -
com.atproto.repo.getRecord -
com.atproto.repo.importRepo(Works "okay". Use with extreme caution.) -
com.atproto.repo.listRecords -
com.atproto.repo.listMissingBlobs
-
com.atproto.server.activateAccount -
com.atproto.server.checkAccountStatus -
com.atproto.server.confirmEmail -
com.atproto.server.createAccount -
com.atproto.server.createInviteCode -
com.atproto.server.createInviteCodes -
com.atproto.server.deactivateAccount -
com.atproto.server.deleteAccount -
com.atproto.server.deleteSession -
com.atproto.server.describeServer -
com.atproto.server.getAccountInviteCodes -
com.atproto.server.getServiceAuth [ ]- not going to add app passwordscom.atproto.server.listAppPasswords-
com.atproto.server.refreshSession -
com.atproto.server.requestAccountDelete -
com.atproto.server.requestEmailConfirmation -
com.atproto.server.requestEmailUpdate -
com.atproto.server.requestPasswordReset -
com.atproto.server.reserveSigningKey -
com.atproto.server.resetPassword []- not going to add app passwordscom.atproto.server.revokeAppPassword-
com.atproto.server.updateEmail
-
com.atproto.sync.getBlob -
com.atproto.sync.getBlocks -
com.atproto.sync.getLatestCommit -
com.atproto.sync.getRecord -
com.atproto.sync.getRepoStatus -
com.atproto.sync.getRepo -
com.atproto.sync.listBlobs -
com.atproto.sync.listRepos [ ]- BGS doesn't even have this implemented lolcom.atproto.sync.notifyOfUpdate-
com.atproto.sync.requestCrawl -
com.atproto.sync.subscribeRepos
-
com.atproto.label.queryLabels -
com.atproto.moderation.createReport(Note: this should be handled by proxying, not actually implemented in the PDS) -
app.bsky.actor.getPreferences -
app.bsky.actor.putPreferences
This project is licensed under MIT license. server/static/pico.css is also licensed under MIT license, available at https://github.com/picocss/pico/.