···11+name: Docker image
22+33+on:
44+ workflow_dispatch:
55+ push:
66+ branches:
77+ - main
88+ tags:
99+ - 'v*'
1010+1111+env:
1212+ REGISTRY: ghcr.io
1313+ IMAGE_NAME: ${{ github.repository }}
1414+1515+jobs:
1616+ build-and-push-image:
1717+ strategy:
1818+ matrix:
1919+ include:
2020+ - arch: amd64
2121+ runner: ubuntu-latest
2222+ - arch: arm64
2323+ runner: ubuntu-24.04-arm
2424+ runs-on: ${{ matrix.runner }}
2525+ # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
2626+ permissions:
2727+ contents: read
2828+ packages: write
2929+ attestations: write
3030+ id-token: write
3131+ outputs:
3232+ digest-amd64: ${{ matrix.arch == 'amd64' && steps.push.outputs.digest || '' }}
3333+ digest-arm64: ${{ matrix.arch == 'arm64' && steps.push.outputs.digest || '' }}
3434+ steps:
3535+ - name: Checkout repository
3636+ uses: actions/checkout@v4
3737+3838+ # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
3939+ - name: Log in to the Container registry
4040+ uses: docker/login-action@v3
4141+ with:
4242+ registry: ${{ env.REGISTRY }}
4343+ username: ${{ github.actor }}
4444+ password: ${{ secrets.GITHUB_TOKEN }}
4545+4646+ # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
4747+ - name: Extract metadata (tags, labels) for Docker
4848+ id: meta
4949+ uses: docker/metadata-action@v5
5050+ with:
5151+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
5252+ tags: |
5353+ type=raw,value=latest,enable={{is_default_branch}},suffix=-${{ matrix.arch }}
5454+ type=sha,suffix=-${{ matrix.arch }}
5555+ type=sha,format=long,suffix=-${{ matrix.arch }}
5656+ type=semver,pattern={{version}},suffix=-${{ matrix.arch }}
5757+ type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.arch }}
5858+5959+ # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
6060+ # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
6161+ # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
6262+ - name: Build and push Docker image
6363+ id: push
6464+ uses: docker/build-push-action@v6
6565+ with:
6666+ context: .
6767+ push: true
6868+ tags: ${{ steps.meta.outputs.tags }}
6969+ labels: ${{ steps.meta.outputs.labels }}
7070+7171+ publish-manifest:
7272+ needs: build-and-push-image
7373+ runs-on: ubuntu-latest
7474+ permissions:
7575+ packages: write
7676+ attestations: write
7777+ id-token: write
7878+ steps:
7979+ - name: Log in to the Container registry
8080+ uses: docker/login-action@v3
8181+ with:
8282+ registry: ${{ env.REGISTRY }}
8383+ username: ${{ github.actor }}
8484+ password: ${{ secrets.GITHUB_TOKEN }}
8585+8686+ - name: Extract metadata (tags, labels) for Docker
8787+ id: meta
8888+ uses: docker/metadata-action@v5
8989+ with:
9090+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
9191+ tags: |
9292+ type=raw,value=latest,enable={{is_default_branch}}
9393+ type=sha
9494+ type=sha,format=long
9595+ type=semver,pattern={{version}}
9696+ type=semver,pattern={{major}}.{{minor}}
9797+9898+ - name: Create and push manifest
9999+ run: |
100100+ # Split tags into an array
101101+ readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
102102+103103+ # Create and push manifest for each tag
104104+ for tag in "${tags[@]}"; do
105105+ docker buildx imagetools create -t "$tag" \
106106+ "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-image.outputs.digest-amd64 }}" \
107107+ "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-image.outputs.digest-arm64 }}"
108108+ done
109109+110110+ # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)."
111111+ - name: Generate artifact attestation
112112+ uses: actions/attest-build-provenance@v1
113113+ with:
114114+ subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
115115+ subject-digest: ${{ needs.build-and-push-image.outputs.digest-amd64 }}
116116+ push-to-registry: true
···11+MIT License
22+33+Copyright (c) 2025 me@haileyok.com
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+40
Makefile
···44GIT_COMMIT := $(shell git rev-parse --short=9 HEAD)
55VERSION := $(if $(GIT_TAG),$(GIT_TAG),dev-$(GIT_COMMIT))
6677+# Build output directory
88+BUILD_DIR := dist
99+1010+# Platforms to build for
1111+PLATFORMS := \
1212+ linux/amd64 \
1313+ linux/arm64 \
1414+ linux/arm \
1515+ darwin/amd64 \
1616+ darwin/arm64 \
1717+ windows/amd64 \
1818+ windows/arm64 \
1919+ freebsd/amd64 \
2020+ freebsd/arm64 \
2121+ openbsd/amd64 \
2222+ openbsd/arm64
2323+724.PHONY: help
825help: ## Print info about all commands
926 @echo "Commands:"
···1431build: ## Build all executables
1532 go build -ldflags "-X main.Version=$(VERSION)" -o cocoon ./cmd/cocoon
16333434+.PHONY: build-release
3535+build-all: ## Build binaries for all architectures
3636+ @echo "Building for all architectures..."
3737+ @mkdir -p $(BUILD_DIR)
3838+ @$(foreach platform,$(PLATFORMS), \
3939+ $(eval OS := $(word 1,$(subst /, ,$(platform)))) \
4040+ $(eval ARCH := $(word 2,$(subst /, ,$(platform)))) \
4141+ $(eval EXT := $(if $(filter windows,$(OS)),.exe,)) \
4242+ $(eval OUTPUT := $(BUILD_DIR)/cocoon-$(VERSION)-$(OS)-$(ARCH)$(EXT)) \
4343+ echo "Building $(OS)/$(ARCH)..."; \
4444+ GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags "-X main.Version=$(VERSION)" -o $(OUTPUT) ./cmd/cocoon && \
4545+ echo " โ $(OUTPUT)" || echo " โ Failed: $(OS)/$(ARCH)"; \
4646+ )
4747+ @echo "Done! Binaries are in $(BUILD_DIR)/"
4848+4949+.PHONY: clean-dist
5050+clean-dist: ## Remove all built binaries
5151+ rm -rf $(BUILD_DIR)
5252+1753.PHONY: run
1854run:
1955 go build -ldflags "-X main.Version=dev-local" -o cocoon ./cmd/cocoon && ./cocoon run
···40764177.env:
4278 if [ ! -f ".env" ]; then cp example.dev.env .env; fi
7979+8080+.PHONY: docker-build
8181+docker-build:
8282+ docker build -t cocoon .
+253-58
README.md
···11# Cocoon
2233> [!WARNING]
44-You should not use this PDS. You should not rely on this code as a reference for a PDS implementation. You should not trust this code. Using this PDS implementation may result in data loss, corruption, etc.
44+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.
5566Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use.
7788-### Impmlemented Endpoints
88+## Quick Start with Docker Compose
99+1010+### Prerequisites
1111+1212+- Docker and Docker Compose installed
1313+- A domain name pointing to your server (for automatic HTTPS)
1414+- Ports 80 and 443 open in i.e. UFW
1515+1616+### Installation
1717+1818+1. **Clone the repository**
1919+ ```bash
2020+ git clone https://github.com/haileyok/cocoon.git
2121+ cd cocoon
2222+ ```
2323+2424+2. **Create your configuration file**
2525+ ```bash
2626+ cp .env.example .env
2727+ ```
2828+2929+3. **Edit `.env` with your settings**
3030+3131+ Required settings:
3232+ ```bash
3333+ COCOON_DID="did:web:your-domain.com"
3434+ COCOON_HOSTNAME="your-domain.com"
3535+ COCOON_CONTACT_EMAIL="you@example.com"
3636+ COCOON_RELAYS="https://bsky.network"
3737+3838+ # Generate with: openssl rand -hex 16
3939+ COCOON_ADMIN_PASSWORD="your-secure-password"
4040+4141+ # Generate with: openssl rand -hex 32
4242+ COCOON_SESSION_SECRET="your-session-secret"
4343+ ```
4444+4545+4. **Start the services**
4646+ ```bash
4747+ # Pull pre-built image from GitHub Container Registry
4848+ docker-compose pull
4949+ docker-compose up -d
5050+ ```
5151+5252+ Or build locally:
5353+ ```bash
5454+ docker-compose build
5555+ docker-compose up -d
5656+ ```
5757+5858+ **For PostgreSQL deployment:**
5959+ ```bash
6060+ # Add POSTGRES_PASSWORD to your .env file first!
6161+ docker-compose -f docker-compose.postgres.yaml up -d
6262+ ```
6363+6464+5. **Get your invite code**
6565+6666+ On first run, an invite code is automatically created. View it with:
6767+ ```bash
6868+ docker-compose logs create-invite
6969+ ```
7070+7171+ Or check the saved file:
7272+ ```bash
7373+ cat keys/initial-invite-code.txt
7474+ ```
7575+7676+ **IMPORTANT**: Save this invite code! You'll need it to create your first account.
7777+7878+6. **Monitor the services**
7979+ ```bash
8080+ docker-compose logs -f
8181+ ```
8282+8383+### What Gets Set Up
8484+8585+The Docker Compose setup includes:
8686+8787+- **init-keys**: Automatically generates cryptographic keys (rotation key and JWK) on first run
8888+- **cocoon**: The main PDS service running on port 8080
8989+- **create-invite**: Automatically creates an initial invite code after Cocoon starts (first run only)
9090+- **caddy**: Reverse proxy with automatic HTTPS via Let's Encrypt
9191+9292+### Data Persistence
9393+9494+The following directories will be created automatically:
9595+9696+- `./keys/` - Cryptographic keys (generated automatically)
9797+ - `rotation.key` - PDS rotation key
9898+ - `jwk.key` - JWK private key
9999+ - `initial-invite-code.txt` - Your first invite code (first run only)
100100+- `./data/` - SQLite database and blockstore
101101+- Docker volumes for Caddy configuration and certificates
102102+103103+### Optional Configuration
104104+105105+#### Database Configuration
106106+107107+By default, Cocoon uses SQLite which requires no additional setup. For production deployments with higher traffic, you can use PostgreSQL:
108108+109109+```bash
110110+# Database type: sqlite (default) or postgres
111111+COCOON_DB_TYPE="postgres"
112112+113113+# PostgreSQL connection string (required if db-type is postgres)
114114+# Format: postgres://user:password@host:port/database?sslmode=disable
115115+COCOON_DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable"
116116+117117+# Or use the standard DATABASE_URL environment variable
118118+DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable"
119119+```
120120+121121+For SQLite (default):
122122+```bash
123123+COCOON_DB_TYPE="sqlite"
124124+COCOON_DB_NAME="/data/cocoon/cocoon.db"
125125+```
126126+127127+> **Note**: When using PostgreSQL, database backups to S3 are not handled by Cocoon. Use `pg_dump` or your database provider's backup solution instead.
128128+129129+#### SMTP Email Settings
130130+```bash
131131+COCOON_SMTP_USER="your-smtp-username"
132132+COCOON_SMTP_PASS="your-smtp-password"
133133+COCOON_SMTP_HOST="smtp.example.com"
134134+COCOON_SMTP_PORT="587"
135135+COCOON_SMTP_EMAIL="noreply@example.com"
136136+COCOON_SMTP_NAME="Cocoon PDS"
137137+```
138138+139139+#### S3 Storage
140140+141141+Cocoon supports S3-compatible storage for both database backups (SQLite only) and blob storage (images, videos, etc.):
142142+143143+```bash
144144+# Enable S3 backups (SQLite databases only - hourly backups)
145145+COCOON_S3_BACKUPS_ENABLED=true
146146+147147+# Enable S3 for blob storage (images, videos, etc.)
148148+# When enabled, blobs are stored in S3 instead of the database
149149+COCOON_S3_BLOBSTORE_ENABLED=true
150150+151151+# S3 configuration (works with AWS S3, MinIO, Cloudflare R2, etc.)
152152+COCOON_S3_REGION="us-east-1"
153153+COCOON_S3_BUCKET="your-bucket"
154154+COCOON_S3_ENDPOINT="https://s3.amazonaws.com"
155155+COCOON_S3_ACCESS_KEY="your-access-key"
156156+COCOON_S3_SECRET_KEY="your-secret-key"
157157+158158+# Optional: CDN/public URL for blob redirects
159159+# When set, com.atproto.sync.getBlob redirects to this URL instead of proxying
160160+COCOON_S3_CDN_URL="https://cdn.example.com"
161161+```
162162+163163+**Blob Storage Options:**
164164+- `COCOON_S3_BLOBSTORE_ENABLED=false` (default): Blobs stored in the database
165165+- `COCOON_S3_BLOBSTORE_ENABLED=true`: Blobs stored in S3 bucket under `blobs/{did}/{cid}`
166166+167167+**Blob Serving Options:**
168168+- Without `COCOON_S3_CDN_URL`: Blobs are proxied through the PDS server
169169+- With `COCOON_S3_CDN_URL`: `getBlob` returns a 302 redirect to `{CDN_URL}/blobs/{did}/{cid}`
170170+171171+> **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.
172172+173173+### Management Commands
174174+175175+Create an invite code:
176176+```bash
177177+docker exec cocoon-pds /cocoon create-invite-code --uses 1
178178+```
179179+180180+Reset a user's password:
181181+```bash
182182+docker exec cocoon-pds /cocoon reset-password --did "did:plc:xxx"
183183+```
184184+185185+### Updating
186186+187187+```bash
188188+docker-compose pull
189189+docker-compose up -d
190190+```
191191+192192+## Implemented Endpoints
919310194> [!NOTE]
1111-Just because something is implemented doesn't mean it is finisehd. 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.
195195+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.
196196+197197+### Identity
198198+199199+- [x] `com.atproto.identity.getRecommendedDidCredentials`
200200+- [x] `com.atproto.identity.requestPlcOperationSignature`
201201+- [x] `com.atproto.identity.resolveHandle`
202202+- [x] `com.atproto.identity.signPlcOperation`
203203+- [x] `com.atproto.identity.submitPlcOperation`
204204+- [x] `com.atproto.identity.updateHandle`
205205+206206+### Repo
207207+208208+- [x] `com.atproto.repo.applyWrites`
209209+- [x] `com.atproto.repo.createRecord`
210210+- [x] `com.atproto.repo.putRecord`
211211+- [x] `com.atproto.repo.deleteRecord`
212212+- [x] `com.atproto.repo.describeRepo`
213213+- [x] `com.atproto.repo.getRecord`
214214+- [x] `com.atproto.repo.importRepo` (Works "okay". Use with extreme caution.)
215215+- [x] `com.atproto.repo.listRecords`
216216+- [x] `com.atproto.repo.listMissingBlobs`
217217+218218+### Server
219219+220220+- [x] `com.atproto.server.activateAccount`
221221+- [x] `com.atproto.server.checkAccountStatus`
222222+- [x] `com.atproto.server.confirmEmail`
223223+- [x] `com.atproto.server.createAccount`
224224+- [x] `com.atproto.server.createInviteCode`
225225+- [x] `com.atproto.server.createInviteCodes`
226226+- [x] `com.atproto.server.deactivateAccount`
227227+- [x] `com.atproto.server.deleteAccount`
228228+- [x] `com.atproto.server.deleteSession`
229229+- [x] `com.atproto.server.describeServer`
230230+- [ ] `com.atproto.server.getAccountInviteCodes`
231231+- [x] `com.atproto.server.getServiceAuth`
232232+- ~~[ ] `com.atproto.server.listAppPasswords`~~ - not going to add app passwords
233233+- [x] `com.atproto.server.refreshSession`
234234+- [x] `com.atproto.server.requestAccountDelete`
235235+- [x] `com.atproto.server.requestEmailConfirmation`
236236+- [x] `com.atproto.server.requestEmailUpdate`
237237+- [x] `com.atproto.server.requestPasswordReset`
238238+- [x] `com.atproto.server.reserveSigningKey`
239239+- [x] `com.atproto.server.resetPassword`
240240+- ~~[] `com.atproto.server.revokeAppPassword`~~ - not going to add app passwords
241241+- [x] `com.atproto.server.updateEmail`
122421313-#### Identity
1414-- [ ] com.atproto.identity.getRecommendedDidCredentials
1515-- [ ] com.atproto.identity.requestPlcOperationSignature
1616-- [x] com.atproto.identity.resolveHandle
1717-- [ ] com.atproto.identity.signPlcOperation
1818-- [ ] com.atproto.identity.submitPlcOperatioin
1919-- [x] com.atproto.identity.updateHandle
243243+### Sync
244244+245245+- [x] `com.atproto.sync.getBlob`
246246+- [x] `com.atproto.sync.getBlocks`
247247+- [x] `com.atproto.sync.getLatestCommit`
248248+- [x] `com.atproto.sync.getRecord`
249249+- [x] `com.atproto.sync.getRepoStatus`
250250+- [x] `com.atproto.sync.getRepo`
251251+- [x] `com.atproto.sync.listBlobs`
252252+- [x] `com.atproto.sync.listRepos`
253253+- ~~[ ] `com.atproto.sync.notifyOfUpdate`~~ - BGS doesn't even have this implemented lol
254254+- [x] `com.atproto.sync.requestCrawl`
255255+- [x] `com.atproto.sync.subscribeRepos`
202562121-#### Repo
2222-- [x] com.atproto.repo.applyWrites
2323-- [x] com.atproto.repo.createRecord
2424-- [x] com.atproto.repo.putRecord
2525-- [ ] com.atproto.repo.deleteRecord
2626-- [x] com.atproto.repo.describeRepo
2727-- [x] com.atproto.repo.getRecord
2828-- [ ] com.atproto.repo.importRepo
2929-- [x] com.atproto.repo.listRecords
3030-- [ ] com.atproto.repo.listMissingBlobs
257257+### Other
312583232-#### Server
3333-- [ ] com.atproto.server.activateAccount
3434-- [ ] com.atproto.server.checkAccountStatus
3535-- [x] com.atproto.server.confirmEmail
3636-- [x] com.atproto.server.createAccount
3737-- [ ] com.atproto.server.deactivateAccount
3838-- [ ] com.atproto.server.deleteAccount
3939-- [x] com.atproto.server.deleteSession
4040-- [x] com.atproto.server.describeServer
4141-- [ ] com.atproto.server.getAccountInviteCodes
4242-- [ ] com.atproto.server.getServiceAuth
4343-- ~[ ] com.atproto.server.listAppPasswords~ - not going to add app passwords
4444-- [x] com.atproto.server.refreshSession
4545-- [ ] com.atproto.server.requestAccountDelete
4646-- [x] com.atproto.server.requestEmailConfirmation
4747-- [x] com.atproto.server.requestEmailUpdate
4848-- [x] com.atproto.server.requestPasswordReset
4949-- [ ] com.atproto.server.reserveSigningKey
5050-- [x] com.atproto.server.resetPassword
5151-- ~[ ] com.atproto.server.revokeAppPassword~ - not going to add app passwords
5252-- [x] com.atproto.server.updateEmail
259259+- [x] `com.atproto.label.queryLabels`
260260+- [x] `com.atproto.moderation.createReport` (Note: this should be handled by proxying, not actually implemented in the PDS)
261261+- [x] `app.bsky.actor.getPreferences`
262262+- [x] `app.bsky.actor.putPreferences`
532635454-#### Sync
5555-- [x] com.atproto.sync.getBlob
5656-- [x] com.atproto.sync.getBlocks
5757-- [x] com.atproto.sync.getLatestCommit
5858-- [x] com.atproto.sync.getRecord
5959-- [x] com.atproto.sync.getRepoStatus
6060-- [x] com.atproto.sync.getRepo
6161-- [x] com.atproto.sync.listBlobs
6262-- [x] com.atproto.sync.listRepos
6363-- ~[ ] com.atproto.sync.notifyOfUpdate~ - BGS doesn't even have this implemented lol
6464-- [x] com.atproto.sync.requestCrawl
6565-- [x] com.atproto.sync.subscribeRepos
264264+## License
662656767-#### Other
6868-- [ ] com.atproto.label.queryLabels
6969-- [ ] com.atproto.moderation.createReport
7070-- [x] app.bsky.actor.getPreferences
7171-- [x] app.bsky.actor.putPreferences
266266+This project is licensed under MIT license. `server/static/pico.css` is also licensed under MIT license, available at [https://github.com/picocss/pico/](https://github.com/picocss/pico/).
···1010// This is kinda lame. Not great to implement app.bsky in the pds, but alas
11111212func (s *Server) handleActorPutPreferences(e echo.Context) error {
1313+ ctx := e.Request().Context()
1414+1315 repo := e.Get("repo").(*models.RepoActor)
14161517 var prefs map[string]any
···2224 return err
2325 }
24262525- if err := s.db.Exec("UPDATE repos SET preferences = ? WHERE did = ?", b, repo.Repo.Did).Error; err != nil {
2727+ if err := s.db.Exec(ctx, "UPDATE repos SET preferences = ? WHERE did = ?", nil, b, repo.Repo.Did).Error; err != nil {
2628 return err
2729 }
2830