Cocoon
[!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.
Atproto Spaces alpha
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=true
When 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.
Registered endpoints
These are the routes currently registered when Spaces are enabled (the HTTP method is shown first):
com.atproto.space
POST createRecord, POST putRecord, POST deleteRecord, POST applyWrites
GET listSpaces, GET getDelegationToken
GET getRecord, GET listRecords, GET getBlob, GET listBlobs
GET getLatestCommit, GET getRepo, GET listRepoOps, GET listRepos
POST getSpaceCredential
POST registerNotify, POST unregisterNotify
POST notifyWrite, POST notifySpaceDeleted
com.atproto.simplespace
POST createSpace, POST updateSpace, POST deleteSpace
GET getSpace
POST 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.
Data flow and boundaries
- Permissioned records sync directly with an authorized PDS client through the
com.atproto.space read/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.getRepo is an authenticated full current-state CAR recovery path. listRepoOps reads 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.getBlob is 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. Public com.atproto.sync.listBlobs and com.atproto.sync.getBlob expose 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
SpaceNotificationSender and 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.
Deletion, retention, and recovery limits
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.
Minimal local test workflow
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.sh
This 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'
Quick Start with Docker Compose
Prerequisites
- Docker and Docker Compose installed
- A domain name pointing to your server (for automatic HTTPS)
- Ports 80 and 443 open in i.e. UFW
Installation
-
Clone the repository
git clone https://github.com/haileyok/cocoon.git
cd cocoon
-
Create your configuration file
cp .env.example .env
-
Edit .env with your settings
Required 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 -d
Or 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
What Gets Set Up
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
Data Persistence
The following directories will be created automatically:
./keys/ - Cryptographic keys (generated automatically)
rotation.key - PDS rotation key
jwk.key - JWK private key
initial-invite-code.txt - Your first invite code (first run only)
./data/ - SQLite database and blockstore
- Docker volumes for Caddy configuration and certificates
Optional Configuration
Database Configuration
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_dump or your database provider's backup solution instead.
SMTP Email Settings
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"
S3 Storage
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 database
COCOON_S3_BLOBSTORE_ENABLED=true: New blobs are stored under an immutable generation-specific key such as blobs/{did}/{cid}/{generation}. Legacy rows with an empty persisted key continue to use blobs/{did}/{cid}.
Blob Serving Options:
- Without
COCOON_S3_CDN_URL: Blobs are proxied through the PDS server
- With
COCOON_S3_CDN_URL: getBlob redirects 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.
Alpine based image
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.alpine
You can also build the image locally with
docker build -f Dockerfile.alpine -t cocoon:alpine .
Management Commands
Create an invite code:
docker exec cocoon-pds /cocoon create-invite-code --uses 1
Reset a user's password:
docker exec cocoon-pds /cocoon reset-password --did "did:plc:xxx"
Updating
docker-compose pull
docker-compose up -d
Implemented Endpoints
[!NOTE]
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.
Identity
-
com.atproto.identity.getRecommendedDidCredentials
-
com.atproto.identity.requestPlcOperationSignature
-
com.atproto.identity.resolveHandle
-
com.atproto.identity.signPlcOperation
-
com.atproto.identity.submitPlcOperation
-
com.atproto.identity.updateHandle
Repo
-
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
Server
-
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
[ ] com.atproto.server.listAppPasswords - not going to add app passwords
-
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
[] com.atproto.server.revokeAppPassword - not going to add app passwords
-
com.atproto.server.updateEmail
Sync
-
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
[ ] com.atproto.sync.notifyOfUpdate - BGS doesn't even have this implemented lol
-
com.atproto.sync.requestCrawl
-
com.atproto.sync.subscribeRepos
Other
-
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
License
This project is licensed under MIT license. server/static/pico.css is also licensed under MIT license, available at https://github.com/picocss/pico/.