# Self-Hosting the Community Server

**Swarmfile Server (Community)** is the open-source server you run yourself (dual MIT/Apache-2.0; source and releases at [swarmfile-server](https://swarmfile.com/public/swarmfile/swarmfile-server)): a single-tenant service with SQLite for metadata and a local block directory (or an S3-compatible bucket) for file bytes. It speaks the same API as the hosted service, so the client tools that already talk to Swarmfile (the desktop app, the git-LFS agent, stock `git-lfs`, the migration tool, and the `swarmfile://` git helper) can all point at yours.

Running it yourself means running it on a computer you trust: the server holds your data, and on a managed project it also holds the encryption key. What stays on the hosted service is everything around the core: access rules and sharing, **anonymous drops**, end-to-end encryption, previews, hosted CI, office caches, and billing.

## Install

**Binary (macOS, Linux, and Windows, each on x64 and ARM).** The install script downloads the release for your machine and verifies its checksum; on Windows it points you at the release `.zip`; unzip it and run `swarmfile-server.exe`:

```bash
curl -fsSL https://get.swarmfile.com/server | sh
```

The [server release page](https://swarmfile.com/public/swarmfile/swarmfile-server) also carries an **Install this release** command inside its **Install & clone** shelf; that one is pinned to the tag you are viewing, while the one-liner above follows the latest release.

**Container.** The release publishes a multi-arch image (`ghcr.io/mirrexa/swarmfile-server`, tagged `latest` and the release version), and the server project carries a `compose.yaml` that uses it:

```bash
docker compose up -d
docker compose exec server swarmfile-server admin --data-dir /data create-org --id my-org --name "My Org"
docker compose exec server swarmfile-server admin --data-dir /data create-project --org my-org --name "My Project"
docker compose exec server swarmfile-server admin --data-dir /data mint-key --org my-org --project <project-id> --name cli
docker compose exec server swarmfile-server admin --data-dir /data enable-git --org my-org --project <project-id> --threshold-bytes 50000000

export SWARMFILE_HUB_URL=http://127.0.0.1:8080
export SWARMFILE_API_KEY=sf_key_…          # the key minted above
git clone swarmfile://my-org/<project-id>
```

The clone needs the standalone `git-remote-swarmfile` helper ([install](https://swarmfile.com/docs/cli/git-remote-swarmfile)); the server's own `GET /api/profile` is served on `http://127.0.0.1:8080/api/profile`. Set `SWARMFILE_SERVER_VERSION` to pin the image, or build the source Dockerfile when you need a local build. `/data` is a named volume; back it up. The GHCR package must be public for an anonymous pull.

![Self-hosting in Docker from the published snapshot's own Dockerfile: the container holds only the binary, the volume is the entire server state, and the open doctor verifies it from the host.](https://swarmfile.com/demo/oss-docker.cast)

**From source:** `cargo build --release -p swarmfile-server --features s3` in the published project (the `s3` feature is optional but the release binaries and images include it).

## First run

```bash
swarmfile-server admin --data-dir ./data init
swarmfile-server admin --data-dir ./data create-org --id my-org --name "My Org"
swarmfile-server admin --data-dir ./data create-project --org my-org --name "My Project"
swarmfile-server admin --data-dir ./data mint-key --org my-org --project <project-id> --name desktop
swarmfile-server serve --data-dir ./data --listen 127.0.0.1:8080
```

`create-project` prints the project id, which every command that follows names it (`--org` and `--project` also accept a live or historical slug). `mint-key` prints a key (`sf_key_…`) exactly once; treat it like a password. A project's encryption tier is chosen when it is created: `--tier plaintext` (the default) stores bytes as written, `--tier managed` keeps a key on the server so authorized clients encrypt and decrypt through it.

Renaming keeps published addresses working:

```bash
swarmfile-server admin --data-dir ./data rename-project --org my-org --project <project-id-or-slug> --slug new-name
swarmfile-server admin --data-dir ./data rename-org --org my-org --slug new-org
```

`admin list-aliases --org my-org` prints the historical org and project slugs, so an operator can audit what old URLs still resolve. `rename-project` and `rename-org` also take `--name`. The old slug is recorded as a reserved alias, so an existing `swarmfile://<host>/<org>/<project>` URL or a git remote keeps resolving to the same project (the community server has no anonymous public pages or share links; those are hosted-only); a live slug always wins, and another project cannot create or rename onto a historical name. Renaming back to an old slug frees it again. (The community server has no project or org delete surface, so a historical name is never released by a delete.)

A `serve` process holds an exclusive lock on its data dir, so there is **one server instance per data dir**: a second `serve` refuses at startup (the admin CLI may run alongside it).

Serve plain HTTP and put a TLS reverse proxy in front (Caddy or nginx); the client tools expect `https://` outside a purely local setup. Set `SWARMFILE_SERVER_PUBLIC_URL` to the public origin (for example `https://files.example.com`): the server's signed host identity (`.well-known/swarmfile.json`) signs that configured origin, never the request `Host` header. The document is origin-root by design (RFC 8615): a deployment served under a path prefix must map `/.well-known/swarmfile.json` at the root for verifiers to find it. Without it the identity document answers 503; `swarmfile-verify-history --check-host-identity` then fails closed.

## Connect the tools

- **Desktop app** - install the self-hosted edition from your server's bundle (the `swarmfile-self-hosted-<version>-<platform>` download on the [swarmfile-server](https://swarmfile.com/public/swarmfile/swarmfile-server) release page), then choose **Connect to your server** and enter the server's address and the key. If the key opens more than one project, the app asks which one. The drive, listing and saves work the same as against the hosted service. The first connect asks you to accept the edition's license (`EULA.md` in the bundle). This edition does not update itself, and **Reinstall** and `swarmfile-doctor --repair` refuse on it rather than replace it with the hosted app: upgrade by installing the next bundle, server first. **Disconnect from server** forgets the key on that computer. A key set by the `SWARMFILE_API_KEY` environment variable can't be changed from the app: **Connect** and **Disconnect** say so and leave it to whoever set the variable. An address set by `SWARMFILE_HUB_URL` is fixed the same way: **Connect** says so. The desktop app from swarmfile.com always connects to the hosted service. On Windows and Linux the two editions can't be installed together: each installer refuses while the other is installed, so uninstall one first. On macOS they install side by side as separate apps.

  Each bundle holds, for one platform, the server (`server/`), the self-hosted desktop installer (`desktop/`), the standalone tools (`tools/`), the license, the third-party notices and a `versions.json` naming every component's release. A bundle is published only after the community end-to-end tests have passed for that exact version set.
- **git-LFS**: a project serves git-LFS under its own path; set it in the repo's `.lfsconfig`:

  ```bash
  git config -f .lfsconfig lfs.url https://your-server/orgs/my-org/lfs/<project-id>
  ```

  Stock `git-lfs` works for ordinary objects; the bundled agent handles very large ones. Native ingest is plaintext-only on the community server: on a managed project both are refused `422`, because the server stores only sealed blocks and will not write unsealed content at rest, so move large files through the mounted drive there. See [Git-LFS](https://swarmfile.com/docs/guides/git-lfs) for how the two fit together.
- **git clone** - turn on a project's git access (one-way, as on the hosted service), then clone over the helper:

  ```bash
  swarmfile-server admin --data-dir ./data enable-git --org my-org --project <project-id-or-slug> --threshold-bytes 50000000
  git clone swarmfile://your-server/my-org/<project-id>
  ```

  Set `--threshold-bytes` above the largest blob the repository already contains before the first enable: the value is frozen, and a push refuses a literal blob at or above it. The default is 1 MiB. Name the server (the host-qualified form) in anything you hand to a teammate: a relative `swarmfile://my-org/<project-id>` resolves against whichever hub that machine is configured for. If the server is plain HTTP rather than behind the TLS proxy above, the client must list its authority in `SWARMFILE_GIT_INSECURE_HOSTS` (loopback needs no entry). A teammate whose Desktop App is connected to a **different** hub presents no session here: connect the app to this server, store the server key for this host with `git credential approve`, or set `SWARMFILE_API_KEY` together with `SWARMFILE_ALLOW_CROSS_HUB_KEY=1`.

  An owner key can do the same over the API: `POST https://your-server/orgs/my-org/projects/<project-id>/metadata/git-view/enable` with the key as a bearer token.

  Cloning here always presents a key: the **anonymous public clone** is a hosted-service feature, and the community server's git surface is member-scoped (no public gates, no clone ticket).

  See [Cloning a Project with Git](https://swarmfile.com/docs/guides/git-clone) for what a clone contains and how push works. On the community server there is no background derive worker: a push confirms its commits itself as it lands, and if a push is interrupted, run `swarmfile git index` from a mounted drive to confirm the commits it left undecided.
- **Existing data**: bring a local tree or object store across with the standalone migration tool ([swarmfile-migrate](https://swarmfile.com/public/swarmfile/swarmfile-migrate)): `swarmfile-migrate --hub-url https://your-server --org-id my-org --project-id <project-id> --token <key> --source ./tree`. See [Migrating Existing Data](https://swarmfile.com/docs/guides/migrating-existing-data).
- **Place a single file**: the standalone `swarmfile-seed` ([swarmfile-seed](https://swarmfile.com/public/swarmfile/swarmfile-seed)) puts one local file at a project path, no mount: `swarmfile-seed --hub-url https://your-server --org-id my-org --project-id <project-id> --token <key> --file ./build/app.zip --path /Releases/app.zip`. See [Seeding a File Into a Project](https://swarmfile.com/docs/guides/seeding-files).
- **Mirror a project**: `swarmfile-mirror` ([swarmfile-mirror](https://swarmfile.com/public/swarmfile/swarmfile-mirror)) copies a project (or a subtree) to another, e.g. off the hosted hub onto yours: give it a `--source-*` and a `--dest-*` hub/org/project and token. See [Mirroring a Project Between Hubs](https://swarmfile.com/docs/guides/mirroring-projects).
- **Check connectivity**: the open `swarmfile-doctor` ([swarmfile-doctor](https://swarmfile.com/public/swarmfile/swarmfile-doctor)) confirms DNS, TLS, your key, the block path and your server's descriptor before you rely on it. See [Diagnosing Connectivity](https://swarmfile.com/docs/guides/connectivity-doctor).

These open tools each install on their own (`curl -fsSL https://get.swarmfile.com/{seed,mirror,doctor} | sh`) and share the migration tool's pipeline: an API key, no engine, no mount.

![The standalone swarmfile-migrate moves an existing tree into a project on a community server: the first pass is killed mid-flight, the same command again resumes exactly where the state file stopped - only the files that never landed move - then re-chunks every source file and checks it against its manifest.](https://swarmfile.com/demo/oss-migrate.cast)

## Keys and access

```bash
swarmfile-server admin --data-dir ./data list-keys
swarmfile-server admin --data-dir ./data revoke-key --key <id|name|prefix>
```

Each key is scoped to one project, or to the whole organization when it is minted without `--project`. A revoked key is refused on its next request; revoke and mint a replacement to rotate.

On a community server a key stands for a person, so give each teammate (and each CI job) their own. Every key has a role:

- **owner** (the default for `admin mint-key`, and what keys minted before roles existed became): manages every key in the organization, and its commits are recorded as confirmed history straight away.
- **member** (`admin mint-key --role member`): manages only its own keys and the keys it minted, and its commits wait for another person's key to confirm them, the hosted service's two-person rule. Keys a member mints count as that member, so a CI key is a good member key: it cannot write confirmed history on its own.

Keys can also be managed without the admin CLI: `swarmfile api-key list`, `swarmfile api-key create <name>` and `swarmfile api-key revoke <id>` work against the server. A key created that way is a member key for one project; an owner key can create another owner key. A key scoped to one project, an owner's included, manages only that project's keys. Revoking a key that has been used also rotates the organization's swarm secret, like `admin revoke-key`.

Locks belong to the key that took them. A `git push` that changes a file locked by another key is refused (`423 locked`) until that lock is released; your own lock never blocks your own push, and only the key that took a lock (or an owner key) can release it. The same goes for a folder reservation: changelists and pushes that touch a folder another key has reserved are refused until the reservation ends.

## Exposing it to your team

The server has no browser UI: the tools are native apps and CLI, so an ordinary LAN/TLS setup needs nothing extra. A few knobs exist for the rest:

- **Git surface.** Git serves clone and push by default. A mirror or read-only replica can narrow it with `SWARMFILE_SERVER_GIT_MODE` (or `swarmfile-server serve --git-mode`): `read` serves clones only (pushes, branch/tag writes and git-view enablement refuse with a typed `403 git_read_only`, while cloning an unsettled project still derives and mints its tip), and `none` turns git off entirely (`403 git_disabled` on every git route). It gates only the git surface; metadata, locks, LFS and blocks stay served, and `GET /api/profile` reports the mode as `features.git`.
- **Browser clients.** CORS is off by default. Set `SWARMFILE_SERVER_CORS_ORIGINS` to a comma-separated list of the origins allowed to call the API from a browser (or `*` to allow any); without it a browser tool cannot read any response.
- **Addressing.** Every route accepts the org and project **slugs** wherever it accepts ids (`https://your-server/orgs/my-org/projects/my-project/…`), so links written with names keep working. Slugs are derived from the name at creation and can be set with `--slug`.

## Peer-to-peer: the swarm

The server also runs the swarm's rendezvous: desktop apps pointed at it join your organization's private peer-to-peer network, so teammates' machines stream file bytes to each other **LAN-first**: one machine fetches a block from the server and the rest of the team fetches it from that machine, and live edits gossip between them instead of waiting for a poll. This is on by default; nothing needs configuring on the clients. A client that can reach a teammate only through the public relay falls back to your server by default; `SWARMFILE_ALLOW_RELAY_FETCH=1` on both serving and fetching engines opts into relayed block transfer (throttled).

![Two Macs on one LAN share a branch through the community server's swarm: a save here reaches the other machine in seconds, its stale save is refused, and an 8 MB render is then fetched peer-to-peer - 8.0 MiB from the colleague's machine, 0 bytes from the server.](https://swarmfile.com/demo/oss-swarm.cast)

`SWARMFILE_SERVER_P2P` (or `serve --p2p`) picks the posture:

| value | what it means |
|---|---|
| `all` (default) | Full peer-to-peer, and the swarm may carry **cleartext**: plaintext-tier project blocks, and cleartext stored on managed projects (LFS objects), included. The swarm is exactly the machines holding authenticated keys to *your* server. Note the swarm is per-organization: any org key holder's machine can fetch any project's blocks by content id, without the per-project scoping the HTTP routes apply; choose `sealed` if that matters to you. |
| `sealed` | Full peer-to-peer, but only sealed (managed-tier, encrypted) blocks travel between machines; cleartext is never served to peers, including the LFS objects managed projects store in the clear. (A machine may still cache cleartext blocks locally; it just won't hand them out.) Choose this if you want cleartext never to leave a machine over the swarm. |
| `off` | No peer-to-peer: no discovery, no peers; every read comes from the server. |

The posture is read when an engine starts, on project switches, and by every running engine's periodic server check, about every 5 minutes for desktop apps and headless seeds/runners alike (set `SWARMFILE_DOCTOR_PERIOD_SECS=0` on an engine to disable that check; then only a restart applies a change). A server that narrows its swarm therefore stops its running engines serving, without waiting for a restart. To cut a whole fleet's swarm instantly, rotate the secret and restart the clients.

How membership works: on the first request the server mints a per-organization swarm secret and hands it out only over the authenticated discovery route, so the swarm is exactly your team's engines. To remove a machine from the swarm, revoke its key; the server rotates the swarm secret in the same command:

```bash
swarmfile-server admin --data-dir ./data revoke-key --key <id|name|prefix>
```

Engines pick up the rotated secret within about 90 seconds; the replaced secret stays accepted during a short overlap so nobody drops mid-read. Until a rotation happens, a machine that still holds the current secret keeps swarm access: the rotation is the cutoff. Use `--no-rotate` for housekeeping revokes (a build-robot key that never joined the swarm); run `admin rotate-swarm-key --org <id>` by hand when you want a rotation without a revoke. `admin swarm-status --org <id>` prints the current generation, how long the old secret is still accepted, how many engines are live, and the cumulative peer-to-peer bytes the engines have reported.

What the swarm needs on the network: plain QUIC/UDP between your machines (engines bind a random local UDP port and advertise it through the server, so allow UDP egress), multicast for same-LAN discovery (mDNS), and, for teammates on different networks, the public iroh relay that lets two machines behind NAT meet. The relay is for meeting/address discovery: block bytes are not sent over it by default, so a relay-only peer also falls back to the server (`SWARMFILE_ALLOW_RELAY_FETCH=1` opts in, throttled). Block UDP entirely and the swarm quietly degrades: every read falls back to the server. Pin the bind address with `SWARMFILE_IROH_BIND_ADDR`, advertise a stable one with `SWARMFILE_IROH_PUBLIC_ADDR`, or turn the swarm off on a single machine with `SWARMFILE_HUB_ONLY=1`. To check a machine, `swarmfile doctor` prints the server profile (look for the `p2p=` / `p2pPlaintext=` fields) and probes iroh and mDNS.

What the community server does not do here: it does not sign change attestations (that trust layer is hosted-only), and it has no per-node approval list: membership is the swarm secret itself.

## History

Every change becomes a commit, the same as on the hosted service. The desktop app commits each burst of saves on its own once the drive has been quiet for a few minutes. Changes that arrive any other way are committed by the server itself once the branch has been quiet for five minutes: an import with `swarmfile-migrate` or a file placed with `swarmfile-seed`. While a desktop app is in the middle of its own commit the server waits twice as long, so a desktop app that went offline before committing is covered after ten minutes. Those commits are authored `server:autosave` and read `Saved N files`; `git push` records your own commits as they are. Set `SWARMFILE_SERVER_AUTOCOMMIT_IDLE_SECS` to change the quiet window (in whole seconds, 1 or more), or to `off` to leave such changes uncommitted until the next push or save. Staged changelists work too: `swarmfile changelist open`, then save, then `swarmfile commit -m` (or `changelist submit`) lands the staged files as one commit, and nothing reaches the branch before that. `swarmfile describe` renames a commit afterwards. Open sessions and changelists with no activity for seven days are closed automatically, as on the hosted service. When two people save the same file at the same time, the version that reaches the server first is kept and the other save is recorded as a conflict, as on the hosted service. That person's drive keeps their version, readable but read-only, and `swarmfile conflicts` names the file. `swarmfile conflicts resolve <path>` settles it: `--winner local` keeps their version, `--winner remote` keeps the one on the server, `--winner keep-both` keeps both (theirs is saved alongside as `name (conflict <user> <date>).ext`), and `--auto` merges text files; the desktop app offers the same choices. Until it is settled, saves to that file are refused from every computer, and deleting the file settles it. A `git push` that changes the file is refused until it is settled too. Locking the file first (`swarmfile lock`, or **Lock** in the desktop app) avoids this: while the lock is held, saves from other computers are refused, and so is a `git push` from any other key, until it's unlocked.

## Storage and backups

By default everything lives under the data directory: `system.db`, one SQLite file per project, the block store, and `history-signing.key` - the server's own Ed25519 checkpoint key, created on first use with owner-only permissions (0600 on macOS/Linux; on Windows the file takes the data directory's inherited ACLs). The server signs a checkpoint of each branch's confirmed head with it, and `swarmfile-verify-history` checks those signatures from outside. The key is part of the server's identity: back it up with the directory (a restored server keeps verifying), and replacing it starts a new identity - checkpoints signed by the old key stop verifying, and the replacement is loaded on the next restart. To keep block bytes in an S3-compatible bucket instead (metadata stays local), set `SWARMFILE_SERVER_S3_ENDPOINT` and `SWARMFILE_SERVER_S3_BUCKET`, plus `SWARMFILE_SERVER_S3_ACCESS_KEY_ID` / `SWARMFILE_SERVER_S3_SECRET_ACCESS_KEY` (`SWARMFILE_SERVER_S3_FORCE_PATH_STYLE` defaults to path-style, so MinIO-style endpoints work as-is; set it to `0` only for virtual-host-style endpoints; `SWARMFILE_SERVER_S3_REGION` for buckets outside `us-east-1`; `SWARMFILE_SERVER_S3_PREFIX` namespaces keys in a shared bucket). This needs a server built with the `s3` feature, which the release binaries and the container image are. The bucket must already exist. Back up the data directory (and the bucket, if you use one): that is the whole server.

![Day two on a community server: mint a key, prove it works, revoke it with one command - the org's swarm key rotates with the revocation - then the backup drill: stop the server, copy its one data directory, and serve the copy. The restored server passes the same doctor checks.](https://swarmfile.com/demo/oss-server-ops.cast)

## Copying a project with exact history (optional)

A same-server copy duplicates a project's database, hub-held key and block/LFS/git-object data into a new project. It is the one operation that preserves **managed** commit history exactly, because nothing is re-sealed. It is deliberately opt-in and the **real** copy runs with the server **stopped** (the command takes the serve lock and refuses while `serve` is running; `VACUUM INTO` would otherwise block writers). `--dry-run` does not create or copy project data or keys (normal startup schema maintenance may still run) and may run while serving. A detailed operator checklist ships with the server source:

```bash
# See exactly what would happen; writes nothing.
swarmfile-server admin --data-dir ./data copy-project \
  --org my-org --project <project-id-or-slug> --name "Copy" --slug copy --dry-run

# The real copy; prints the new id, head, key and object counts.
swarmfile-server admin --data-dir ./data copy-project \
  --org my-org --project <project-id-or-slug> --name "Copy" --slug copy
swarmfile-server admin --data-dir ./data mint-key --org my-org --project <new-id> --name copy
```

What it does and does not do:

- It copies the project DB (entries, branches, commits/claims, changesets), the hub-held project key (`managed`), and the `p/`, `lfs/` and `o/` data prefixes. The commit DAG, head hash and content CIDs are byte-identical; the source is never modified.
- It is a **point-in-time** copy; later writes to the source are not followed. It is **not a mirror**, does not cross servers or hubs, and is **not a backup**: it lives in the same data directory and block store, so a disk/store loss loses both copies. Transient locks, conflicts and checkouts are **not** carried; the copy opens clean.
- A crash during the copy can leave a marker-named destination (`__r219copy-…`) and its data prefixes under a `*.copy-<nonce>` staging name. The source is unaffected; the next `serve` start or `copy-project` removes marker rows, staging prefixes and their DB/key automatically. Inspect the server log for the cleanup line before retrying.
- It requires the **filesystem block store**; a configured S3 block store refuses before touching metadata (prefix copy is not implemented there yet).
- API keys are not copied (they are scoped and hashed); mint a destination key as above.
- The community server has no E2E tier (only `plaintext`/`managed`); E2E key sharing is a hosted/cross-hub gap and is not copied by this command.
- A pre-existing destination block prefix is refused (no silent merge), and a failed copy rolls back the destination metadata and copied prefixes.

## Federating public projects

Two community servers can serve **public plaintext** project data through an allowlist, without copying bytes: one server (the *origin*) exports a project to a named peer, and the other (the *federator*) pins that peer's signed host identity and allowlists the project. A federated clone then reads through the federator:

```bash
git clone swarmfile://server-a.example/fed/server-b/acme/widget
```

Set `SWARMFILE_SERVER_PUBLIC_URL` on both servers first (the signed origin-root `/.well-known/swarmfile.json` must be reachable, as described under [Connect the tools](#connect-the-tools)).
On the **origin** (`server-b`), pin the federator and export the project - the project must be plaintext, its git access enabled, and the export named by the right slug:

```bash
swarmfile-server admin --data-dir ./data federation peer add   --name server-a --origin https://server-a.example
swarmfile-server admin --data-dir ./data federation project allow --export   --peer server-a --org acme --project widget
```

On the **federator** (`server-a`), pin the origin and allow the remote project. The command fetches the origin's signed identity and its public clone gate, so the origin must already be exported and serving:

```bash
swarmfile-server admin --data-dir ./data federation peer add   --name server-b --origin https://server-b.example
swarmfile-server admin --data-dir ./data federation project allow   --peer server-b --org acme --project widget
swarmfile-server admin --data-dir ./data federation project list
```

`federation peer list` shows the pinned origin and key id; `federation project list` shows each row's role (`proxy` on the federator, `export` on the origin), whether its actor signature verifies, whether the actor is `local` or `imported` from the peer's host-signed `/.well-known/swarmfile-federation.json`, and the export's effective auth mode. The import is explicit and two-step: export locally, let the federator allow the proxy, then re-run the export with `--import-actor`; the federator's advertisement must verify against its pinned host identity, and no private key is copied. `peer disable` stops all of a peer's proxying and exports immediately (the pin stays for audit); `peer remove` then drops a disabled peer and its allow rows (the recovery path after a permanent host-key rotation); `project deny` removes one allow row. The per-project **actor key** is generated at allow time (or imported from the peer's signed advertisement with `--import-actor`), advertises the opt-in record, and is separate from every content key (which stays null for plaintext) and from the server's history-signing key.

### Signed peer reads (optional)

An export is public-by-URL by default. To require a per-request signature from the federator's per-project actor key, run this on the **origin** after the actor is imported and the federator has allowed the proxy:

```bash
swarmfile-server admin --data-dir ./data federation project allow --export \
  --require-actor --peer server-a --org acme --project widget
# later, back to anonymous public reads:
swarmfile-server admin --data-dir ./data federation project allow --export \
  --allow-anonymous --peer server-a --org acme --project widget
```

- The requirement is effective only when **every enabled export** of the project requires an actor; any anonymous export keeps the project public (`federation project list` shows `effectiveAuth`). The origin cannot attribute an anonymous request to a peer, so this is deliberately weakest-export-wins.
- The federator signs every proxied project read with the actor key (`swarmfile-fed-request-v1`: method, origin path + query, body hash, timestamp, nonce, hop chain); it also signs `_resolve` when it has that project's proxy actor key, and a configured proxy row with a missing secret fails closed. The origin checks a ±60 s timestamp, a short nonce replay cache and a per-actor budget (`SWARMFILE_SERVER_FEDERATION_ACTOR_RATE_PER_MIN`, default 6,000/min). An unsigned, stale, replayed or wrongly signed request gets the **same 404** as a project that is not exported.
- A signed POST (the LFS batch and oid-coverage) hashes the exact forwarded body; an unsigned POST to a required project is refused before its body is read.
- The setup order is: export (anonymous) → federator `project allow` → origin `--import-actor` → origin `--require-actor`. A federator re-allow after that signs its own gate check; a brand-new federator allow cannot, so switch the requirement on only after the first allow.
- Direct public links stop working while every export requires an actor. That is an access boundary, not a secrecy one: the data is still public plaintext, and a peer that is allowed to read it can.

### Public activity feed

Every exported project (and, with signed reads, every allowlisted peer) can read a metadata-only activity feed:

```
GET /public/<org>/<project>/activity?limit=50&before=<seq>
```

It returns `{org, project, generatedAt, branches, commits, nextBefore}`; commit messages are capped at 4 KiB (`messageTruncated` says so) so one huge message cannot make the feed unbounded. `branches` are the live public refs with provisional hashes withheld; `commits` are recent committed changesets (`seq`, `hash`, `status`, `treeCid`, `parents`, `message`, `committedAt`, `createdAt`, `branch`) - **no file contents and no author identities**. `limit` is 1-200 (default 50); pass `nextBefore` as `before` to page older commits. It uses the same export/auth gate as the clone and is carried through a federator at `/fed/<peer>/.../activity`. It is not a public directory: the project URL is still unlisted.

What this does and does not do:

- **Only public plaintext data.** Managed/E2E projects, ACLs, member data and keys are never federated. git-LFS **downloads** are served through the federator; LFS uploads and locking stay member-only. Exporting makes the project's public reads answerable to anyone who knows the origin's `/public/…` URL (unless every enabled export is actor-required) - the data is public and the URL is unlisted, not secret. Do not export private data.
- The federator acts as a **GET/HEAD proxy plus two read-only LFS POSTs** (`git/oid-coverage` and the download-only `git/lfs/objects/batch`; upload batches are refused) to the pinned origin's public reads. It refuses redirects, strips client credentials/cookies and hop-by-hop headers, caps response size and time (`SWARMFILE_SERVER_FEDERATION_MAX_BYTES`, `SWARMFILE_SERVER_FEDERATION_TIMEOUT_SECS`), rate-limits per peer/project (`SWARMFILE_SERVER_FEDERATION_RATE_PER_MIN`, default 600 per minute; raise it for large clones, which fetch many blocks) and writes one `[federation]` audit line per request. The origin has its own per-project budget (`SWARMFILE_SERVER_FEDERATION_ORIGIN_RATE_PER_MIN`, default 6,000/min, 429 + `Retry-After: 60`), which a direct anonymous reader also consumes; keep that in mind when sizing. It caches no bytes and never re-signs the origin's history.
- The peer's signed identity is re-verified against the pin every `SWARMFILE_SERVER_FEDERATION_IDENTITY_TTL_SECS` (default 60). Restoring the original key keeps serving; a **replaced** key fails closed until the operator pins it: `federation peer disable`, `federation peer remove`, then `federation peer add` with the new statement.
- A rename on the origin keeps old URLs working through its slug alias, but a federator allow row names a slug: using the new slug requires `federation project allow` again on the federator. The federated URL is explicit (`/fed/<peer>/…`); the federator never owns or aliases the origin's namespace. `project deny` keeps a proxy actor secret so a re-allow can sign a require-actor origin's gate; removing the peer clears it.
- The federator's host-signed `/.well-known/swarmfile-federation.json` lists every **proxy** row it federates (remote origin, org, project and the public actor key/signature). It is anonymous and signed, but it does publish the federation relationship; a peer-scoped advertisement is a Phase 4b item. Public project data and URLs are already public by design.
- **Mesh routes are experimental and disabled by default.** A federator can point a peer at another federator's `/fed` path (A→B→C) only when every participating server sets `SWARMFILE_SERVER_FEDERATION_MESH=1`; without it, path-scoped peers, signed `/fed` hops and two-plus-hop reads are refused. The intermediate `/fed` is not a published endpoint. Do not share that URL, do not rely on anonymous reads through it, and expect a future per-reader tightening. The supported public contract is `/public`, plus your own federator's client-facing `/fed` URL. The intermediate's opt-in flags (`allow_transitive` on the middle proxy, `allow_reexport` on the origin) are prerequisites for that future tightening, not a per-reader guarantee today.
- This is not ForgeFed/ActivityPub, not private/E2E federation, and not a mirror. Hosted/cross-hub managed copy and E2E key sharing are owner-deferred and not built on either side. The open-source server's `test:federation` lane proves the two-server slice end to end.

## Upgrades

Stop the server, replace the binary or image, start it again: the schema migrates on first use. If your team uses branches and is upgrading from a release before October 2026, run `swarmfile cache resync` once on each desktop app afterward: older servers sent other branches' changes to the default branch's sync, and the resync clears them from the app's cache. A data directory (or project) that a newer server has opened refuses to open on an older one, so downgrades need a backup rather than a rollback.

## What the community server leaves to the hosted service

Access rules and external collaborators, branch merges, the commit-level `revert` and `restore` operations, project management (archiving, retention and the other project settings beyond the administrator's `admin create-project` / `admin rename-project`; as in [First run](#first-run)), end-to-end encryption (refused by design: the server can never hold those keys), previews, share links, **anonymous drops** (the homepage's public-link upload surface), RFIs, notification emails and webhooks, hosted CI, the Office caches feature set (the enrollment wizard, fleet dashboard and reporting; a bare [seed node](https://swarmfile.com/docs/admin/self-hosted-seed-nodes) runs against this server too), multi-organization hosting, and billing. If your team needs those, the hosted service keeps them. The same clients work with both, so you can start yourself and move when it helps.

The clients say so rather than failing. Any request for one of these gets the same refusal from the server itself, so a command with no special handling still prints why it was refused. Where the desktop app reaches one of these (the **Project settings** panel, **New project**, the CI and notification settings, a share-link or RFI link, the plan usage card), it shows one line saying the feature isn't available on this server, with a link back to this section. The CLI refuses the same commands with the code `not_supported_on_this_server` and adds a `hint:` line pointing here (a `hint` field under `--json`); `swarmfile project create`, `git import` and `clone --into` name the `admin create-project` command. The server has no web dashboard either, and its profile says so, so the desktop app's links into one (**Open in browser**, **Team**, account settings, the full notification inbox, a file's web link, a release's public URL) explain that in one line instead of opening the hosted site, and the tray menu drops **Open on the Web**.
