Skip to content

About

A local ATProto network in Docker for developing and testing against a real PDS, Jetstream, and DID registry without connecting to production Bluesky.

Topics

Resources

Stars

22 stars

Watchers

1 watching

Forks

Repository files navigation

atproto-devnet

A self-contained AT Protocol network for local development and CI. It runs on Docker Compose, so it works wherever Docker Compose does, and nothing in it touches Bluesky's production network.

The default stack is one PDS, a PLC, Jetstream and TAP. That is enough to create accounts, publish and read records, and test Jetstream consumers. Optional overlays add a PDS that supports permissioned spaces, two more PDS builds beside it, a local relay that crawls them all, and https names with a local CA. There is no AppView and no feed generator.

Services

npm run up starts the services below. In your own project, docker-compose.yml alone runs PDS, PLC, Jetstream, TAP and init, and your project provides Postgres and SMTP (see Integrating into your project).

Service Image Default Port Purpose
PDS ghcr.io/bluesky-social/pds:0.4 3000 Personal Data Server — stores repos, handles auth
PLC itaru2622/bluesky-did-method-plc 2582 DID registry — local resolution, no plc.directory dependency
Jetstream ghcr.io/bluesky-social/jetstream 6008 JSON event stream from PDS firehose
TAP ghcr.io/bluesky-social/indigo/tap 2480 Repo sync + backfill
init alpine:3.20 — One-shot: creates invite codes and seeds test accounts
Postgres postgres:16-alpine 5433 The PLC's database (docker-compose.test.yml)
MailDev maildev/maildev:latest 1081 web, 1026 SMTP Email for the PDS (docker-compose.test.yml)

The overlays add more services:

Quick start (standalone)

Run the devnet with its own Postgres and MailDev for self-contained testing:

git clone https://github.com/OpenMeet-Team/atproto-devnet.git
cd atproto-devnet
cp .env.example .env
npm install

# Start all services (devnet + test infrastructure)
npm run up

# Run the test suite
npm test

# Stop everything
npm run down

The suite checks health, account seeding, record CRUD, Jetstream events, the raw firehose, TAP tracking, and that no service points at Bluesky.

Upgrading from earlier versions

Changes to the default stack that existing setups will notice:

  • The PDS no longer forwards to Bluesky's AppView. app.bsky.* reads and any method the PDS doesn't implement now go to https://appview.invalid and fail with 502 UpstreamFailure. Bluesky's AppView never indexed devnet accounts, so for those accounts nothing useful is lost.
  • DID resolution works again on current pds:0.4 pulls. Since @atproto/pds 0.5.34, the PDS refuses to resolve DIDs through an http:// PLC unless SSRF protection is off, and the floating pds:0.4 tag now pulls 0.5.36. Without the fix, describeRepo, OAuth and anything else that resolves a new DID fails with Forbidden protocol "http:". The PDS now sets PDS_DISABLE_SSRF_PROTECTION=true, so it can also fetch private addresses, such as an OAuth client's metadata on your machine. The default stack still uses http. The https devnet keeps SSRF protection off too, for another reason: every address a PDS fetches from there (nginx, the PLC, your machine) is a private one, and the protected fetch refuses private addresses.
  • init reseeds when data/ is stale. data/ outlives npm run down. init used to skip seeding whenever data/accounts.json existed, leaving credentials for accounts that were gone. It now checks that the first recorded account exists on the running PDS and reseeds if it doesn't, so data/accounts.env gets new DIDs and a new invite code.
  • The PDS runs as root. Release images already did. This lets DEVNET_PDS_IMAGE take monorepo images, which default to node.
  • The https devnet's nginx config is a template now: rerun scripts/https-up.sh after pulling. https/nginx.conf became https/nginx.conf.template, which the nginx image fills in with DEVNET_DOMAIN when it starts. An nginx container made before the pull still mounts the old file, and once the file is gone that container cannot start again (Docker leaves an empty directory in its place). https-up.sh recreates it on the new mount. Checking out an older version needs the same rerun, the other way round. An overlay that mounts its own config over /etc/nginx/conf.d/default.conf should mount it at /etc/nginx/templates/default.conf.template instead, and one that replaces nginx's command must keep the image's entrypoint, which writes the template out.

Integrating into your project

atproto-devnet is designed to be composed into your project's Docker environment using Docker Compose file stacking. Clone it as a sibling directory and layer it with a thin overlay file in your project.

Prerequisites

Your project needs to provide two things that atproto-devnet expects from the network:

  1. PostgreSQL — PLC stores DIDs here (database name configurable via DEVNET_DB_NAME)
  2. SMTP server — PDS sends email verification through this (e.g., MailDev)

If your project already runs these, reuse them. If not, provide them in your overlay (see Open Social example below).

The pattern

parent/
├── your-project/
│   ├── docker-compose.yml          # Your existing services
│   ├── docker-compose-devnet.yml   # Overlay: bridges devnet into your network
│   └── scripts/
│       ├── devnet-up.sh            # Start everything
│       └── devnet-down.sh          # Stop everything
└── atproto-devnet/                 # This repo (cloned as sibling)
    ├── docker-compose.yml          # PDS, PLC, Jetstream, TAP, init
    └── .env                        # Port configuration

The overlay file does three things:

  1. Connects devnet services to your Docker network so containers can talk to each other
  2. Overrides environment variables to point devnet at your project's Postgres and SMTP
  3. Points your app at the local devnet instead of production Bluesky

Compose file stacking

Stack three files together — your base, the devnet, and your overlay:

docker compose \
  -f docker-compose.yml \
  -f ../atproto-devnet/docker-compose.yml \
  -f docker-compose-devnet.yml \
  --project-directory . \
  up -d

Compose resolves relative paths from your project, because your file comes first. That includes the devnet's own ./scripts and ./data, which the init service mounts. So your overlay must mount them from the devnet checkout:

services:
  init:
    volumes:
      - ../atproto-devnet/scripts:/scripts:ro
      - ../atproto-devnet/data:/devnet-data

Real-world examples

OpenMeet (reuses existing Postgres + MailDev)

OpenMeet's API already runs Postgres and MailDev. The overlay connects devnet services to OpenMeet's network and reuses its infrastructure.

docker-compose-devnet.yml (overlay):

services:
  # Connect PLC to OpenMeet's existing postgres
  plc:
    environment:
      DEVNET_DB_HOST: postgres
      DEVNET_DB_USER: ${DATABASE_USERNAME}
      DEVNET_DB_PASSWORD: ${DATABASE_PASSWORD}
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - api-network

  # Connect PDS to OpenMeet's existing maildev
  pds:
    environment:
      PDS_EMAIL_SMTP_URL: smtp://maildev:1025
    networks:
      - api-network

  # Join the network
  jetstream:
    networks:
      - api-network
  tap:
    networks:
      - api-network
  init:
    volumes:
      - ../atproto-devnet/scripts:/scripts:ro
      - ../atproto-devnet/data:/devnet-data
    networks:
      - api-network

  # Point the API at the local devnet
  api:
    environment:
      - PDS_URL=http://pds:3000
      - DID_PLC_URL=http://plc:2582

  # Point firehose consumer at local Jetstream
  bsky-firehose-consumer:
    environment:
      - BSKY_FIREHOSE_URL=ws://jetstream:6008/subscribe

networks:
  api-network:
    name: api-network

scripts/devnet-up.sh handles the full lifecycle:

  1. Starts all services via compose file stacking
  2. Waits for PDS health check
  3. Generates a fresh invite code (999,999 uses)
  4. Updates .env with the invite code
  5. Force-recreates the API container to pick up the new code

See the full implementation in openmeet-api: docker-compose-devnet.yml, scripts/devnet-up.sh and scripts/devnet-down.sh on its main branch.

Open Social (provides its own Postgres + MailDev)

Open Social doesn't have existing Postgres/MailDev services, so its overlay provides them — similar to docker-compose.test.yml but tailored to Open Social's needs.

Open Social stacks the files the other way round. Its scripts/start-test-env.sh passes the devnet's docker-compose.yml first and its own overlay second, with no --project-directory. Paths then resolve from the devnet checkout, so init finds its scripts. The overlay reaches Open Social's own files through OPENSOCIAL_DIR, which the script exports.

docker-compose.devnet.yml (overlay):

services:
  # Provide postgres for both PLC and Open Social
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${DEVNET_DB_USER:-postgres}
      POSTGRES_PASSWORD: ${DEVNET_DB_PASSWORD:-postgres}
      POSTGRES_DB: ${DEVNET_DB_NAME:-plc}
    volumes:
      - ${OPENSOCIAL_DIR}/scripts/init-opensocial-db.sh:/docker-entrypoint-initdb.d/10-opensocial.sh:ro
    ports:
      - "${DEVNET_POSTGRES_PORT:-5433}:5432"
    networks:
      - atproto-devnet
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DEVNET_DB_USER:-postgres}"]
      interval: 3s
      timeout: 3s
      retries: 10

  # Provide maildev for PDS email verification
  maildev:
    image: maildev/maildev:latest
    ports:
      - "${DEVNET_MAILDEV_WEB_PORT:-1081}:1080"
      - "${DEVNET_MAILDEV_SMTP_PORT:-1026}:1025"
    networks:
      - atproto-devnet

  # Wire PLC/PDS to our postgres/maildev
  plc:
    depends_on:
      postgres:
        condition: service_healthy
  pds:
    environment:
      PDS_EMAIL_SMTP_URL: smtp://maildev:1025

.env.devnet is checked into git with safe defaults — no secrets to manage:

DATABASE_URL=postgresql://postgres:postgres@localhost:5433/opensocial
PDS_URL=http://localhost:4000
PLC_URL=http://localhost:4001
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
COOKIE_SECRET=dev-cookie-secret

Open Social also includes a smoke test (test/devnet-smoke.test.ts) that creates a community via the API and verifies the ATProto records land on the local PDS.

See the full implementation: collectivesocial/open-social#18

atmo (the https devnet, with no devnet code in the app)

atmo is an events app. It runs on the https devnet as it would on the real network. Its server reaches each PDS and plc.directory at their https names, and the app has no devnet code or test switch. The devnet knows nothing about atmo, so everything below is made with the devnet's tools. The steps use the feat/groups-opensocial branch of tompscanlan/atmo-events, checked out beside the devnet as atmo-events/, and the opensocial.group proposal beside both as opensocial-proposal/. Start with the https devnet up.

Lexicons. Every devnet PDS resolves NSIDs only from the devnet's lexicon authority. atmo's OAuth scope includes two permission sets, rsvp.atmo.permissionSet and app.bsky.authCreatePosts. If the authority does not hold them, the PDS cannot expand the scope and the consent page fails. Copy both from their real authorities, then publish them, and the proposal's lexicons for groups. From the devnet checkout, outside https-run, because the copy reads the real plc.directory:

mkdir -p data/atmo-lexicons
for nsid in rsvp.atmo.permissionSet app.bsky.authCreatePosts; do
  authority=$(echo "$nsid" | awk -F. '{print $2"."$1}')      # rsvp.atmo.x -> atmo.rsvp
  did=$(dig +short TXT "_lexicon.$authority" | tr -d '"' | sed 's/^did=//')
  pds=$(curl -s "https://plc.directory/$did" |
    jq -r '.service[] | select(.id == "#atproto_pds") | .serviceEndpoint')
  curl -s "$pds/xrpc/com.atproto.repo.getRecord?repo=$did&collection=com.atproto.lexicon.schema&rkey=$nsid" |
    jq '.value | del(."$type")' > "data/atmo-lexicons/$nsid.json"
done
./scripts/https-lexicons.sh data/atmo-lexicons
./scripts/https-lexicons.sh ../opensocial-proposal/lexicons

An account. alice.devnet.test and bob.devnet.test are already there (see Seeded test accounts). To use another, make it with scripts/https-account.sh before you start atmo, because https-run maps only the handles it knows when the command starts.

Configure atmo. In atmo-events/apps/web, after pnpm install, write a git-ignored .env. It overrides the vars in wrangler.jsonc for vite dev. Wrangler reads a .dev.vars instead when one exists, so keep only one of the two:

OAUTH_PUBLIC_URL=
GROUP_PDS_SERVICE=https://alpha.devnet.test
GROUP_HANDLE_DOMAIN=devnet.test
  • OAUTH_PUBLIC_URL= is empty on purpose. wrangler.jsonc sets it to https://atmo.rsvp. Empty makes atmo use its loopback OAuth client, http://127.0.0.1:5454, which needs no keys and no public origin.
  • GROUP_PDS_SERVICE and GROUP_HANDLE_DOMAIN name where atmo would create group accounts: the alpha, the devnet PDS that serves spaces. Write the domain with no leading dot. atmo adds the dot itself, and .devnet.test gives handles like name..devnet.test, which the PDS refuses.

Then create atmo's tables in its local D1 database. Without them, atmo logs no such table: groups when it serves its OAuth client metadata.

pnpm exec wrangler d1 migrations apply DB --local

Run atmo under https-run, from atmo-events/apps/web:

../../atproto-devnet/scripts/https-run pnpm dev     # http://127.0.0.1:5454

Use it from a browser. Set up a browser on your computer as in From a browser on your own computer. Forward port 5454 to 5454 as well, since the loopback client's redirect URI names that port. Open http://127.0.0.1:5454 in that browser and sign in as alice.devnet.test, by handle or by DID, with her password from data/accounts.env. atmo sends you to https://alpha.devnet.test to sign in and approve, then back to atmo, signed in.

URL What answers
http://127.0.0.1:5454 atmo
https://alpha.devnet.test the alpha PDS: its sign-in and consent pages, and its xrpc
https://<handle>/.well-known/atproto-did the handle's PDS, with the handle's DID
https://plc.directory/<did> the devnet's PLC, with the DID document

Use it from the shell. The same names work under https-run. curl does not read NODE_EXTRA_CA_CERTS, so give it the CA. From the devnet checkout:

dn() { ./scripts/https-run curl -s --cacert data/https/ca.crt "$@"; }
dn https://alpha.devnet.test/xrpc/_health                # {"version":"0.5.32"}
dn https://alice.devnet.test/.well-known/atproto-did     # alice's DID
did=$(sed -n 's/^ALICE_DID=//p' data/accounts.env)
dn https://plc.directory/$did                            # her DID document, alsoKnownAs at://alice.devnet.test
dn "https://alpha.devnet.test/xrpc/com.atproto.repo.listRecords?repo=$did&collection=community.lexicon.calendar.event"

The last line lists the events atmo has written to alice's repo.

Groups: atmo's confidential client. With the loopback client, atmo's group page says group creation is not configured. To create and link a group, atmo needs its confidential OAuth client, at an https origin where the PDS can fetch its client metadata and keys. The devnet gives that origin with an app route. From the devnet checkout:

./scripts/https-app.sh atmo 5454                       # https://atmo.devnet.internal -> port 5454
./scripts/https-invite.sh GROUP_PDS_INVITE_CODE ../atmo-events/apps/web/.env

Then in atmo-events/apps/web, set OAUTH_PUBLIC_URL=https://atmo.devnet.internal in .env in place of the empty line, and add a client key. atmo's key script prints the key, so write it straight into the file:

printf "CLIENT_ASSERTION_KEY='%s'\n" \
  "$(npx tsx src/lib/atproto/scripts/generate-key.ts 2>/dev/null | tail -1)" >> .env

Run atmo where nginx can reach it, still under https-run:

__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=atmo.devnet.internal \
HTTPS_RUN_EXTRA_NAMES=mygroup.devnet.test \
  ../../atproto-devnet/scripts/https-run pnpm dev --host 172.18.0.1 --port 5454 --strictPort
  • --host replaces the 127.0.0.1 in atmo's vite.config.ts. Use the address that host.docker.internal has inside the containers; here it is the docker0 address. Check with docker exec <project>-nginx-1 getent hosts host.docker.internal.
  • __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS lets vite answer requests for atmo.devnet.internal.
  • HTTPS_RUN_EXTRA_NAMES maps, for atmo's process, the handle of the group you are about to create, since https-run reads its names only at start.

Open https://atmo.devnet.internal in the browser set up above. Its *.devnet.internal rule covers the name, and only port 443 needs forwarding. Sign in, then create the group under groups. The form mints the group's account on the alpha, then has you sign in as the group to link it. The groups e2e below creates a group the same way on every run, through atmo's own create.

The groups e2e. It makes its fixtures with the same tools. From the devnet checkout, after the lexicons above:

export ACCOUNTS_FILE=$PWD/data/atmo-e2e.env       # the test's logins, kept mode 600
./scripts/https-account.sh e2e-owner E2E_OWNER
./scripts/https-account.sh e2e-admin E2E_ADMIN
./scripts/https-account.sh e2e-outsider E2E_OUTSIDER
./scripts/https-account.sh e2enospaces E2E_NOSPACES regular   # a member on a PDS without spaces
unset ACCOUNTS_FILE
./scripts/https-invite.sh E2E_INVITE_CODE data/atmo-e2e.env   # each run mints its group with one use
# what the test reads: URLs, DIDs, the handle domain and where the secrets are; no secret
{ grep -E '^E2E_[A-Z]+_DID=' data/atmo-e2e.env
  echo "E2E_PDS=$(sed -n 's/^ALPHA_PDS_URL=//p' data/devnet.env)"
  echo "E2E_PLC_URL=$(sed -n 's/^PLC_URL=//p' data/devnet.env)"
  echo "E2E_HANDLE_DOMAIN=$(sed -n 's/^DEVNET_DOMAIN=//p' data/devnet.env)"
  echo "E2E_CREDENTIALS=$PWD/data/atmo-e2e.env"; } > data/atmo-e2e.fixtures.env

Then from atmo's apps/web, under https-run. Start it after the accounts exist, so their handles are mapped:

../../atproto-devnet/scripts/https-run sh -c \
  'set -a; . ../../atproto-devnet/data/atmo-e2e.fixtures.env; set +a; node scripts/groups-e2e.mjs'
# ...
# SUMMARY: 49 passed, 0 failed

The test refuses any host that is not this machine. Under https-run the devnet's names resolve to 127.0.0.1, so it admits them. Without https-run it refuses them before any login. The test is apps/web/scripts/groups-e2e.mjs on the feat/groups-opensocial branch of tompscanlan/atmo-events.

Seeded test accounts

When DEVNET_SEED_ACCOUNTS=true (the default), the init container creates two test accounts and writes their credentials to data/accounts.json and data/accounts.env:

Account Handle Password
Alice alice.devnet.test alice-devnet-pass
Bob bob.devnet.test bob-devnet-pass

Create additional accounts at any time:

./scripts/create-account.sh carol.devnet.test

It talks to http://localhost:3000. On the spaces stacks, set DEVNET_PDS_URL=http://localhost:3010. On the https devnet, use scripts/https-account.sh instead. It needs curl and jq, and it prints the new password.

Choosing a PDS version

DEVNET_PDS_IMAGE picks the PDS image, so you can run your app or the test suite against another release, an unreleased upstream commit, or the spaces alpha:

npm run down      # fresh volumes: a newer PDS migrates its database, and an older one can't read it
DEVNET_PDS_IMAGE=ghcr.io/bluesky-social/pds:0.4.5037 npm run up && npm test
  • Releases: ghcr.io/bluesky-social/pds:<version>, e.g. 0.4.5037, beta, latest. The default is 0.4.
  • Upstream commits: ghcr.io/bluesky-social/atproto:pds-<full commit sha>. All of the last 100 commits on main had one (checked 2026-10-05); commits on other branches may not.
  • Spaces alpha: see the next section.

Both kinds run here. The PDS runs as root because monorepo images default to node, which can't open the root-owned data volume. It also has PDS_DISABLE_SSRF_PROTECTION set, because builds that include upstream 1ff43e6e5 (2026-09-10) resolve DIDs through a fetch that refuses the local PLC's http:// URL.

The suite has passed on pds:0.4 (0.4.5036) and atproto:pds-cea6f5c4a034860c35eda03dbde207f5bdb9387f (0.5.36).

Spaces PDS and unpublished lexicons

docker-compose.spaces.yml swaps the PDS for the atproto permissioned-spaces alpha (com.atproto.space.*, com.atproto.simplespace.*) and lets it resolve lexicons that aren't published anywhere. That's what you need to test an OAuth space: scope naming your own space type, or an include: permission set, before the NSIDs resolve through DNS.

A PDS normally resolves an NSID through a _lexicon DNS TXT record. With PDS_LEXICON_AUTHORITY_DID set, it resolves every NSID from that one account instead, so the lexicons your app's scopes name have to be published there as com.atproto.lexicon.schema records, with the NSID as the record key.

Booting takes two passes, because the authority's DID only exists once the PDS is up:

F="-f docker-compose.yml -f docker-compose.test.yml -f docker-compose.spaces.yml"
docker compose $F up -d --wait
./scripts/lexicon-authority.sh            # prints DEVNET_LEXICON_AUTHORITY_DID=did:plc:...
DEVNET_LEXICON_AUTHORITY_DID=did:plc:... docker compose $F up -d --wait   # whole stack, not just pds
# then putRecord your lexicons into the authority account

npm run down names only the base files. Stop an overlay stack with the same files you started it with: docker compose $F down, and add -v to drop its volumes. This holds for the multi-PDS and relay stacks below too.

How this differs from the default stack:

  • PDS on 3010, at http://localhost:3010. The authority account lives on this PDS, and its DID document's endpoint has to reach the PDS both from inside its container (to resolve lexicons) and from the host. PDS_HOSTNAME=localhost makes the endpoint http://localhost:<PDS_PORT>, so the container and host ports must match. PDS_DEV_MODE turns off the SSRF protection that would refuse a localhost fetch. Override the port with DEVNET_SPACES_PDS_PORT.
  • Digest-pinned image. Set DEVNET_PDS_IMAGE to try another spaces build.

Observed on this image (revision 79d6307e): writes inside a space (createSpace, member entries, records in a space) don't appear on the PDS firehose or on Jetstream; only public repo records do.

Several PDS builds side by side

docker-compose.multi-pds.yml, stacked on the spaces overlay, adds two more PDSes so you can test how different builds work together. For example: can a member on a regular PDS join a group hosted on the spaces alpha?

F="-f docker-compose.yml -f docker-compose.test.yml -f docker-compose.spaces.yml -f docker-compose.multi-pds.yml"
docker compose $F up -d --wait      # then the two-pass authority step from the spaces section
Service URL Image setting Default Handles
pds http://localhost:3010 DEVNET_PDS_IMAGE spaces alpha 79d6307e .devnet.test
pds-regular http://localhost:3020 DEVNET_PDS_REGULAR_IMAGE pds:0.4.5037 .regular.devnet.test
pds-prod http://localhost:3030 DEVNET_PDS_PROD_IMAGE pds:0.4.5009 (by digest) .prod.devnet.test

Set DEVNET_PDS_PROD_IMAGE to whatever your production PDS runs.

Each PDS writes http://localhost:<port> into its accounts' DID documents. For one PDS to reach another's accounts, that URL has to work inside every PDS container as well as on the host, so the two extra PDSes share the alpha's network namespace (network_mode: service:pds). Other containers reach them as pds:3020 and pds:3030. The extra PDSes don't require invites, and each has its own service DID, since all three would otherwise be did:web:localhost. On their own, Jetstream and TAP follow only the alpha; add the local relay below to get one stream from all three.

A PDS forwards any XRPC method it doesn't implement (com.atproto.space.* on a non-spaces build, for one) to its AppView with a service-auth token. On a stock build that shows up as 502 UpstreamFailure, because every devnet PDS points its AppView at https://appview.invalid (see DEVNET_APPVIEW_URL).

A local relay

docker-compose.relay.yml, stacked after the multi-PDS overlay, adds a relay that crawls all three PDSes. Jetstream and TAP read from the relay, which is the shape production has:

PDSes --> relay (:2470) --> Jetstream (:6008)
                       \--> TAP (:2480)
F="$F -f docker-compose.relay.yml"
docker compose $F up -d --wait     # builds the relay image the first time

relay-init registers each PDS through the relay's admin API, because a relay won't accept a localhost host from a PDS's own requestCrawl. The relay and TAP join the PDSes' network namespace, so the http://localhost:<port> URLs in DID documents work for them as well.

The relay is built locally, not pulled. Upstream's relay (ghcr.io/bluesky-social/indigo:relay-<commit>) checks every host through an SSRF-safe transport that refuses loopback and private addresses, with no setting to turn it off. Its admin requestCrawl accepts localhost:<port>, and the host check then fails with unsafe network address, so the published image can never crawl a devnet PDS. relay/Dockerfile builds the same upstream commit with relay/allow-private-hosts.patch, which adds one opt-in setting, RELAY_ALLOW_PRIVATE_HOSTS, and copies the binary into the published image. The setting covers both places the relay refuses those addresses: the host check, and the firehose dialer for wss:// hosts, which the https devnet needs because its PDS names resolve to nginx's private address. Pick another commit with DEVNET_RELAY_INDIGO_COMMIT; it needs a published relay-<commit> image, and the patch has to apply.

The https devnet

The stacks above serve every PDS at http://localhost:<port>, so an app has to be told it is on a devnet before it will sign in there: allow plain http, and ask the devnet PLC instead of plc.directory. docker-compose.https.yml goes the other way. Stacked last on the five files above, it makes the whole spaces stack look like the real network, so an app built for the real network runs against it with no devnet code:

Name Served by
alpha.devnet.test, *.devnet.test pds, the spaces alpha (79d6307e), and its handles; invites required
regular.devnet.test, *.regular.devnet.test pds-regular, a current release, and its handles
prod.devnet.test, *.prod.devnet.test pds-prod, the build you run in prod, and its handles
plc.directory this stack's own PLC
<name>.devnet.internal an app on this machine, at the port scripts/https-app.sh recorded for <name>
*.<ending>.devnet.test the app that claimed <ending> with scripts/https-app.sh, for the handles it hands out

nginx answers all of them on 127.0.0.1:443 with one certificate from a local CA. Each PDS's public URL, OAuth issuer and DID document endpoint is https://<its name>, and the relay crawls the three PDSes as wss:// hosts. On the machine, the names resolve to 127.0.0.1 only for commands run under scripts/https-run, so every other program still reaches the real plc.directory.

devnet.test is the default of one setting, DEVNET_DOMAIN, and every name in the table above (and in the rest of this README) uses it. Only the https devnet reads it; the plain stack keeps DEVNET_PDS_HOSTNAME and DEVNET_HANDLE_DOMAIN. Set it in the environment or in .env and the same stack comes up under that domain: alpha.<domain> with handles at <name>.<domain>, regular.<domain>, prod.<domain>. plc.directory and <name>.devnet.internal stay as they are. In .env, write the domain itself (quotes, export and a trailing comment are fine); the scripts refuse a ${...} there, which compose would expand and they would not. A checkout keeps one domain, because its CA can vouch only for the domain it was made for (The CA); for a second domain, use a second checkout.

The devnet knows no app. It has no app's accounts, invite codes, lexicons or ports. It gives an app tools to make its own (below), and using them is optional.

Up and down

Every run names its compose project with DEVNET_PROJECT; there is no default. The scripts refuse the shared project devnet-spaces unless DEVNET_RESET_OK=1, and exit before calling docker.

export DEVNET_PROJECT=devnet-mine
./scripts/https-up.sh     # certificates, two-pass authority boot, data/devnet.env
./scripts/https-down.sh   # stop it; -v also drops its volumes (every account and DID)

Both pass the same six files, the https overlay last:

F="-f docker-compose.yml -f docker-compose.test.yml -f docker-compose.spaces.yml"
F="$F -f docker-compose.multi-pds.yml -f docker-compose.relay.yml -f docker-compose.https.yml"
docker compose -p "$DEVNET_PROJECT" $F ps

https-up.sh is safe to rerun; a rerun leaves the running services as they are, and only the one-shot init and relay-init run again. It makes the leaf certificate if there is none, brings the stack up, makes the lexicon authority (lex-authority.devnet.test on the alpha), brings the stack up again with DEVNET_LEXICON_AUTHORITY_DID so every PDS starts with it, and writes data/devnet.env. The base init still seeds alice and bob and a 100-use DEVNET_INVITE_CODE into data/accounts.env, as it does for the http stacks. Nothing else is seeded.

Published ports, all on 127.0.0.1 only: 443 (nginx), the PLC (DEVNET_PLC_PORT), Jetstream (DEVNET_JETSTREAM_PORT, DEVNET_JETSTREAM_METRICS_PORT) and maildev (DEVNET_MAILDEV_WEB_PORT, DEVNET_MAILDEV_SMTP_PORT). Set those to run a second https devnet beside another; pass the same values on every run. The PDS, relay, TAP and Postgres ports are not published: reach a PDS by its https name. Port 443 is one per machine, so only one https devnet can serve at a time.

https-down.sh passes its arguments to docker compose down and never deletes data/. The PLC's database is a named volume (plc-db), so a down without -v keeps every DID along with the PDSes. After -v, the next https-up.sh starts over: init reseeds alice and bob and rewrites data/accounts.env from scratch, so the logins the tools wrote there are gone along with their accounts (run https-account.sh and https-invite.sh again), and the authority is made again with a new DID.

What it writes

data/ is git-ignored:

  • data/devnet.env: the devnet's own facts, nothing secret. ALPHA_PDS_URL, REGULAR_PDS_URL, PROD_PDS_URL, PLC_URL (https://plc.directory), JETSTREAM_URL (ws://127.0.0.1:<port>), MAILDEV_URL, LEX_AUTHORITY_DID, LEX_AUTHORITY_HANDLE, DEVNET_PROJECT, DEVNET_DOMAIN and DEVNET_CA_FILE. An app can source it: set -a; . <devnet>/data/devnet.env; set +a.
  • data/accounts.env (mode 600): logins. DEVNET_INVITE_CODE, ALICE_* and BOB_* from init, LEX_AUTHORITY_*, and whatever the tools below add.
  • data/https/: ca.crt and ca.key (the CA), leaf.crt and leaf.key (what nginx serves), names (the handles https-run maps), apps and nginx/ (the app routes), and hosts (the hosts file https-run last generated).

Running a command against it: https-run

scripts/https-run getent hosts plc.directory     # 127.0.0.1 inside; the real address outside
(cd ../bookclub && ../atproto-devnet/scripts/https-run npm run dev)

https-run <cmd> runs the command with two changes, for its process tree only:

  • the three PDS names, plc.directory, every handle in data/https/names and every app in data/https/apps (as <name>.devnet.internal) resolve to 127.0.0.1. bubblewrap gives the command its own mount namespace, with a generated hosts file bound over /etc/hosts. Same filesystem, network and user otherwise. HTTPS_RUN_EXTRA_NAMES adds names.
  • Node trusts the devnet CA on top of its own roots (NODE_EXTRA_CA_CERTS=data/https/ca.crt). Miniflare's source passes the same file to workerd, so a Worker's fetch should trust it too.

The names are read when the command starts. Handles are listed one by one (there is no wildcard DNS), so restart a command under https-run to pick up a handle or app made after it started. Node's certificate checks stay on: https-run only adds a CA for them to trust.

Tools for an app

Each example uses a made-up app, bookclub. None of them prints a secret; a login or code goes only to the file named.

An account, on any of the three PDSes: scripts/https-account.sh <name> <PREFIX> [alpha|regular|prod].

scripts/https-account.sh bookclubowner BOOKCLUB_OWNER            # bookclubowner.devnet.test on the alpha
scripts/https-account.sh bookreader BOOKCLUB_READER regular      # bookreader.regular.devnet.test

It prints the DID alone, and a rerun finds the account instead of making another. The login goes to ACCOUNTS_FILE (default data/accounts.env, mode 600) as <PREFIX>_HANDLE, _DID, _PASSWORD and _PDS, and the handle goes to data/https/names. On the alpha it sends DEVNET_INVITE_CODE; the other two require no invite and are sent none. The labels alpha, regular and prod are refused on the alpha, where the handle would be another PDS's name.

An invite code on the alpha, for an app that creates accounts itself: scripts/https-invite.sh <VAR> [file].

scripts/https-invite.sh BOOKCLUB_INVITE_CODE ../bookclub/.env.devnet

It mints the code through the PDS's admin API (INVITE_USES, default 100) and writes <VAR>=<code> to the file (default data/accounts.env, mode 600), replacing an earlier line.

Lexicons. With PDS_LEXICON_AUTHORITY_DID set, every devnet PDS resolves every NSID from the authority account, never from DNS, so an app's record types, permission sets and space types have to be published there as com.atproto.lexicon.schema records, keyed by NSID. An app publishes its own:

scripts/https-lexicons.sh ../bookclub/lexicons    # prints each NSID: com.example.bookclub.review, ...

It publishes every lexicon JSON under the folder, at any depth, and leaves a record alone when it already holds the same document. Or publish with the app's own tooling: sign in with the LEX_AUTHORITY_* login in data/accounts.env and putRecord each document into com.atproto.lexicon.schema with the NSID as the record key.

An app route, so the app has an https origin that both the PDSes and https-run's processes reach: scripts/https-app.sh <name> <port>.

scripts/https-app.sh bookclub 5600     # https://bookclub.devnet.internal -> port 5600 on this machine

The app must listen where Docker's host gateway lands (host.docker.internal inside the containers; the docker0 address here), or on 0.0.0.0. The route is kept in data/https/apps (a rerun with the same name changes the port), and nginx in DEVNET_PROJECT's stack is reloaded. A .devnet.internal name with no route gets a 404 from nginx and reaches nothing. A confidential OAuth client can use https://bookclub.devnet.internal/... as its client_id: the PDS fetches its metadata and keys from there over TLS. A client_id under .test is refused by the PDS (invalid_client_id: The client_id's TLD must not be a local hostname), which is why apps live under .devnet.internal.

A handle ending, for an app that hands out handles of its own, as a PDS does. A third word claims one:

scripts/https-app.sh grouphost 7070 gh  # also https://<handle>.gh.devnet.test -> port 7070

nginx then sends every name under gh.devnet.test to the app, Host and all, so the app answers /.well-known/atproto-did for its handles and they resolve from anywhere on the devnet. The leaf gains *.gh.devnet.test from the same CA, so nothing has to be trusted again. alpha, regular and prod belong to the PDSes, and an ending belongs to one app. A rerun without the ending drops it. Under https-run, list each such handle in HTTPS_RUN_EXTRA_NAMES, as for any handle.

scripts/https-probe.mjs checks the whole path for any app name. It resolves an account through https://plc.directory with stock atcute at its defaults, serves a confidential client at https://<PROBE_APP>.devnet.internal/, pushes a PAR with that client_id, and pushes a raw PAR with a .test client_id, which the PDS refuses. ATCUTE_DIR is required: a directory node resolves @atcute/oauth-node-client and @atcute/identity-resolver from.

scripts/https-app.sh probe 5480
ATCUTE_DIR=<dir> PROBE_DID=<did> scripts/https-run node scripts/https-probe.mjs
RESOLVED did:plc:... on https://alpha.devnet.test through plc.directory, ... off
PAR ACCEPTED for confidential client https://probe.devnet.internal/probe-<run>/oauth-client-metadata.json
REFUSED .test client_id: HTTP 400 invalid_client_id: The client_id's TLD must not be a local hostname

How it differs from the http stacks

  • Each PDS at its https name. PDS_HOSTNAME is alpha.devnet.test, regular.devnet.test or prod.devnet.test, and the alpha's handle domain is set to .devnet.test explicitly (its default would be .alpha.devnet.test). The alpha runs with PDS_DEV_MODE=false, as it would on the real network; the two releases keep dev mode on. All three keep PDS_DISABLE_SSRF_PROTECTION, because everything they fetch here sits on an RFC 1918 address.
  • The PDSes reach each other over TLS. Inside the compose network, nginx also answers to the three PDS names, and each PDS trusts the CA through NODE_EXTRA_CA_CERTS. Docker's DNS has no wildcard names, so each PDS preloads https/app-names.cjs, which looks up any .devnet.internal name as nginx. Only the address changes: the request keeps the app's name, and TLS still checks the certificate for it.
  • nginx routes by name. Each PDS's block takes its name and its handle wildcard, and passes Host through, so the PDS answers /.well-known/atproto-did for a handle. Of two matching wildcards nginx takes the longer, so *.regular.devnet.test wins over *.devnet.test. A name nginx does not list gets no certificate at all.
  • The relay dials wss. relay-init registers the three bare names, which the relay treats as https hosts. Their addresses are nginx's, a private one, so the relay is built with relay/allow-private-hosts.patch, whose RELAY_ALLOW_PRIVATE_HOSTS covers the firehose dialer as well as the host check (see A local relay). The CA is mounted into /etc/ssl/certs, which Go reads on top of its bundle. RELAY_ALLOW_INSECURE_HOSTS is dropped. The image has its own tag, atproto-devnet-relay:https-<commit>. The relay's listHosts shows a host only once it has sent an event.
  • TAP resync is not supported. TAP follows the relay's stream, but it fetches a repo to resync through an SSRF-safe transport that refuses nginx's address, so a resync fails here.
  • Jetstream is unchanged, at ws://127.0.0.1:<DEVNET_JETSTREAM_PORT>, reading the relay over the compose network.

The CA

scripts/https-ca.sh makes the CA once. https-up.sh runs it when there is no leaf, or when the leaf does not name *.devnet.internal. Rerunning it keeps the CA and only reissues the leaf, so a browser or OS that trusts ca.crt keeps trusting the devnet. The leaf names the three PDSes, *.devnet.test, *.regular.devnet.test, *.prod.devnet.test, plc.directory, *.devnet.internal, and *.<ending>.devnet.test for each handle ending an app claimed; a TLS wildcard covers one label, so each handle domain needs its own. https-app.sh reissues the leaf when it claims an ending, and https-up.sh when the leaf lacks any of these names. Add deeper names with HTTPS_EXTRA_NAMES="x.mine.devnet.test" on every run, then restart nginx. A name one label under devnet.test is already covered. To start over, delete data/https/ca.* and trust the new ca.crt wherever the old one was trusted.

The CA can only vouch for servers under devnet.test (the DEVNET_DOMAIN it was made for), devnet.internal and plc.directory (name constraints, server auth only, no IP addresses), so trusting it puts no other site at risk, even if data/https/ca.key leaks. Keep that file on the dev machine, and remove the CA from a browser or OS when you're done with it.

From a browser on your own computer

nginx runs in the dev container. To use the devnet from a browser on the host:

  1. Forward port 443, in VS Code's Ports view: container 443 to host 443 exactly. The PDS's URLs carry no port, and VS Code quietly picks another local port when 443 is taken, so check the forwarded address. Forward your app's own port too if the browser opens it directly.

  2. Map the names to 127.0.0.1 for the browser. Start a separate Chrome or Edge instance with its own profile directory, so the flag takes effect even when the browser is already open:

    rem cmd.exe; in PowerShell, write $env:TEMP for %TEMP%
    chrome.exe --user-data-dir=%TEMP%\devnet-https --host-resolver-rules="MAP *.devnet.test 127.0.0.1, MAP plc.directory 127.0.0.1, MAP *.devnet.internal 127.0.0.1"
    

    A wildcard rule also covers the three PDS names. Hosts-file entries work too, one name at a time; they need admin, and they point plc.directory at the devnet for every program on the computer until you remove the line.

  3. Trust the CA, once. If your host already trusts this ca.crt, there is nothing to do: https-ca.sh keeps the CA. Otherwise copy data/https/ca.crt to the host and add it to your user's root store: certutil -user -addstore Root ca.crt (no admin; Windows asks you to confirm). Remove it later with certutil -user -delstore Root "atproto-devnet local CA". Clicking through a certificate warning instead covers only the page you clicked through, not the requests an app's code in the browser makes to plc.directory or a PDS.

Worked scenarios

sandbox/opensocial is a cookbook that runs on this stack: all of the overlays above. It starts from a clean checkout and runs probes for:

  • OAuth space: scopes that name a space type;
  • members on other PDS builds joining a group;
  • a group's members-only events kept in a space;
  • private RSVPs, and who can read them.

Each probe prints its requests and a list of verdicts. The README records what we saw, so you can compare a run against it.

When the bug is inside the PDS: dev-env

devnet runs published images, so it's the right place to see how your app behaves against a real PDS, PLC and Jetstream. To step through PDS code, run a branch with your own edits, or test several PDSes talking to each other, use atproto's in-process network instead: packages/dev-env in the atproto monorepo. It builds PLC and PDSes from source in one Node process. On the permissioned-data* branches, bin-multi-pds starts three PDSes with a lexicon authority already wired in, and you can attach a debugger to it.

A worked example is sandbox/opensocial. It has a script that publishes a set of unpublished lexicons and a probe that signs in with OAuth space: scopes naming them, and both run against either dev-env or this repo's spaces overlay.

Configuration

All settings have sensible defaults. Override via .env or environment variables:

Infrastructure (consumer provides)

Variable Default Description
DEVNET_DB_USER postgres PostgreSQL username
DEVNET_DB_PASSWORD postgres PostgreSQL password
DEVNET_DB_HOST postgres PostgreSQL hostname
DEVNET_DB_PORT 5432 PostgreSQL port
DEVNET_DB_NAME plc Database name for PLC
DEVNET_SMTP_URL smtp://maildev:1025 SMTP server for PDS email. Under npm run up, the test overlay sets the PDS's SMTP URL to smtp://maildev:1025 directly, so this has no effect there

PDS settings

Variable Default Description
DEVNET_DOMAIN devnet.test The https devnet's domain, which every name there sits under (The https devnet). The plain stack does not read it
DEVNET_PDS_HOSTNAME devnet.test PDS service hostname (the https devnet names its PDSes itself)
DEVNET_PDS_IMAGE ghcr.io/bluesky-social/pds:0.4 PDS image (see Choosing a PDS version)
DEVNET_PDS_ADMIN_PASSWORD devnet-admin-password PDS admin password
DEVNET_APPVIEW_URL / DEVNET_APPVIEW_DID https://appview.invalid / did:example:invalid Where the PDS forwards app.bsky.* reads and methods it doesn't implement. Unresolvable, so nothing reaches Bluesky
DEVNET_REPORT_SERVICE_URL / DEVNET_REPORT_SERVICE_DID https://moderator.invalid / did:example:invalid Where reports go
DEVNET_HANDLE_DOMAIN .devnet.test Handle suffix for accounts (the https devnet uses . + DEVNET_DOMAIN)
DEVNET_SEED_ACCOUNTS true Create alice/bob on startup
DEVNET_PDS_REGULAR_IMAGE / DEVNET_PDS_PROD_IMAGE ghcr.io/bluesky-social/pds:0.4.5037 / a pinned production digest The two extra PDS builds (Several PDS builds side by side)
DEVNET_LEXICON_AUTHORITY_DID empty The spaces PDS's lexicon authority (Spaces PDS and unpublished lexicons)
DEVNET_RELAY_INDIGO_COMMIT b2619d8... The indigo commit the relay image builds from
DEVNET_RELAY_ADMIN_PASSWORD devnet-relay-admin The relay's admin password

Host port mapping

Variable Default Description
DEVNET_PDS_PORT 3000 PDS
DEVNET_PLC_PORT 2582 PLC
DEVNET_JETSTREAM_PORT 6008 Jetstream WebSocket
DEVNET_JETSTREAM_METRICS_PORT 6009 Jetstream metrics
DEVNET_TAP_PORT 2480 TAP
DEVNET_SPACES_PDS_PORT 3010 The spaces PDS. DEVNET_PDS_PORT applies to the default PDS only, and pds-regular and pds-prod always use 3020 and 3030
DEVNET_RELAY_PORT 2470 Relay
DEVNET_POSTGRES_PORT 5433 Postgres (npm run up only)
DEVNET_MAILDEV_WEB_PORT / DEVNET_MAILDEV_SMTP_PORT 1081 / 1026 MailDev (npm run up only)

create-account.sh and the test suite reach the PDS at DEVNET_PDS_URL (default http://localhost:3000). lexicon-authority.sh defaults it to the spaces PDS on 3010. The tests also read DEVNET_PLC_URL, DEVNET_JETSTREAM_WS_URL, DEVNET_JETSTREAM_METRICS_URL and DEVNET_TAP_URL, each defaulting to localhost on the ports above.

npm scripts

Script Description
npm run up Start devnet + test infrastructure (standalone mode)
npm run down Stop and remove all containers and volumes
npm run logs Tail logs from all services
npm test Run the full test suite
npm run test:watch Run tests in watch mode
npm run test:health Run health checks only

Test suite

The test suite validates all major operations against a running devnet:

Test file What it validates
health.test.ts All services respond to health checks
account.test.ts Alice and Bob accounts were seeded correctly
record-crud.test.ts Create, read, list, and delete ATProto records
jetstream.test.ts JSON event stream delivers commit events, supports filtering
firehose.test.ts Raw PDS firehose emits CBOR events
isolation.test.ts PDS, Jetstream and TAP point only at the local PLC and the local PDS or relay, with no crawlers and no Bluesky AppView. It checks settings, not traffic
tap.test.ts TAP accepts a DID to track. Live sync is not tested yet

License

MIT

About

A local ATProto network in Docker for developing and testing against a real PDS, Jetstream, and DID registry without connecting to production Bluesky.

Topics

Resources

Stars

22 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages