Self-Hosting a Bluesky PDS on Miren
Bluesky runs on the AT Protocol, and the nice thing about AT Protocol is the Personal Data Server concept: a thing you can actually run yourself. Your repo, your keys, your posts, your server. The account still federates into the wider network; it just doesn’t live on someone else’s box.
The catch has always been the ops. The official self-hosting guide hands you a Docker image, a Caddy config for on-demand TLS, a pile of secrets to generate, and some DNS you have to get exactly right. None of it is hard, exactly, but it’s a lot of small fiddly things that each have to be correct before you get a login screen.
So we tried it a different way: point Claude Code at an empty directory, lean on the Miren skills, and see how far the two of them could get a PDS onto miren.team, a domain we own. They got us all the way to a live account on the ATmosphere. Here’s the real run, including a few hiccups along the way.
What Claude scaffolded
The whole thing started with one sentence:
Wire up a dockerfile to run a bluesky pds that executes as a miren service. Miren’s http ingress will handle tls.
Our app-setup skill’s first move was to send two research agents off in parallel — one to figure out the Miren service format, one to read the Bluesky PDS Docker setup — and reconcile the two. We’d told it up front that Miren’s ingress handles TLS, and the agents confirmed the consequence: the whole Caddy layer just disappears. The official setup runs Caddy in front of the PDS to handle certificates. On Miren that’s the platform’s job, so the PDS goes from “two containers and an on-demand-TLS config” to “one process listening on port 3000.”
From there it wrote two files. The Dockerfile.miren is basically the official image with the data paths pointed at a persistent mount:
FROM ghcr.io/bluesky-social/pds:0.4
ENV PDS_DATA_DIRECTORY=/pds/data
ENV PDS_BLOBSTORE_DISK_LOCATION=/pds/data/blocks
ENV PDS_PORT=3000
ENV PDS_BLOB_UPLOAD_LIMIT=104857600
ENV PDS_DID_PLC_URL=https://plc.directory
ENV PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
ENV PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
ENV PDS_REPORT_SERVICE_URL=https://mod.bsky.app
ENV PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
ENV PDS_CRAWLERS=https://bsky.network
ENV LOG_ENABLED=true
EXPOSE 3000
And .miren/app.toml describes the app to Miren:
name = "pds"
[build]
dockerfile = "Dockerfile.miren"
[services.web]
command = "node --enable-source-maps index.js"
port = 3000
[services.web.concurrency]
mode = "fixed"
num_instances = 1
[[services.web.disks]]
name = "pds-data"
mount_path = "/pds/data"
size_gb = 20
filesystem = "ext4"
[[env]]
key = "PDS_HOSTNAME"
required = true
description = "Public hostname for the PDS (e.g. pds.example.com)"
[[env]]
key = "PDS_JWT_SECRET"
required = true
sensitive = true
description = "JWT signing secret (openssl rand --hex 16)"
[[env]]
key = "PDS_ADMIN_PASSWORD"
required = true
sensitive = true
description = "Admin password (openssl rand --hex 16)"
[[env]]
key = "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX"
required = true
sensitive = true
description = "PLC rotation key (secp256k1 private key hex)"
The service is pinned to a single instance (mode = "fixed", num_instances = 1) because a PDS keeps its whole world — a SQLite database and the blob store — on one persistent disk, and you don’t want two copies fighting over it. The four secrets are all marked required and sensitive, so Miren refuses to deploy without them and masks them in logs and CLI output. Getting those declarations into app.toml up front means every future deploy validates the same set.
The secrets, and the PLC key that isn’t what it looks like
With the config in place, it was time to generate the required secrets. Most of them are boring in the good way:
# JWT secret
openssl rand --hex 16
# Admin password
openssl rand --hex 16
The fourth one, the PLC rotation key, is a secp256k1 private key, and the official installer generates it with a dense little one-liner:
openssl ecparam --name secp256k1 --genkey --noout --outform DER \
| tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32
You can think of that tail | head as the World’s Smallest Parser: the DER encoding wraps the raw key in a fixed-size ASN.1 header, so tail --bytes=+8 strips the envelope and head --bytes=32 grabs the 32 raw key bytes. It’s crude, and it only works because the DER layout for this key type is completely predictable. If you don’t have xxd lying around, any cryptographically random 32-byte hex string works in practice. It’s not mechanically identical — the openssl path samples a guaranteed-valid secp256k1 scalar (1 ≤ k < the curve order), while raw random bytes are only almost certainly in range — but the curve order is so close to 2²⁵⁶ that the odds of an out-of-range value are about 2⁻¹²⁸, i.e. never:
python3 -c "import secrets; print(secrets.token_hex(32))"
Set them in one shot. Each miren env set rolls out a deploy on its own, so you want a single command, not four. Miren takes each secret one of two ways: leave the flag bare and it prompts you (masked, so nothing hits your shell history), or point it at a file with =@file. Let it prompt for the two random values; the PLC key is a long blob, so save the pipeline’s output to a file called plc and read that:
miren env set \
-e PDS_HOSTNAME=miren.team \
-s PDS_JWT_SECRET \
-s PDS_ADMIN_PASSWORD \
-s PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=@plc
First boot: read the logs
It didn’t boot. This is where our app-health skill comes in: its whole job is figuring out why an app is unhappy, and step one is to stop guessing and read the logs:
miren logs
The container was crash-looping, and the logs said exactly why:
SyntaxError: Non-base16 character
at @atproto/crypto/src/secp256k1/keypair.ts:41
The PDS was choking trying to import the PLC rotation key. The value wasn’t 64 clean hex characters — it had a trailing newline hanging off the end. We’d generated the key into a file and set it with Miren’s read-from-file syntax, miren env set -s PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=@plc, and at the time the @file read pulled in the file’s trailing \n right along with the key. Re-setting a clean value fixed it and the next boot came up green. (That footgun is gone now — we fixed env set so KEY=@file trims trailing newlines. You only find papercuts like that by stubbing your toe on them in production.) A quick health check confirms the process is serving:
curl https://miren.team/xrpc/_health
# {"version":"0.4.208"}
That whole loop — crash, miren logs, spot the error, fix, redeploy — took about a minute. The value isn’t that Claude is a genius debugger; it’s that the logs are one command away and the error message was honest. Most PDS boot failures are a bad secret, and they all announce themselves the same way.
Creating the first account: the goat hunt
This is the part I’d have wanted to read before starting.
The PDS ships with goat, the AT Protocol CLI, and the obvious command to make an account is goat account create. So we ran it. It demanded an invite code. We passed one. It demanded another. Round and round — because goat account create is the client flow for signing up on some PDS out on the network, and it was cheerfully trying to talk to the public network instead of the box it was running on.
Claude spent a while here guessing: maybe it needs --pds-host localhost, maybe there’s an --admin-password flag, maybe it reads PDS_ADMIN_PASSWORD from the environment. Some of those were half-right and none of them were the answer, and eventually I told it to knock it off:
stop guessing and go research to find out
That’s the right instinct, and it worked. A research agent came back with how the admin path actually works — goat authenticates to the local admin API with HTTP Basic auth using the PDS_ADMIN_PASSWORD from the running instance’s environment — and, more importantly, that the command we wanted was a completely different subcommand. Walking the help tree with miren app run made it obvious:
miren app run -- goat --help
miren app run -- goat pds --help
miren app run -- goat pds admin --help
miren app run -- goat pds admin account --help
The account-creation command lives at goat pds admin account create, not goat account create. The admin version picks up the admin password from the environment, mints its own invite code, and uses it — so the invite requirement that had been blocking us all along should just evaporate.
Should. Running it through miren app run still didn’t create the account, and this was the second gotcha — the one that actually cost the most time. miren app run spins up a fresh, one-off container from your app’s image. That’s perfect for stateless checks like goat --help or resolving a handle against the public network, but the admin command needs the running PDS: its admin API on localhost:3000, the admin password baked into that instance’s environment, and its live database. A throwaway container has the goat binary but not the server behind it.
What you want is a shell inside the instance that’s already up. Grab its id from miren sandbox list and exec into it:
miren sandbox list
# find the running pds web sandbox, e.g. pds-web-CYx8SaH3aUYptT3aNE9nv
miren sandbox exec -i pds-web-CYx8SaH3aUYptT3aNE9nv -- \
goat pds admin account create \
--email team-product@miren.dev \
--handle mirener.miren.team \
--password "$(openssl rand --hex 16)"
That runs inside the live PDS, picks up the admin password from its environment, mints its own invite, and finally creates the account.
The lesson: “run a command against my app” is really two different tools. miren app run is an ephemeral container from the image — cheap, disposable, and blind to anything the running instance holds on its local socket or in its database. miren sandbox exec drops you into the actual running sandbox, live traffic and all. Reach for the first to check what a binary does; reach for the second when the command has to talk to the server that’s really up. The AT Protocol CLI was genuinely fiddly here, and knowing which of those two you needed was most of the battle.
Handles, DNS, and the rest of the ATmosphere
With an account created, there’s one more concept that reaches back into the infrastructure: handles. PDS_HOSTNAME doesn’t just name the server, it’s the suffix for every default handle on it. Our account mirener becomes mirener.miren.team, and the network verifies that handle by resolving it back to your PDS. Which means you need a wildcard DNS record, not just one for the base domain:
miren.team A <our server IP>
*.miren.team A <our server IP>
and Miren’s ingress has to route *.miren.team to the service, so each user’s subdomain answers. You can check both the base host and a user subdomain the same way:
curl https://miren.team/xrpc/_health
curl https://mirener.miren.team/xrpc/_health
and confirm the identity resolves end to end, which returns the DID document with your PDS as its serviceEndpoint:
miren app run -- goat resolve mirener.miren.team
The caveat: even with DNS, TLS, and handle resolution all green, a brand-new account can take a beat to show up when you search for it from another account. The account exists and resolves; the wider network’s AppView just hasn’t indexed it yet. The PDS is supposed to announce itself to the crawler at bsky.network when activity happens, and nudging that along — making a first post, or poking com.atproto.sync.requestCrawl — is the last mile between “my server works” and “my friends can find me.” It’s the one part of this that isn’t a Miren concern at all; it’s AT Protocol’s federation doing its thing on its own schedule.
What the skills bought us
The skills didn’t make PDS knowledge unnecessary — we still had to understand handles, the admin API, wildcard DNS, and how AT Protocol federates. What they took off the table was the yak-shaving: a crash-loop was a miren logs and a minute, and a fiddly goat was a shell in the live instance instead of a guessing game.
The account that came out the far end of all this is real and it’s live: come say hi to mirener on Bluesky — served off our own box on miren.team, federating into the same network as everyone else.
If you want to run your own corner of the ATmosphere, the config above is the whole thing — a Dockerfile.miren, an app.toml, four secrets, and a wildcard DNS record. If you haven’t tried Miren yet, standing up a PDS is a pretty good excuse to get started.