An atproto PDS written in Go

add docker-compose, caddy (#35)

* add docker compose, caddy

* tweaks

* more tweaks

* tweak

* fix gh action

+1 -1
.env.example
···
COCOON_RELAYS=https://bsky.network
# Generate with `openssl rand -hex 16`
COCOON_ADMIN_PASSWORD=
-
# openssl rand -hex 32
COCOON_SESSION_SECRET=
···
COCOON_RELAYS=https://bsky.network
# Generate with `openssl rand -hex 16`
COCOON_ADMIN_PASSWORD=
+
# Generate with `openssl rand -hex 32`
COCOON_SESSION_SECRET=
+2 -2
.github/workflows/docker-image.yml
···
on:
workflow_dispatch:
-
push:
env:
REGISTRY: ghcr.io
···
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
-
push-to-registry: true
···
on:
workflow_dispatch:
+
push: main
env:
REGISTRY: ghcr.io
···
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
+
push-to-registry: true
+2
.gitignore
···
*.key
*.secret
.DS_Store
···
*.key
*.secret
.DS_Store
+
data/
+
keys/
+10
Caddyfile
···
···
+
{$COCOON_HOSTNAME} {
+
reverse_proxy localhost:8080
+
+
encode gzip
+
+
log {
+
output file /data/access.log
+
format json
+
}
+
}
+132 -1
README.md
···
# Cocoon
> [!WARNING]
-
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.
Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use.
## Implemented Endpoints
···
# 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.
+
+
## 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
+
+
1. **Clone the repository**
+
```bash
+
git clone https://github.com/haileyok/cocoon.git
+
cd cocoon
+
```
+
+
2. **Create your configuration file**
+
```bash
+
cp .env.example .env
+
```
+
+
3. **Edit `.env` with your settings**
+
+
Required settings:
+
```bash
+
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"
+
```
+
+
4. **Start the services**
+
```bash
+
# Pull pre-built image from GitHub Container Registry
+
docker-compose pull
+
docker-compose up -d
+
```
+
+
Or build locally:
+
```bash
+
docker-compose build
+
docker-compose up -d
+
```
+
+
5. **Get your invite code**
+
+
On first run, an invite code is automatically created. View it with:
+
```bash
+
docker-compose logs create-invite
+
```
+
+
Or check the saved file:
+
```bash
+
cat keys/initial-invite-code.txt
+
```
+
+
**IMPORTANT**: Save this invite code! You'll need it to create your first account.
+
+
6. **Monitor the services**
+
```bash
+
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
+
+
#### SMTP Email Settings
+
```bash
+
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
+
```bash
+
COCOON_S3_BACKUPS_ENABLED=true
+
COCOON_S3_BLOBSTORE_ENABLED=true
+
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"
+
```
+
+
### Management Commands
+
+
Create an invite code:
+
```bash
+
docker exec cocoon-pds /cocoon create-invite-code --uses 1
+
```
+
+
Reset a user's password:
+
```bash
+
docker exec cocoon-pds /cocoon reset-password --did "did:plc:xxx"
+
```
+
+
### Updating
+
+
```bash
+
docker-compose pull
+
docker-compose up -d
+
```
## Implemented Endpoints
+26 -39
cmd/cocoon/main.go
···
EnvVars: []string{"COCOON_DB_NAME"},
},
&cli.StringFlag{
-
Name: "did",
-
Required: true,
-
EnvVars: []string{"COCOON_DID"},
},
&cli.StringFlag{
-
Name: "hostname",
-
Required: true,
-
EnvVars: []string{"COCOON_HOSTNAME"},
},
&cli.StringFlag{
-
Name: "rotation-key-path",
-
Required: true,
-
EnvVars: []string{"COCOON_ROTATION_KEY_PATH"},
},
&cli.StringFlag{
-
Name: "jwk-path",
-
Required: true,
-
EnvVars: []string{"COCOON_JWK_PATH"},
},
&cli.StringFlag{
-
Name: "contact-email",
-
Required: true,
-
EnvVars: []string{"COCOON_CONTACT_EMAIL"},
},
&cli.StringSliceFlag{
-
Name: "relays",
-
Required: true,
-
EnvVars: []string{"COCOON_RELAYS"},
},
&cli.StringFlag{
-
Name: "admin-password",
-
Required: true,
-
EnvVars: []string{"COCOON_ADMIN_PASSWORD"},
},
&cli.StringFlag{
-
Name: "smtp-user",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_USER"},
},
&cli.StringFlag{
-
Name: "smtp-pass",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_PASS"},
},
&cli.StringFlag{
-
Name: "smtp-host",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_HOST"},
},
&cli.StringFlag{
-
Name: "smtp-port",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_PORT"},
},
&cli.StringFlag{
-
Name: "smtp-email",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_EMAIL"},
},
&cli.StringFlag{
-
Name: "smtp-name",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_NAME"},
},
&cli.BoolFlag{
Name: "s3-backups-enabled",
···
EnvVars: []string{"COCOON_DB_NAME"},
},
&cli.StringFlag{
+
Name: "did",
+
EnvVars: []string{"COCOON_DID"},
},
&cli.StringFlag{
+
Name: "hostname",
+
EnvVars: []string{"COCOON_HOSTNAME"},
},
&cli.StringFlag{
+
Name: "rotation-key-path",
+
EnvVars: []string{"COCOON_ROTATION_KEY_PATH"},
},
&cli.StringFlag{
+
Name: "jwk-path",
+
EnvVars: []string{"COCOON_JWK_PATH"},
},
&cli.StringFlag{
+
Name: "contact-email",
+
EnvVars: []string{"COCOON_CONTACT_EMAIL"},
},
&cli.StringSliceFlag{
+
Name: "relays",
+
EnvVars: []string{"COCOON_RELAYS"},
},
&cli.StringFlag{
+
Name: "admin-password",
+
EnvVars: []string{"COCOON_ADMIN_PASSWORD"},
},
&cli.StringFlag{
+
Name: "smtp-user",
+
EnvVars: []string{"COCOON_SMTP_USER"},
},
&cli.StringFlag{
+
Name: "smtp-pass",
+
EnvVars: []string{"COCOON_SMTP_PASS"},
},
&cli.StringFlag{
+
Name: "smtp-host",
+
EnvVars: []string{"COCOON_SMTP_HOST"},
},
&cli.StringFlag{
+
Name: "smtp-port",
+
EnvVars: []string{"COCOON_SMTP_PORT"},
},
&cli.StringFlag{
+
Name: "smtp-email",
+
EnvVars: []string{"COCOON_SMTP_EMAIL"},
},
&cli.StringFlag{
+
Name: "smtp-name",
+
EnvVars: []string{"COCOON_SMTP_NAME"},
},
&cli.BoolFlag{
Name: "s3-backups-enabled",
+56
create-initial-invite.sh
···
···
+
#!/bin/sh
+
+
INVITE_FILE="/keys/initial-invite-code.txt"
+
MARKER="/keys/.invite_created"
+
+
# Check if invite code was already created
+
if [ -f "$MARKER" ]; then
+
echo "✓ Initial invite code already created"
+
exit 0
+
fi
+
+
echo "Waiting for database to be ready..."
+
sleep 10
+
+
# Try to create invite code - retry until database is ready
+
MAX_ATTEMPTS=30
+
ATTEMPT=0
+
INVITE_CODE=""
+
+
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
+
ATTEMPT=$((ATTEMPT + 1))
+
OUTPUT=$(/cocoon create-invite-code --uses 1 2>&1)
+
INVITE_CODE=$(echo "$OUTPUT" | grep -oE '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{8}' || echo "")
+
+
if [ -n "$INVITE_CODE" ]; then
+
break
+
fi
+
+
if [ $((ATTEMPT % 5)) -eq 0 ]; then
+
echo " Waiting for database... ($ATTEMPT/$MAX_ATTEMPTS)"
+
fi
+
sleep 2
+
done
+
+
if [ -n "$INVITE_CODE" ]; then
+
echo ""
+
echo "╔════════════════════════════════════════╗"
+
echo "║ SAVE THIS INVITE CODE! ║"
+
echo "║ ║"
+
echo "║ $INVITE_CODE ║"
+
echo "║ ║"
+
echo "║ Use this to create your first ║"
+
echo "║ account on your PDS. ║"
+
echo "╚════════════════════════════════════════╝"
+
echo ""
+
+
echo "$INVITE_CODE" > "$INVITE_FILE"
+
echo "✓ Invite code saved to: $INVITE_FILE"
+
+
touch "$MARKER"
+
echo "✓ Initial setup complete!"
+
else
+
echo "✗ Failed to create invite code"
+
echo "Output: $OUTPUT"
+
exit 1
+
fi
+125
docker-compose.yaml
···
···
+
version: '3.8'
+
+
services:
+
init-keys:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
image: ghcr.io/haileyok/cocoon:latest
+
container_name: cocoon-init-keys
+
volumes:
+
- ./keys:/keys
+
- ./data:/data/cocoon
+
- ./init-keys.sh:/init-keys.sh:ro
+
environment:
+
COCOON_DID: ${COCOON_DID}
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
COCOON_ROTATION_KEY_PATH: /keys/rotation.key
+
COCOON_JWK_PATH: /keys/jwk.key
+
COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL}
+
COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network}
+
COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD}
+
entrypoint: ["/bin/sh", "/init-keys.sh"]
+
restart: "no"
+
+
cocoon:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
image: ghcr.io/haileyok/cocoon:latest
+
container_name: cocoon-pds
+
network_mode: host
+
depends_on:
+
init-keys:
+
condition: service_completed_successfully
+
volumes:
+
- ./data:/data/cocoon
+
- ./keys/rotation.key:/keys/rotation.key:ro
+
- ./keys/jwk.key:/keys/jwk.key:ro
+
environment:
+
# Required settings
+
COCOON_DID: ${COCOON_DID}
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
COCOON_ROTATION_KEY_PATH: /keys/rotation.key
+
COCOON_JWK_PATH: /keys/jwk.key
+
COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL}
+
COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network}
+
COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD}
+
COCOON_SESSION_SECRET: ${COCOON_SESSION_SECRET}
+
+
# Server configuration
+
COCOON_ADDR: ":8080"
+
COCOON_DB_NAME: /data/cocoon/cocoon.db
+
COCOON_BLOCKSTORE_VARIANT: ${COCOON_BLOCKSTORE_VARIANT:-sqlite}
+
+
# Optional: SMTP settings for email
+
COCOON_SMTP_USER: ${COCOON_SMTP_USER:-}
+
COCOON_SMTP_PASS: ${COCOON_SMTP_PASS:-}
+
COCOON_SMTP_HOST: ${COCOON_SMTP_HOST:-}
+
COCOON_SMTP_PORT: ${COCOON_SMTP_PORT:-}
+
COCOON_SMTP_EMAIL: ${COCOON_SMTP_EMAIL:-}
+
COCOON_SMTP_NAME: ${COCOON_SMTP_NAME:-}
+
+
# Optional: S3 configuration
+
COCOON_S3_BACKUPS_ENABLED: ${COCOON_S3_BACKUPS_ENABLED:-false}
+
COCOON_S3_BLOBSTORE_ENABLED: ${COCOON_S3_BLOBSTORE_ENABLED:-false}
+
COCOON_S3_REGION: ${COCOON_S3_REGION:-}
+
COCOON_S3_BUCKET: ${COCOON_S3_BUCKET:-}
+
COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-}
+
COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-}
+
COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-}
+
+
# Optional: Fallback proxy
+
COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-}
+
restart: unless-stopped
+
healthcheck:
+
test: ["CMD", "curl", "-f", "http://localhost:8080/xrpc/_health"]
+
interval: 30s
+
timeout: 10s
+
retries: 3
+
start_period: 40s
+
+
create-invite:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
image: ghcr.io/haileyok/cocoon:latest
+
container_name: cocoon-create-invite
+
network_mode: host
+
volumes:
+
- ./keys:/keys
+
- ./create-initial-invite.sh:/create-initial-invite.sh:ro
+
environment:
+
COCOON_DID: ${COCOON_DID}
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
COCOON_ROTATION_KEY_PATH: /keys/rotation.key
+
COCOON_JWK_PATH: /keys/jwk.key
+
COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL}
+
COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network}
+
COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD}
+
COCOON_DB_NAME: /data/cocoon/cocoon.db
+
depends_on:
+
- init-keys
+
entrypoint: ["/bin/sh", "/create-initial-invite.sh"]
+
restart: "no"
+
+
caddy:
+
image: caddy:2-alpine
+
container_name: cocoon-caddy
+
network_mode: host
+
volumes:
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
+
- caddy_data:/data
+
- caddy_config:/config
+
restart: unless-stopped
+
environment:
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
CADDY_ACME_EMAIL: ${COCOON_CONTACT_EMAIL:-}
+
+
volumes:
+
data:
+
driver: local
+
caddy_data:
+
driver: local
+
caddy_config:
+
driver: local
+34
init-keys.sh
···
···
+
#!/bin/sh
+
set -e
+
+
mkdir -p /keys
+
mkdir -p /data/cocoon
+
+
if [ ! -f /keys/rotation.key ]; then
+
echo "Generating rotation key..."
+
/cocoon create-rotation-key --out /keys/rotation.key 2>/dev/null || true
+
if [ -f /keys/rotation.key ]; then
+
echo "✓ Rotation key generated at /keys/rotation.key"
+
else
+
echo "✗ Failed to generate rotation key"
+
exit 1
+
fi
+
else
+
echo "✓ Rotation key already exists"
+
fi
+
+
if [ ! -f /keys/jwk.key ]; then
+
echo "Generating JWK..."
+
/cocoon create-private-jwk --out /keys/jwk.key 2>/dev/null || true
+
if [ -f /keys/jwk.key ]; then
+
echo "✓ JWK generated at /keys/jwk.key"
+
else
+
echo "✗ Failed to generate JWK"
+
exit 1
+
fi
+
else
+
echo "✓ JWK already exists"
+
fi
+
+
echo ""
+
echo "✓ Key initialization complete!"