services:
postgres:
profiles: [dev]
# `!override` replaces the base file's `5432:5432` publish instead of adding to it:
# binding both `0.0.0.0:5432` and `127.0.0.1:5432` on one container EADDRINUSEs.
ports: !override
- "127.0.0.1:${DEV_POSTGRES_PORT:-5432}:5432"
redis:
profiles: [dev]
ports: !override
- "127.0.0.1:${DEV_REDIS_PORT:-6379}:6379"
minio:
profiles: [dev]
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "127.0.0.1:${DEV_MINIO_PORT:-9000}:9000"
- "127.0.0.1:${DEV_MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- dev_minio_data:/data
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live >/dev/null"]
interval: 5s
timeout: 5s
retries: 20
minio-create-bucket:
profiles: [dev]
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
depends_on:
minio:
condition: service_healthy
environment:
WISP_PUBLIC_BUCKET: ${WISP_PUBLIC_BUCKET:-wisp-dev}
WISP_PRIVATE_BUCKET: ${WISP_PRIVATE_BUCKET:-wisp-private-dev}
entrypoint:
- /bin/sh
- -c
- |
mc alias set local http://minio:9000 minioadmin minioadmin
mc mb --ignore-existing "local/$${WISP_PUBLIC_BUCKET}"
mc mb --ignore-existing "local/$${WISP_PRIVATE_BUCKET}"
plc-postgres:
profiles: [dev]
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: plc
volumes:
- dev_plc_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d plc"]
interval: 5s
timeout: 5s
retries: 20
plc:
profiles: [dev]
image: itaru2622/bluesky-did-method-plc:latest
depends_on:
plc-postgres:
condition: service_healthy
entrypoint: >
sh -c "
export DB_CREDS_JSON=$$(printf '{\"username\":\"postgres\",\"password\":\"postgres\",\"host\":\"plc-postgres\",\"port\":\"5432\",\"database\":\"plc\"}')
export DB_MIGRATE_CREDS_JSON=$$DB_CREDS_JSON
exec node /app/packages/server/service/index.js
"
environment:
PORT: "2582"
DATABASE_URL: postgres://postgres:postgres@plc-postgres:5432/plc
ENABLE_MIGRATIONS: "true"
LOG_ENABLED: "true"
LOG_LEVEL: info
NODE_ENV: development
ports:
- "127.0.0.1:${DEV_PLC_PORT:-2582}:2582"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:2582/_health"]
interval: 5s
timeout: 3s
retries: 20
maildev:
profiles: [dev]
image: maildev/maildev:2.2.1
ports:
- "127.0.0.1:${DEV_MAILDEV_PORT:-1080}:1080"
pds:
profiles: [dev]
image: ghcr.io/bluesky-social/pds:0.4
depends_on:
plc:
condition: service_healthy
maildev:
condition: service_started
environment:
PDS_PORT: "3300"
PDS_HOSTNAME: localhost
PDS_DATA_DIRECTORY: /pds/data
PDS_BLOBSTORE_DISK_LOCATION: /pds/data/blocks
PDS_DID_PLC_URL: http://plc:2582
PDS_BSKY_APP_VIEW_URL: https://api.bsky.app
PDS_BSKY_APP_VIEW_DID: did:web:api.bsky.app
PDS_CRAWLERS: ""
PDS_INVITE_REQUIRED: "true"
PDS_ADMIN_PASSWORD: wisp-dev-admin-password
PDS_JWT_SECRET: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
PDS_DPOP_SECRET: abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
PDS_EMAIL_SMTP_URL: smtp://maildev:1025
PDS_EMAIL_FROM_ADDRESS: noreply@localhost
PDS_SERVICE_HANDLE_DOMAINS: .test
PDS_DEV_MODE: "true"
PDS_DISABLE_SSRF_PROTECTION: "true"
LOG_ENABLED: "true"
NODE_ENV: development
volumes:
- dev_pds_data:/pds/data
ports:
- "127.0.0.1:${DEV_PDS_PORT:-3300}:3300"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3300/xrpc/_health"]
interval: 5s
timeout: 3s
retries: 30
# The PDS proxies XRPC to the Wisp service by resolving its `did:web` document.
# @atproto/identity only allows plain HTTP when the hostname is exactly `localhost`,
# which inside the PDS container means the container itself. This sidecar shares the
# PDS's network namespace and forwards that port to the host, so `did:web:localhost%3A8000`
# reaches the Wisp service running on the developer's machine. Production uses a real
# HTTPS `did:web`, so this exists only to make the same code path testable locally.
pds-host-bridge:
profiles: [dev]
image: alpine/socat:1.8.0.0
depends_on:
pds:
condition: service_healthy
network_mode: "service:pds"
# Both loopback families are bridged: Node may resolve `localhost` to either, and a
# listener on only one of them fails intermittently depending on resolution order.
# The image's entrypoint is socat itself, so it is overridden to run both listeners.
entrypoint: ["/bin/sh", "-c"]
command:
- >-
socat TCP4-LISTEN:8000,fork,reuseaddr,bind=127.0.0.1 TCP:host.docker.internal:8000 &
socat TCP6-LISTEN:8000,fork,reuseaddr,bind=[::1] TCP:host.docker.internal:8000
restart: unless-stopped
pds-init:
profiles: [dev]
image: alpine:3.20
depends_on:
pds:
condition: service_healthy
environment:
PDS_URL: http://pds:3300
PDS_ADMIN_PASSWORD: wisp-dev-admin-password
ALICE_HANDLE: alice.test
ALICE_PASSWORD: alice-dev-password
BOB_HANDLE: bob.test
BOB_PASSWORD: bob-dev-password
volumes:
- ./scripts/dev-pds-init.sh:/scripts/dev-pds-init.sh:ro
- ./.dev/atproto:/devnet-data
entrypoint: /bin/sh
command: ["/scripts/dev-pds-init.sh"]
restart: "no"
volumes:
dev_minio_data:
dev_plc_postgres_data:
dev_pds_data: