blog · · engineering

Your notes never touch our relay

outl syncs your notes device to device with no server in the middle. Almost. There's exactly one server-shaped thing in the path, a relay, and it's the part that makes people nervous: 'wait, my notes go through a machine you run?' The honest answer is that the relay forwards bytes it can't read, and the reason it can't isn't a promise in a policy doc, it's the transport. This walks a single sync from boot to merge: the ed25519 identity that never leaves the device, the one QUIC endpoint, the vector-clock handshake that streams only the ops the other side is missing, and the exact moment the relay touches the connection. At every step the content is encrypted end to end with keys the relay never has. The step-by-step is the proof: there is no point in the pipeline where the relay could read a note even if it wanted to.

A
13 min read

Every time I explain how outl syncs, the same question shows up, and it’s the right question to ask.

“So my notes go through a server you run?”

The pitch is no server. Your notes live on your devices, sync flows device to device, nobody’s cloud in the middle. And that’s true, with exactly one asterisk: there’s a single server-shaped thing in the path called a relay, and if you don’t know what it does, “peer-to-peer sync with a relay” sounds like a contradiction dressed up to sound better than “we have a server.”

So here’s the claim, stated as plainly as I can, and then the rest of this post is the proof.

The relay forwards bytes it cannot read. Your notes are encrypted end to end with keys the relay never has. It can see that two of your devices are talking, and roughly how much. Never what.

That’s not a promise in a privacy policy, the kind you have to take on trust. It’s a property of the transport: there is no point in the sync pipeline where the relay is handed a key, so there is no point where it could read a note even if the operator wanted to. The way to show that is to walk one sync from beginning to end and point at where the content is, where the encryption is, and where, precisely, the relay touches the wire.

outl’s P2P transport is built on iroh: QUIC connections addressed by public key. The code is in the outl-sync-iroh crate. Let’s follow a single op from one device to another.

step 0: the identity that never leaves

Before anything syncs, a device needs an identity. Not an account, there is no account, no email, no sign-in. An identity in the cryptographic sense: a keypair.

On first run, outl generates an ed25519 keypair and writes it to ~/.outl/identity.key, 0600, one per machine:

// identity.rs
let secret = SecretKey::generate(&mut rng);
// persisted to ~/.outl/identity.key, permissions 0o600 on Unix

The public half of that key is the device’s address on the network, its node id. When another device wants to reach this one, it dials that public key. The private half never leaves the disk it was written to. It is never synced, never uploaded, never sent to a relay. This is the whole foundation: the key that decrypts your sync traffic exists only on your devices, so the question “can the relay decrypt it” has a one-word answer before we even start. No key, no.

Hold onto that. Everything else is plumbing on top of it.

step 1: boot, one endpoint, one door

When outl starts with [sync] transport = "iroh" (the default), it binds a single long-lived iroh endpoint using that identity:

// engine.rs: the long-lived sync endpoint
let endpoint = bind::n0_builder_ipv4_only(relay_url.as_deref())
    .secret_key(identity.secret_key().clone())
    .alpns(vec![SYNC_ALPN.to_vec(), PAIRING_ALPN.to_vec()])
    .bind()
    .await?;

Two details matter here.

One endpoint per identity. The device binds exactly one endpoint for its node id, and it advertises two protocols on it: SYNC_ALPN (outl-sync/2) for syncing and PAIRING_ALPN (outl-sync/pair/1) for adding a new device. ALPN is QUIC’s “which protocol are we speaking” tag, negotiated inside the TLS handshake. Bumping it to /2 was deliberate: the wire format changed, and an old client dialing a new one now fails cleanly at connect instead of misparsing a stream. (Binding a second endpoint on the same identity is exactly the mistake that broke sync once via a status probe. One identity, one endpoint, is load-bearing.)

The relay registration. As part of bind, the endpoint tells its home relay “I’m reachable, here’s my node id.” That’s the first and only time the relay enters the picture at boot, and it’s worth reading the log line it produces:

INFO endpoint{id=35c8fc38bf}:relay-actor:
  home is now relay https://use1-1.relay.avelino.outl.iroh.link./

The device just registered a rendezvous point. It handed the relay a public key and said “route inbound connection attempts for this key to me.” It did not hand over any notes, any keys, or any workspace data. The relay now knows one thing: a device with this public key exists and is currently reachable through it. That’s the entire deposit.

step 2: finding a peer

Syncing needs someone to sync with. Paired devices live in <workspace>/.outl/peers.json, per-graph rather than per-device, so pairing a laptop into one workspace doesn’t expose it to another workspace on the same machine.

Each entry carries the peer’s node id and, critically, its endpoint_addr: the full iroh EndpointAddr captured at pairing time, meaning node id + relay URL + direct socket addresses. That’s what lets the dial try the cheapest path first.

When outl resolves how to reach a peer, it orders candidates most-reachable-first:

  1. The full EndpointAddr. If there’s a direct LAN address, connect straight to it, no relay involved at all.
  2. Node id + relay URL, for older entries: let the relay help.
  3. Bare node id: fall back to discovery.

There’s a filter in front of the direct addresses (is_reachable_lan_ipv4) that drops VPN, CGNAT, and public-WAN IPs a pairing might have captured, keeping only addresses that are actually on a local subnet. Two devices on the same wifi sync entirely over the LAN, so the relay is registered but never carries a byte.

That’s the first place the “no server” claim gets concrete: on your home network, sync is fully local and the relay is idle. It only earns its keep when the two devices genuinely can’t see each other.

step 3: the dial, and where the relay actually works

Now the connect:

// engine_sync.rs: connect_with_fallback
endpoint.connect(peer_addr, SYNC_ALPN).await

Under the hood iroh does the NAT-traversal dance that every P2P system does (STUN/TURN, WebRTC’s ICE, Tailscale’s DERP, same family of trick):

  • Direct, if possible. If a direct address works, QUIC connects straight across. Relay untouched.
  • Hole punch, via the relay. If both peers are behind NAT, they use the relay to exchange addresses and timing, then fire packets at each other’s NATs simultaneously to punch a path open. When it works (it usually does) they get a direct QUIC connection and the relay drops out of the data path entirely. It saw the coordination handshake and nothing after.
  • Relayed fallback. When hole punching fails (symmetric NAT, strict corporate firewall), the relay forwards packets between the two peers so sync still works. Slower, uses the relay’s bandwidth, but never fails closed.

This third case is the only one where your sync traffic transits the relay at all. So it’s the case worth being exact about, because it’s the one people are actually worried about.

Here is what the relay forwards in that case: QUIC packets, encrypted with TLS 1.3, keyed to the two devices’ ed25519 identities. The relay terminates nothing. It has neither device’s private key. Remember step 0: those never left the disks they were born on. It is a pipe moving opaque ciphertext from one node id to another. It can count the bytes. It cannot open them.

When the connection is up, outl opens a single bidirectional QUIC stream over it and runs the whole sync inside that stream. Every message from here on is inside the encrypted tunnel.

step 4: the handshake, send only what’s missing

Two devices are connected. Neither wants to re-send its entire history; they want to exchange only the ops the other is missing. outl does this with a per-actor vector clock.

For every device it has ever heard from, a peer knows two numbers:

// protocol.rs
struct ActorClock {
    max: Hlc,    // highest HLC timestamp seen from this actor
    count: u64,  // distinct ops at or below `max`
}

The max is a watermark: “I have everything from this device up to here.” On its own a watermark assumes ops arrive in order with no gaps, which isn’t true over a lossy, out-of-order network. The count is what closes that hole. It’s a gap detector. If I hold fewer distinct ops below your watermark than you claim to, an op slipped in ahead of a backlog, and I resend that actor’s full log rather than trust the watermark.

The exchange is four framed messages over the one stream:

  1. Initiator sends SyncRequest { workspace_id, vector_clock }, its clock for every actor.
  2. Responder validates the workspace id, checks the initiator is in its peers.json (fail-closed: an unknown or unreadable peer list refuses the sync), and replies with its own clock.
  3. Each side computes ops_missing_for(other's clock), walking its log and keeping only what the fast-path watermark says the peer lacks, or the full actor log if the count says there’s a gap, then streams that blob.
  4. Both ingest. The responder closes the stream with an explicit “done” code only after the ops are durably written, so “synced OK but nothing arrived” can’t happen silently.

What crosses the wire in steps 3 and 4 is your actual content: the ops that encode every block edit, move, and property. And it crosses inside the QUIC + TLS 1.3 stream, between two endpoints that authenticated each other by ed25519 identity. If this sync is being relayed, this is the exact traffic the relay is forwarding: encrypted ops it has no key for. The workspace-id check and the peer-authorization check both run on the responding device, inside the connection, not on the relay. The relay has no idea a workspace id was even exchanged.

This is also why offline catch-up is free: the op log (ops-<actor>.jsonl) is the buffer. A device off for a week reconnects, sends its clock, and pulls exactly the ops it missed. No full resync, no server holding a queue.

step 5: ingest, dedup, and merge

Received ops don’t get trusted blindly. ingest_received_ops runs a few gates before anything lands:

  • Clock sanity. An op timestamped more than 24h in the future is logged and skipped, so one device with a wrong clock can’t poison the merge.
  • Dedup by (actor, ts), under a cross-process file lock on the ops directory. The same op arriving twice, or two processes on one machine (the GUI and an MCP agent) appending at once, can’t corrupt the log or apply anything twice.
  • A frame-size cap (256 MiB) checked before allocating, so a malicious peer can’t send a 0xFFFFFFFF length prefix and force a 4 GiB allocation.

Only after those gates do the ops hit disk and replay through the tree CRDT (Kleppmann’s move operation), merging deterministically with everything already there. That merge, the reason two devices always converge to the same tree, is its own story; here the point is just that it happens on your device, from ops that arrived encrypted, decrypted only by a key that never left.

staying live: catch-up and gossip

One sync is done. Staying in sync is two loops running quietly.

  • Catch-up, every 8 seconds: reload the peer list, re-dial new or previously-failed peers, and run a maintenance re-sync against peers that already synced. When both sides’ clocks match, delta_sync is a cheap no-op. This makes convergence independent of anything fancier. Even if gossip never fired, the 8-second tick pulls everyone even.
  • Gossip, real-time: when a device commits a local op it announces it to a swarm topic derived from blake3(workspace_id), and peers who hear it dial back to pull. The announce also kicks a direct forced-sync pass so devices not yet in the swarm still get poked.

Both loops feed the same delta_sync from step 4, and both feed the reachability map that drives the little status dot. Every real dial records whether it reached the peer, so the UI reports actual sync traffic instead of a synthetic ping.

None of this changes the relay’s role. Every dial these loops make is another QUIC connection with the same encryption, either direct or relay-forwarded-but-opaque. More frequency, same guarantee.

so what can the relay see

Being honest about the asterisk means naming its exact size, no bigger and no smaller.

When traffic is being relayed (i.e., hole punching failed for that pair), the relay observes:

The relay can seeThe relay cannot see
The two endpoint IDs (public keys) talkingThe content of any op, block, or page
Connection timing (when you sync, how often)Which pages or blocks changed
Packet sizes and traffic volumePage titles, tags, backlinks, anything semantic
The IP each endpoint connects fromThe op log, the CRDT state, your .md files

So the honest threat model is: a relay operator can learn that two specific devices sync, when, and roughly how much. Never what. And after a successful hole punch, even that shrinks to the initial handshake, because the data went direct.

The reason the right column is empty isn’t diligence. It’s that filling it would require a private key the relay was never given. Step 0 decided the whole table.

why I can promise the part I can promise

The default relay is a dedicated one under outl’s own namespace (use1-1.relay.avelino.outl.iroh.link), not the shared public pool, so at least the metadata isn’t spread across best-effort infrastructure. It’s still hosted on iroh’s authors’ infra today. A relay outl fully owns, fronted by relay.outl.app, is on the roadmap, and the thing it buys is metadata sovereignty: moving that “who syncs with whom, and when” off anyone else’s box. It does not buy more content privacy, because content privacy is already total on any relay.

And if you don’t want to trust any relay of mine, there are two exits, both first-class:

  • Point at your own. relay_url = "https://your-host" in [sync], run iroh-relay on a small VPS, and your devices coordinate through a box only you touch. One config line.
  • Drop the relay entirely. Switch to transport = "file" and outl syncs the same per-actor op logs over iCloud Drive, Syncthing, or any shared folder. Same CRDT, same convergence, zero iroh, zero relay. The transport is a trait; the algorithm doesn’t know or care which one moved the bytes.

“Peer-to-peer with a relay” is not a contradiction, and it’s not marketing softening a server. It’s a precise architecture: the relay is a coordination layer that helps two devices find each other and, when the network forces it, forwards packets it structurally cannot read.

The reason I can say “your notes never touch our relay” and mean it isn’t a policy I’m asking you to trust. It’s that the key which decrypts your sync traffic is generated on your device, stored on your device, and never sent anywhere. Follow the sync from boot to merge and there is simply no step that hands the relay a way in. The walk is the proof.

The whole transport is open source in the outl repository, in the outl-sync-iroh crate. If a claim here bothers you, the best move is the one the license invites: go read it.