atfs #
A tiny, CGO-free Go server that lets a preconfigured set of atproto accounts upload files to it — over dev.atfs.repo.uploadFile, an XRPC call also served under the same com.atproto.repo.uploadBlob NSID every atproto client already speaks, wire-identical either way — and serves that content back out over IPFS (a full Bitswap + DHT participant, not a gateway bolted onto someone else's node) and over plain HTTP. Extremely simple setup is the point: local configuration is a couple of environment variables, and everything else lives in an atproto record you create once, on your own PDS.
How it works #
- Blessed CIDs. Uploaded blobs are addressed with atproto's own "blessed" CID format — CIDv1,
rawmulticodec (0x55), sha-256 multihash, base32 multibase — exactly what a PDS's ownuploadBlobwould hand back. Because it's a raw block, it's directly fetchable over IPFS with no translation step: the CID your upload gets back is its IPFS CID. - Dual-CID for large blobs. Bitswap can't move a block bigger than roughly 1–2 MiB, so anything larger than that also gets a UnixFS DAG built for it at upload time — a second,
dag-pbroot CID over the same bytes, fetchable block-by-block by ordinary IPFS peers. The blessed raw CID stays canonical: it's what the XRPC APIs return, and atfs's own HTTP paths serve it at any size regardless (the block-size limit only constrains Bitswap transport, not a local read). The UnixFS CID exists purely so large blobs can cross the IPFS network at all. - Configuration in two places. Local config — who owns the instance, how it's reached — is a handful of environment variables (
ATFS_*). Everything else — the complete upload allowlist, the service DID uploaders authenticate against (which doubles as this instance's own address), which other instances to mirror — lives in an atproto record,dev.atfs.server, atat://{owner-did}/dev.atfs.server/{peer-id}. atfs reads it at boot and then watches the owner's repo, so later edits apply within seconds without a restart. A missing record isn't fatal: the instance comes up anyway, logs the exactat://URI it went looking for, and runs with defaults — uploads disabled — until you create one. - Serves only what it has been asked to. An instance is never a general cache or an open relay: everything it serves has been claimed by name — uploaded to it by an allowlisted account, pinned by one, or mirrored from an instance the owner has explicitly listed in
follows. Nothing arrives because it happened to pass through.
Quickstart #
# atcr.io serves pulls to authenticated atproto identities only — log in
# once with any Bluesky handle + an app password
# (bsky.app/settings/app-passwords):
docker login atcr.io -u your.handle.example.com
docker run -d \
-e ATFS_OWNER_DID=did:plc:yourowndid \
-v atfs-data:/data \
-p 2837:80 -p 4001:4001/tcp -p 4001:4001/udp \
atcr.io/byjp.me/atfs
ATFS_OWNER_DID and a volume on /data are the only two things a first run needs. On boot, before any dev.atfs.server record exists, the log tells you exactly what to do next: it prints this instance's peer ID and the at:// URI a record needs to exist at, then carries on serving with uploads disabled. GET / returns a one-line identity string, handy for confirming the instance is actually up.
To enable uploads, create that record on the owner's own PDS at the rkey the log gave you, with a bare-domain did:web serviceDid and at least your own DID listed in accounts — the owner isn't implicitly included, so this step is what actually grants yourself upload rights (see Configuration below). No restart needed: the instance subscribes to the owner's repo, so the record appearing (or changing later) is picked up within seconds and logged.
The container runs as a non-root user #
The image runs as uid/gid 65532:65532 and serves plain HTTP on port 80.
Port 80 needs no extra privilege, because Docker sets net.ipv4.ip_unprivileged_port_start=0 inside every container: the usual "below 1024 is root-only" rule doesn't apply there, so no capability is granted and none is needed. On a runtime that keeps the restriction — some hardened Kubernetes setups — set ATFS_HTTP_PORT above 1024 and remap it on the outside.
/data has to be writable by uid 65532, or atfsd exits immediately with mkdir data: permission denied. A named volume (-v atfs-data:/data, as in the quickstart) is seeded from the image and inherits that ownership for free. A bind mount keeps the host directory's own ownership, so chown it first:
mkdir -p ./atfs-data && sudo chown 65532:65532 ./atfs-data
docker run -d -e ATFS_OWNER_DID=did:plc:yourowndid \
-v "$PWD/atfs-data:/data" \
-p 2837:80 -p 4001:4001/tcp -p 4001:4001/udp \
atcr.io/byjp.me/atfs
Configuration #
Local: ATFS_* environment variables #
| Variable | Default | Purpose |
|---|---|---|
ATFS_OWNER_DID |
(required) | The instance owner's atproto DID or handle — where the dev.atfs.server record is fetched from. Carries no upload rights of its own; the owner must be listed in the record's accounts like any other account to upload. |
ATFS_DATA_DIR |
./data (atfsd) |
Where blobs and this node's libp2p identity are stored. Effectively /data in the Docker image (its WORKDIR); built with gosd's gosd tag for an SBC image, it instead defaults to the onboard eMMC when the board has one, else /data (see "Running on an SBC"). |
ATFS_HTTP_PORT |
2837 (80 in Docker) |
Plain-HTTP listen port. 0 disables the plain-HTTP transport. The Docker image sets 80 so a container needs no port remapping to look like a web server; override it if your container runtime keeps the usual restriction on ports below 1024 (see "The container runs as a non-root user"). |
ATFS_HTTP_HOSTNAMES |
(empty — any hostname) | Comma-separated Host-header allowlist for the plain-HTTP transport; a non-matching request gets 421 Misdirected Request. |
ATFS_IDENTITY_KEY |
(empty — generate one) | The libp2p private key this instance's peer ID is derived from, base64-encoded (the same encoding as kubo's Identity.PrivKey). Normally unset: atfs generates a key on first run and keeps it in the data dir, and a key already stored there always wins — set this only to hand an instance an identity in advance, or to restore one onto a data dir that's lost it, so an existing dev.atfs.server record keeps working. |
The dev.atfs.server record #
accounts is required; every other field is optional, and unknown fields are ignored, so a running instance is never broken by a field it predates.
| Field | Default | Purpose |
|---|---|---|
accounts |
(required) | The complete upload allowlist: atproto accounts (DIDs), permitted to upload. The owner is not implicitly included — list it explicitly if it should be able to upload to its own instance. DIDs rather than handles, so an entry survives a handle change and can be reverse-indexed to find which instances a person can use. |
serviceDid |
(empty — uploads disabled) | This instance's own identity and address: the aud uploaders must address in their inter-service auth JWTs, and — when it's a bare-domain did:web (see "did:web self-resolution" below) — also this instance's single HTTPS base URL. Uploads stay off until this is a bare-domain did:web and accounts names at least one account. |
follows |
[] |
Other atfs instances to mirror, each named by the at-uri of that instance's own dev.atfs.server record. See "Following another instance". |
Edits to this record apply live. The instance subscribes to the owner's PDS repo stream and re-reads the record whenever it changes — and again on every reconnect, so an edit made while the instance was offline isn't missed — logging one line saying what changed. accounts and serviceDid take effect on the very next request; follows reaches the running sync loop immediately. One thing still needs a restart, and says so in the log when it changes: the /.well-known/did.json document atfs serves for a bare-domain did:web serviceDid (built once at startup — see "did:web self-resolution" below). An instance that can't reach the owner's PDS just keeps running on the configuration it read at boot.
Removing an account from accounts is a revocation, not just a closed door. Everything that account uploaded or pinned here is released, and any of it nothing else claims is deleted — content, IPFS artifacts, DHT announcements and all — because an appliance that publishes content to a global DHT from the operator's own address has to be able to stop. Content another account (or a followed instance's mirror) still claims stays, minus the delisted account's claim. Three deliberate limits: emptying accounts altogether turns uploads off and revokes nothing (a record that was deleted, or that a PDS lost, looks exactly the same from here, and that mistake would be unrecoverable — so revoke by removing an account while at least one other stays listed); an account whose identity can't be resolved at that moment keeps its content until the next edit; and only an edit the running instance actually sees revokes, so if it was offline for the removal, re-add and re-remove the account.
Uploads are authenticated with standard atproto inter-service auth JWTs — signed by the uploading account's own signing key, verified against its DID document — exactly like talking to a PDS. There's no atfs-specific credential.
The uploadFile/uploadBlob response is the PDS's exact shape plus one extra top-level field, ipfsRoot: the CID to fetch over the IPFS network (the UnixFS root for a large blob, the blessed CID itself for a small one). Standard atproto clients ignore it; clients writing dev.atfs.file records want it.
Trust model #
The owner's PDS is a full, unverified authority over every instance configured from it. The record is read over a plain com.atproto.repo.getRecord call — no signed commit, no MST proof checked — so it's exactly what the PDS chooses to hand back, and (per "Edits to this record apply live" above) an edit takes effect within seconds with no restart and no human in the loop. Whoever controls that PDS therefore controls, for every instance pointed at it: the upload allowlist (accounts), the audience uploaders authenticate against (serviceDid), and the set of other instances mirrored (follows).
Concretely, a compromised PDS — or one that's mis-issued a certificate, or is simply operated by someone untrustworthy — can add itself to accounts and gain upload/pin rights, repoint serviceDid so the instance starts accepting tokens minted for a different audience, or add a follows entry and have the instance fetch, store, serve, and DHT-announce content of its choosing.
None of that is a bug in atfs: it's atproto's ordinary "you trust your PDS" model, applied to config-in-a-record on purpose (see "Configuration in two places" above). It's worth stating plainly anyway, because the stakes are higher than they are for a PDS's usual job — this record grants write access to an appliance's disk and its public serving surface, not just read access to some posts. A compromised owner PDS is equivalent to a compromised instance. Picking which PDS an atfs owner account lives on is picking who gets to hold that.
CLI #
atfs (cmd/atfs, built alongside the server — see "Building from source") is a small client for the three calls above: it does the login → mint-a-scoped-token → call dance itself, instead of four hand-rolled curls.
Every subcommand takes --server (the instance, e.g. https://myatfs.example.com), --user (your handle or DID), and --aud (the instance's service DID — its dev.atfs.server record's serviceDid), also settable as ATFS_SERVER/ATFS_USER/ATFS_AUD. Your app password comes from ATFS_APP_PASSWORD only — never a flag, since argv is visible to every other process on the machine — and is never printed or logged.
export ATFS_SERVER=https://myatfs.example.com
export ATFS_USER=you.example.com
export ATFS_AUD=did:web:myatfs.example.com # the instance's serviceDid
export ATFS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx
atfs upload FILE uploads a file and prints a ready-to-embed dev.atfs.file JSON object:
$ atfs upload ./photo.jpg > photo.atfs.json
$ cat photo.atfs.json
{
"cid": { "$link": "bafkrei..." },
"ipfsRoot": { "$link": "bafkrei..." },
"size": 483821,
"mimeType": "image/jpeg",
"providers": ["https://myatfs.example.com"]
}
atfs pin FILE|- reads a dev.atfs.file reference — a file, or - for stdin, so it composes with upload's own output — and asks the instance to fetch and serve it, printing where the pin stands:
$ atfs pin photo.atfs.json
{
"state": "seeking"
}
Call it again with the same reference to check progress; it settles at "pinned" (or "failed" — see "Pinning content from elsewhere" below).
atfs delete CID releases your claim on a blob; the bytes themselves go too, once every claimant has done the same:
$ atfs delete bafkrei...
bafkrei...: content deleted (last claim released)
Worked examples #
The CLI above is the easy way to drive any of this — it does the login → mint-a-token → call dance for you. What follows is the tooling-free equivalent: goat (for identity and token minting) plus plain curl, so it's clear exactly what's on the wire for each of atfs's five XRPC calls.
Two traps before copying any of this:
- A token's
lxmclaim must name the NSID you actually call. Every authenticated call below is an atproto inter-service auth JWT (see "Configuration" above), and atfs checks itslxmclaim against the path the request hit — a token minted with--lxm dev.atfs.repo.uploadFileis rejected on/xrpc/com.atproto.repo.uploadBlob, even though the two paths do exactly the same thing (see "The alias trade-off" below). - Every token is single-use. atfs tracks each token's
jticlaim and rejects a repeat within that token's own validity window (auth: token already used (jti replay)) — so retrying a call, or pollingpinFilefor status, means minting a fresh token each time, never reusing the last one.
Set up once per shell session:
export ATFS_SERVER=https://myatfs.example.com
export ATFS_AUD=did:web:myatfs.example.com # the instance's serviceDid
goat account login -u you.example.com -p "$ATFS_APP_PASSWORD"
goat account login persists a session that every goat account service-auth call below reuses — it's the identity you're minting tokens as, unrelated to $ATFS_SERVER.
uploadFile #
TOKEN=$(goat account service-auth --lxm dev.atfs.repo.uploadFile --aud "$ATFS_AUD")
curl -sS -X POST "$ATFS_SERVER/xrpc/dev.atfs.repo.uploadFile" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg
{"blob":{"$type":"blob","ref":{"$link":"bafkrei..."},"mimeType":"image/jpeg","size":483821},"ipfsRoot":{"$link":"bafkrei..."}}
goat's default token lifetime is 60 seconds (--duration-sec); bump it for a large, slow upload — atfs's own CLI asks for 10 minutes.
getFile #
Public — no token:
curl -sS "$ATFS_SERVER/xrpc/dev.atfs.repo.getFile?cid=bafkrei..." -o photo.jpg
Add &did=<did> to restrict the fetch to a file that DID holds a pin on; a miss (wrong cid, or a did that doesn't) comes back 400 BlobNotFound, not 404 (see "Retrieval" above).
deleteFile #
TOKEN=$(goat account service-auth --lxm dev.atfs.repo.deleteFile --aud "$ATFS_AUD")
curl -sS -X POST "$ATFS_SERVER/xrpc/dev.atfs.repo.deleteFile" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"cid": "bafkrei..."}'
{"contentDeleted": true}
pinFile #
The request body wraps a whole dev.atfs.file reference — the same JSON atfs upload prints (see "CLI" above) — under a file key:
FILE_REF='{
"cid": {"$link": "bafkrei..."},
"ipfsRoot": {"$link": "bafkrei..."},
"size": 483821,
"mimeType": "image/jpeg",
"providers": ["https://myatfs.example.com"]
}'
TOKEN=$(goat account service-auth --lxm dev.atfs.repo.pinFile --aud "$ATFS_AUD")
curl -sS -X POST "$ATFS_SERVER/xrpc/dev.atfs.repo.pinFile" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"file\": $FILE_REF}"
{"state": "seeking"}
Re-calling with the same body is the status check — poll it, minting a fresh token each time since the last one is now spent:
while :; do
TOKEN=$(goat account service-auth --lxm dev.atfs.repo.pinFile --aud "$ATFS_AUD")
RESP=$(curl -sS -X POST "$ATFS_SERVER/xrpc/dev.atfs.repo.pinFile" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"file\": $FILE_REF}")
STATE=$(jq -r .state <<<"$RESP")
echo "$STATE"
case "$STATE" in
pinned|failed) break ;;
esac
sleep "$(jq -r '.nextAttemptIn // 5' <<<"$RESP")"
done
nextAttemptIn is a relative second count, never a deadline (an appliance's clock can't be trusted — see "Pinning content from elsewhere" above); it's absent while a fetch is actually in flight (state: "fetching", which carries a progress object instead) or once the pin has settled, so the fallback above just avoids a tight loop in that gap.
listFiles #
Public, no token, cursor-paginated:
CURSOR=
while :; do
PAGE=$(curl -sS "$ATFS_SERVER/xrpc/dev.atfs.repo.listFiles?limit=500&cursor=$CURSOR")
jq -c '.files[]' <<<"$PAGE"
CURSOR=$(jq -r '.cursor // empty' <<<"$PAGE")
[ -z "$CURSOR" ] && break
done
cursor is present in a response only when that page filled to limit; its absence marks the last page.
The alias trade-off #
dev.atfs.repo.uploadFile is also mounted, wire-identical, as com.atproto.repo.uploadBlob — so any existing client that already speaks uploadBlob via inter-service auth works against atfs unchanged, once it's pointed at the atfs instance and minting its token with the alias's own NSID. That doesn't come for free: a token minted with --lxm dev.atfs.repo.uploadFile is rejected on the com.atproto.repo.uploadBlob path, and vice versa — the two names share a wire contract, not a token. dev.atfs.repo.getFile's com.atproto.sync.getBlob alias is wire-identical too, but since retrieval needs no token at all, there's nothing there for an lxm to mismatch. deleteFile and pinFile have no com.atproto alias to begin with — atproto has no equivalent call for either to mirror.
Serving #
Plain HTTP — ATFS_HTTP_PORT (default 2837, or 80 in the Docker image; 0 to disable). Answers any Host header by default; set ATFS_HTTP_HOSTNAMES to a comma-separated allowlist and anything else gets 421 Misdirected Request. This is the only transport atfs itself runs, in-process.
Ingress — getting a public HTTPS hostname pointed at that port is someone else's job. On the shipped SD-card images, both of gosd's ingress agents (Cloudflare Tunnel and Tailscale Funnel) are baked in; enable one by filling in the settings under config/ingress/cloudflared/ or config/ingress/tailscale-funnel/ on the card — each is a file of its own, beside an explanation of what to type into it, and atfs ships its own wording for the ones where the answer is atfs-specific (the port is always 2837, and a Cloudflare hostname wants to match your record's serviceDid). See gosd's docs/ingress.md for the account-side setup. In Docker, atfs serves plain HTTP only — bring your own reverse proxy or ingress in front of it. Either way, the hostname allowlist above applies to that traffic too, since it arrives over the same plain-HTTP transport with the public hostname already in Host.
Custom domains — pointing another domain at an instance is just a CNAME record: the DNS entry that says "this name lives wherever that one does". atfs is happy to answer for it — it serves any Host by default — so over plain HTTP there's nothing more to do. HTTPS needs one extra step, because certificates are per-domain and whatever terminates your TLS only answers for domains it holds a certificate for — a visitor on an unregistered domain is turned away before the request gets anywhere near atfs. With Cloudflare Tunnel the terminator is Cloudflare's edge, and the step is Cloudflare for SaaS: register each extra domain as a custom hostname, Cloudflare checks the domain really does point at you, and from then on its certificate is issued and renewed automatically — the first 100 hostnames are free. The getting-started guide walks through it; the fallback origin it asks for is the DNS record already pointing at your tunnel. Two caveats: these requests arrive with the visitor's domain in Host, so a set ATFS_HTTP_HOSTNAMES needs the new domain added — and on the SD-card images this doesn't work end-to-end yet, because the tunnel gosd configures forwards exactly one hostname and answers 404 for the rest (a catch-all option in gosd is tracked, not yet built). In Docker, certificates for extra domains are your reverse proxy's department — Caddy's on-demand TLS does exactly this. Tailscale Funnel has no equivalent: it only ever serves its own ts.net name.
did:web self-resolution — when serviceDid is a bare-domain did:web (e.g. did:web:atfs.example.com, nothing further after the domain), atfs serves its own GET /.well-known/did.json for exactly that domain's Host, with a service entry whose serviceEndpoint is derived from that same domain — one name, one address, nothing extra to configure or let drift out of sync. It's implicitly allowed through ATFS_HTTP_HOSTNAMES even if you forget to list it. This makes the instance a resolvable atproto service with no external identity hosting — but the domain still has to actually terminate public TLS somewhere (gosd ingress or your own reverse proxy) for resolution to work. A did:plc serviceDid, or a did:web naming a path or port, isn't just missing this document — uploads stay disabled for it too (see "Configuration" above).
Retrieval #
GET /ipfs/<cid>— a standard IPFS gateway path, for either a blessed raw CID or a UnixFS root CID.GET /xrpc/dev.atfs.repo.getFile?cid=<cid>(didoptional; also served,did-required, ascom.atproto.sync.getBlob) — including returning400 BlobNotFound(not404) for a miss, matching a PDS's owngetBlob.- Any ordinary IPFS client — kubo, helia, anything speaking Bitswap — since atfs is a real DHT participant that announces what it holds, not a gateway-only node.
Both HTTP paths serve a blob's Content-Type exactly as its uploader declared it — the operator's own domain will render whatever an allowlisted account uploads. Every response also carries X-Content-Type-Options: nosniff and a locked-down Content-Security-Policy: default-src 'none'; sandbox (what a Bluesky PDS sends on getBlob and what kubo sends on its own path gateway), and anything outside a small inline-safe allowlist (images, video, audio, PDF, plain text) gets Content-Disposition: attachment, so a declared text/html downloads instead of rendering.
Pinning content from elsewhere #
POST /xrpc/dev.atfs.repo.pinFile (JSON body {"file": {…a dev.atfs.file reference…}}) asks the instance to fetch content that already exists on the IPFS network and serve it as if you had uploaded it. The reference is the whole request: the cid the fetched bytes must hash to (so the fetch is trustless whatever the source), the ipfsRoot to fetch, the size to refuse before fetching a byte, and any providers to try over plain HTTPS before the IPFS network. Same allowlist as uploading, because a pin is an upload — just one where atfs goes and gets the bytes itself.
It answers immediately with a state — seeking, fetching, pinned or failed — because the bytes usually aren't here yet. Retrying is the node's job, not yours: a cid whose provider records haven't propagated (kubo's re-provide cycle runs 12–24 hours) is retried on a backoff doubling from a minute to an hour, for about 48 hours of the instance's uptime, and the request survives reboots. Only content that's provably wrong — bytes that don't hash to the cid, a file over the size limit — fails straight away. Call it again with the same file to check progress; call it again after a failure to start over. To stop waiting, deleteFile the same cid.
A providers list is someone else's instruction about where your instance should make connections, so it's treated as one: origins must be https, and anything resolving to an address that isn't routable on the public internet — loopback, your LAN, link-local, CGNAT — is never dialled, checked on the resolved address rather than the name. lastError is one of a small set of reasons (no-source-responded, content-mismatch, …; see the lexicon) rather than the error itself, which stays in the instance's logs where it can't map your network for whoever asked. The same applies to instances you follow: they name their own address, and it gets the same treatment.
Deletion #
POST /xrpc/dev.atfs.repo.deleteFile (JSON body {"cid": "<cid>"}) releases the caller's pin on a blob; the content itself is only physically removed once every claimant has released it. The same goes for content that hasn't arrived yet: a pending pinFile request is a claim before the fact, so deleting it withdraws your interest, and the fetch is abandoned only when the last account waiting on it has done the same. There's no com.atproto alias here — atproto has no equivalent blob-deletion call to mirror, since a PDS just garbage-collects a blob once no record references it. Authorization is by pin-set membership rather than the upload allowlist, so an account can always release its own claims, even after being removed from the allowlist.
Storage ceiling #
An instance keeps a slice of its data volume permanently free — a twentieth of it, never less than 512 MiB and never more than 4 GiB (see internal/store) — because a genuinely full volume takes the metadata, pin records and IPFS index down with the content, and on an SD-card appliance there's no shell to clear it from. There's nothing to configure: the figure is derived from the volume itself.
Once what's left above that reserve is spoken for, uploadFile and pinFile answer 400 InsufficientStorage and store nothing; retrieval, enumeration and deletion carry on untouched, and deleteFile is how you make room. Space is reserved for the duration of each upload and each pin fetch rather than merely checked, so several large transfers at once can't all be waved through on the same free space. There's a ceiling on outstanding pinFile requests too (thousands — far past allowlist scale), since a pin for content that never turns up costs a small file and a retry timer indefinitely.
A follower that runs out of room stops adding for that poll and picks up where it left off an hour later. It never releases anything over it: the origin still lists what it holds, and being full is this instance's problem, not evidence the origin dropped a file.
Enumeration #
GET /xrpc/dev.atfs.repo.listFiles?limit=&cursor= lists this instance's committed, directly claimed files, a page at a time — dev.atfs.file-shaped entries, the same shape pinFile takes as input. limit (default 500, max 1000) bounds the page; cursor resumes from a previous response's cursor field, which is present only when the page filled (its absence marks the last page). Unauthenticated, unlike the upload/pin/delete calls — every cid it lists is already public, announced to the DHT and served at /ipfs/<cid>.
"Directly claimed" means uploaded here or pinned here by one of this instance's accounts. Content held only because this instance follows another is served and announced as normal but never listed here — a mirror doesn't re-export what it mirrors. That's what stops two instances following each other from echoing forever, lets an origin's deletions travel outward, and keeps mirroring non-transitive: follow each origin you actually want. A file mid-deletion or not yet indexed for IPFS is left off the listing too, so a follower should expect the set to shift slightly between polls and should never read a single absence as a deletion.
Following another instance #
Add another instance's dev.atfs.server at-uri to your own record's follows list and this instance mirrors everything that instance directly claims:
{
"$type": "dev.atfs.server",
"accounts": ["did:plc:youraccountdid"],
"serviceDid": "did:web:myatfs.example.com",
"follows": ["at://did:plc:theirowner.../dev.atfs.server/12D3KooWtheirpeerid"]
}
It's the record's at-uri rather than a URL on purpose: the identity is what's followed, so the instance you follow can change hostname without you noticing (atfs just re-resolves and picks up the new one). atfs resolves that record to find where to poll — derived from its serviceDid, exactly like this instance's own address (see "did:web self-resolution" above) — and its serviceDid (whose name to hold the mirrored claims under — an instance with no serviceDid gets a did:key derived from its peer ID instead). Never your own DID: mirrored claims stay distinct from your uploads, so releasing one never touches the other, even when both happen to belong to the same DID.
Following is unilateral and needs no permission from the instance being followed. listFiles is public and every pinned cid is already a DHT provider record, so an opt-in would be theatre — anyone able to mirror your content can already do so.
Roughly hourly (fixed, no knob), atfs walks the followed instance's listFiles, pins anything new through the same machinery as pinFile, and gives up anything that has vanished. Two details worth knowing:
- Releases need two consecutive polls. A file that drops out of one listing and comes back in the next is left alone —
listFileslegitimately omits files mid-GC or mid-index, and releasing then re-fetching content is worse than an hour of staleness. - An unreachable instance is never an empty one. If the record can't be resolved, no endpoint answers, or a walk dies halfway, that poll releases nothing at all and tries again next time. Only a listing that completed can say a file is gone.
Removing an entry from follows releases all of that instance's claims at once — no waiting — and the content is deleted once nothing else claims it. Anything you also uploaded or pinned yourself stays, since every claimant has to release before bytes go.
Reachability: what to expect #
HTTPS reachability comes from gosd's ingress agents (or your own reverse proxy in Docker) — they only ever dial outward, so any NAT, including CGNAT, still works. Whatever your network, your content is globally fetchable by URL.
The IPFS side — being fetchable by CID, from the swarm — is best-effort by the nature of NAT, and atfs climbs the whole ladder automatically:
- Router port-mapping (UPnP/NAT-PMP): atfs asks your router for a public port. Routers that honour it make the instance truly public — DHT server mode, gateways fetch on the first try — with no human involvement.
- Relay + hole-punching: otherwise atfs holds relay reservations and coordinates hole-punches, which pierce most home NATs (best over QUIC). Patient clients (
ipfs get) fetch fine; a busy public gateway's short timeout may lose the race on a cold fetch. - Relay only: symmetric NAT and CGNAT defeat hole-punching by construction — no software fixes that. Peers can still trickle through the relay, but treat HTTPS as the distribution channel there.
AutoNAT measures which rung you're on (the reachability changed log lines); it doesn't open anything. If you control the router and want gateway-grade p2p latency, forward TCP+UDP 4001 to the instance — it notices the change, promotes itself, and re-announces its content automatically.
Running on an SBC #
atfs also builds as flashable SD-card images via gosd, for the boards listed in .tangled/workflows/image.yml today (rock-4se, nanopi-zero2, pi-zero-2w) — adding another board is just adding its gosd board id there.
Flash the image, then open the config/ folder on the card's small atfs-boot partition (it mounts like any other FAT32 volume). Every setting is its own file, beside a .explain.md saying in plain language what to type into it, so setting config/env/ATFS_OWNER_DID is normally the only edit a new card needs — see packaging/config for the ones atfs adds, and gosd's docs/config.md for the tree as a whole. Blobs and this node's identity live on a separate atfs-data partition, mounted at the fixed /data; if that partition is missing or fails to mount, gosd falls back to an empty read-only volume instead, so a broken data partition fails loudly rather than silently losing writes. To expose the instance publicly, fill in one of the config/ingress/* settings groups — see "Serving" above.
Boards with onboard eMMC (NanoPi Zero2, Radxa Zero 3E) use it for storage automatically instead: atfsd formats and mounts a blank eMMC on first boot, then just mounts it on every boot after that. If the eMMC already holds something else, atfsd halts with instructions on the serial console rather than wiping it, and the same happens if the eMMC carries atfs's own volume but has become unmountable — gosd refuses to silently reformat either case. Set ATFS_DATA_DIR to opt out and use the SD /data partition instead, leaving the eMMC untouched.
To authorize either — wiping a non-blank eMMC, or rebuilding atfs's own volume if it stops mounting — set config/env/ATFS_FORMAT_DRIVES_IF_NOT_ATFS on the card to today's date, in UTC as YYYY-MM-DD: there's no shell to run mkfs by hand, so this is how an operator authorizes either without needing a serial adapter first. It has to be a date rather than true/false because gosd boards have no battery-backed clock — every boot starts at 1970 until the network comes up and SNTP syncs — so atfs waits (briefly, with a bounded timeout) for that sync before checking it, and only accepts today's date or yesterday's, so editing the card at 23:55 and booting at 00:05 still works. That also means the value can't be left on the card and silently authorize a wipe or rebuild again weeks or months later the way a bare true could, so there's no need to remove it afterwards. Which of the two actually happens depends only on what the eMMC needs: adopting foreign content is the harmless, expected case, but rebuilding atfs's own unhealthy volume destroys every blob on the drive and the node's identity key, so it comes back with a new peer ID and its dev.atfs.server record (whose rkey is the old peer ID) has to be recreated by hand — the serial console names which one happened.
Cards that arrive already configured #
An image can also be handed its settings before it's ever flashed. Every setting's file is padded to a fixed size, and atfs-<board>.inject.json alongside the .img records the byte ranges each one occupies; whoever distributes the image can overwrite exactly those bytes with real values — gosd's image-injection docs cover the mechanics, and it needs no understanding of FAT32, so a browser can do it between the CDN and the user's disk.
Because the reserved space is the settings file itself, injected settings are ordinary card settings: atfs needs no code to read them, they survive an upgrade reflash (gosd keeps a copy on /data and puts it back), crash reports redact them, and whoever holds the card can read and edit them beside the same explanation as every other setting. A card nobody injected anything into ships the same defaults it always did.
This is what lets a setup flow create the dev.atfs.server record and the card in one sitting: injecting ATFS_IDENTITY_KEY fixes the instance's peer ID up front, and the record's rkey is that peer ID, so it no longer has to wait for a first boot to report an identity it invented. One consequence to know: since the snapshot carries those values into /data and re-renders them onto the next card, reflashing is not a way to clear a device's identity key — only wiping /data is.
That flow is atfs.dev/setup/ — a Svelte app whose source is web/setup, built into site/setup when the site deploys (make web does the same locally). It signs in with atproto OAuth, mints the identity in the browser, writes the record, and splices both ATFS_OWNER_DID and ATFS_IDENTITY_KEY into the .img's reserved ranges — for an image it downloads, or one already on your disk. The key is generated in the tab and sent nowhere, so it also asks you to save it: pasting it back is how a rebuilt card keeps the identity its record is keyed by. The splicing is @jphastings/gosd; the identity and record logic is atfs's own, and webtest/ checks its arithmetic against go-libp2p's on shared fixtures, so a browser-minted key and the peer ID atfsd derives from it cannot drift apart.
There's no shell and no SSH on a gosd image: atfsd — the same binary as the container, built with gosd's gosd build tag — is the only binary gosd-init runs, restarted forever with backoff if it ever exits, and the serial console is the entire interface. That's where boot shows which release the card is running, this instance's peer ID, the at:// URI for its config record, and whether uploads are enabled.
Building from source #
make build # -> bin/atfsd, bin/atfs
make test
make vet
make check # vet + test
go.mod pins the minimum Go toolchain, and CI sets GOTOOLCHAIN=auto so an older local toolchain fetches the right one automatically rather than failing. Build with make build or go build ./cmd/atfsd rather than a bare go build ./...: with more than one main package in this module, that discards its output instead of producing a binary you can run.
The whole module is CGO-free by design (CGO_ENABLED=0) — load-bearing for both the gosd arm64 cross-compile and the scratch-based Docker image, which ships no libc, shell, or other runtime dependency at all.
Status #
atfs is young — pre-first-release, and its shape may still move. The dev.atfs.server and dev.atfs.file lexicons are drafted in lexicons/ but not yet published to the network under the dev.atfs authority. A project site at atfs.dev is planned but doesn't exist yet.