The relay
The only transport that needs no estate infrastructure at all. No git host, no storage account, no VNet, no inbound rule. Both sides dial out over ordinary HTTPS and meet at a server neither of them trusts.
Status, plainly
| station side | complete. Fetches requests, publishes status and progress, delivers the finished log |
| relay server | dbhq-uk/heliograph-relay, deployed at heliograph-relay.dbhq.uk. Free to use, and you have to ask for a token |
| control side | heliograph init --transport relay, and a round trip in CI drives all three halves |
It works end to end, over the deployed relay. On 2026-09-09 a sealed request went out through heliograph-relay.dbhq.uk, a station picked it up, ran the step and sealed the log, and the CLI read it back with its timestamps and its footer intact.
Two things prove it, and they prove different things. A round trip in CI drives the real binary, a real start.sh and a real heliograph-seal against a local relay - that is what shows the two halves agree. A second check, on main only, asks the deployed relay whether it still answers this credential - that is what catches the TLS, the custom domain, the routing and the token going wrong independently of this repository, which they can and which is silent.
The hosted relay, and how to ask for a token
heliograph-relay.dbhq.uk is a relay DBHQ runs, built from the source in dbhq-uk/heliograph-relay. Every block on this page that names it needs an estate on it first, and there is no sign-up page. Provisioning is a person editing an environment variable, so somebody issues you the two tokens by hand.
Ask at the address in SECURITY.md - it is a one-person company and that is the mailbox - saying which estate id you would like. What comes back is the estate id, a control token and a station token. Nothing else is created anywhere: an estate on this relay is three strings in a configuration value, which is also why it can be given away.
Free, with limits, and not a promise for ever
Free today. No card, no account, and nothing to buy. What it is not is permanent, and this page will not imply otherwise.
The limits are the server's own constants rather than a plan, so they are the same on the hosted relay and on a container you run yourself:
| a message | 8 MiB. A larger one is refused with 413 |
| a queue | 256 messages, per station per direction. A full one is refused with 429, rather than dropping what is already in it |
| retention | seven days, and a message is deleted the moment it is collected |
| a poll | held open for 25 seconds |
No SLA, no support commitment, and no backup. A relay is a queue, not a store - see below - so anything you need to keep, the control node keeps.
If it changes, thirty days' notice. If the limits change, if it starts costing something, or if it stops, everybody holding a token gets an email at the address they asked from at least thirty days beforehand, and heliograph-relay.dbhq.uk keeps answering for the whole of that period. Hand provisioning is what makes that promise keepable: the mailbox that issued a token is the record of who holds one.
That notice matters more than it looks, because a station holds RELAY_URL in an environment variable on a machine in somebody else's estate. There is no channel to push a new endpoint down; changing one costs a round trip through the operator, and a change-controlled estate takes longer than an email does.
If thirty days is not enough for what you are putting on it, run your own. It is one container and no keys, below, and the two sides cannot tell the difference.
Setting one up
The estate id and the two tokens come from whoever runs the relay. On the hosted one that is the section above; on your own it is whatever you put in HELIOGRAPH_RELAY_ESTATES.
heliograph init payments --transport relay \
--dir https://heliograph-relay.dbhq.uk \
--relay-estate payments --scope db-a
heliograph plant -e payments # what to send the operator
heliograph relay peer -e payments <the line they send back>
export HELIOGRAPH_RELAY_TOKEN=... # the CONTROL token
heliograph send steps/probe.sh
Full flags on the CLI page. The one step that matters most is the last one there: compare the two fingerprints over a channel the operator already trusts. It is the only step a machine cannot do for you.
Running your own
The relay is a queue with a token check and no keys, so hosting one is a small thing to own:
CTL=$(head -c 32 /dev/urandom | base64)
STN=$(head -c 32 /dev/urandom | base64)
docker run -p 8080:8080 \
-e HELIOGRAPH_RELAY_ESTATES="payments:$CTL:$STN" \
ghcr.io/dbhq-uk/heliograph-relay:latest
Put it behind something that terminates TLS. heliograph init refuses a plain http:// URL for anything but loopback, because the token travels as a bearer header on every request.
Keep the estates value somewhere you can read it back. Cloudflare secrets are write-only, so a hosted relay whose token nobody recorded can only be re-issued - which means re-enrolling every station on it.
The threat model, which is the whole point
The relay is outside the trust boundary in both directions.
A relay that could read your logs would be a privacy problem, and an estate that will not give you SSH is not going to accept one.
A relay that could forge a request would have code execution inside every estate at once, through a channel the estate installed deliberately. That is the one that matters, and everything below exists to make it impossible rather than merely against the rules.
How that is achieved
Content is encrypted with a key held only on control and station. The relay stores and forwards ciphertext it cannot read, and every message is signed by its origin and verified before it is acted on.
Sign then encrypt, in that order: the signature travels inside the encryption, so the relay cannot see who signed what, and cannot strip or swap a signature it cannot reach.
Nothing bespoke:
| key agreement | X25519 |
| key derivation | HKDF-SHA256 |
| encryption | ChaCha20-Poly1305 |
| signing | Ed25519 |
That is the age construction with signing added. ChaCha rather than AES-GCM because station hardware is unknown and may lack AES-NI, where ChaCha is both faster and constant-time in software.
Every message binds the estate, the station, the direction, the kind and a sequence number inside the signature, so a relay cannot replay a message, reflect one back, or forward one from a different estate.
Sequence numbers are the replay defence
They are persisted on both sides. Losing that state is not merely inconvenient: a reset would let the relay replay everything it has ever seen. The station keeps them in a local, gitignored file.
Two processes take them, so a lock is not optional. The loop publishes the status; the runner is a separate child process and publishes the finished log. Both took a number from a value loaded when they started, so they collided - and the receiver dropped the second as a replay, correctly, because that is what a replay looks like. The log arrived and the run reported running for ever, with nothing erroring anywhere. Numbers are now taken under a mkdir lock, and the state file is written by taking the higher of each field so a stale writer can never wind the counter back.
A relay is a queue, not a store
It deletes on collection and expires after seven days. So the control node keeps what it collects, and one consequence is worth knowing: whatever asks the relay a question collects everything waiting, including messages it was not asking about. That is why heliograph doctor proves its credential by collecting properly rather than by a throwaway request, and why the station's preflight probes a routing key nothing uses - an earlier version proved the token by reading this station's own request queue, which silently ate whatever request was waiting every time somebody started a station.
Two tokens, two scopes
Because the station token sits on a machine you do not trust and cannot reach.
| token | may |
|---|---|
| station | read requests, write status and logs, for one estate |
| control | write requests, read logs, for one estate |
Why the bash station needs a binary, and the PowerShell one does not
heliograph-seal is the single exception to nothing is installed on the far side, and it applies to the bash station only. It is argued for explicitly rather than smuggled in.
openssl enc refuses AEAD ciphers outright. A shell implementation would have to hand-assemble encrypt-then-MAC and key agreement across openssl 1.1.1 and 3.x behaviour differences - which is where crypto bugs live, and where they are silent.
So heliograph-seal does the sealing and no networking at all. curl stays in the shell, where its behaviour can be read and debugged. Every other bash transport stays pure bash and always will.
The binary must be present and executable or the station refuses to start: there is no plaintext fallback and no degraded mode.
The checksum is only enforced if you set one. With RELAY_SEAL_SHA256, a binary that does not match refuses to run. Without it the station prints a warning and carries on, which is weaker than this page used to claim. Set it.
The number comes from SHA256SUMS on the release, and you do not have to take our word for it either: provenance is the command that rebuilds heliograph-seal from the tag and arrives at the same hash. That is the whole answer to "you told me the far side was readable and this is a binary" - you cannot read it, and you can prove it is the source you did read.
On the PowerShell station there is no binary and no checksum
The PowerShell station does the same construction in managed C# that ships as source inside the payload, compiled by Add-Type when the transport loads. RELAY_SEAL and RELAY_SEAL_SHA256 do not apply there; everything else on this page does.
That is not a shortcut around the argument above. It is the same reasoning reaching a different answer, because the constraint is different: a shell cannot do AEAD, and .NET can be given the code to. An estate that refuses a native binary is exactly the estate that payload exists for, so a relay that needed one would not have been a relay for them at all.
The two seals are compared rather than assumed to agree. internal/seal emits golden vectors from fixed keys, and the PowerShell side has to reproduce them byte for byte at every stage - canonical metadata, shared secret, derived key, signature, sealed message - as well as open what Go sealed and refuse six tampered variants. Each primitive is separately checked against its own standard's vectors, because a round trip is satisfied by two implementations that agree with each other and with nothing else.
Configuring a station
TRANSPORT=relay
RELAY_URL=https://heliograph-relay.dbhq.uk
RELAY_ESTATE=payments
RELAY_STATION=sql01
RELAY_TOKEN=<the station-scoped token>
RELAY_IDENTITY=/path/to/station.key # this station's key
RELAY_PEER=/path/to/control.pub # the control side's public identity
RELAY_SEAL_SHA256=<checksum from the release> # bash station only
RELAY_SEAL_SHA256 is optional and should not be - see above. On the PowerShell station it does not exist, because neither does the binary.
A request may not set anything that configures capture, delivery, redaction or identity - including every RELAY_ variable above. RELAY_PEER is the sharpest case: it is what an incoming request is verified against and* what an outgoing log is sealed to, so a request that could choose it could choose who reads the log. Reserved by prefix rather than by name, and tested. See security.
Self-hosting
The same server, one container, no keys:
docker run -p 8080:8080 \
-e HELIOGRAPH_RELAY_ESTATES="payments:$CONTROL_PUB:$STATION_PUB" \
ghcr.io/dbhq-uk/heliograph-relay:latest
It holds public identities so it can route, and nothing it could decrypt anything with.
Long-poll, not WebSocket
"Proxies strip the upgrade header" is the usual reason given and it is over-general, so here is the narrower one. A proxy that only tunnels CONNECT never sees a wss handshake, because it is inside the TLS, and cannot strip what it cannot read. A proxy that intercepts TLS terminates the connection, reads the handshake, and can strip the upgrade - and there is no client-side fix for that. Only whoever runs the network can permit it.
The estates this exists for are the ones most likely to run one. A corporate root CA and a deep-inspection appliance are ordinary where every change goes through a board and nobody gets a route in, so wss would work in most networks and fail in the ones that matter most, silently. A long poll is an ordinary HTTP request that happens to take a while: no intermediary needs to understand it, and there is no upgrade to strip.
The long poll holds for 25 seconds server-side, so a client must wait longer than that or it times out its own successful poll.
Known limits, before you rely on it
Two, both found by adversarial review and neither yet fixed. They are here rather than in an issue tracker because somebody evaluating this transport needs them before they choose it.
A cancelled run's partial log does not arrive. The station passes it alongside the cancellation status, and only the git transport honours that. On the relay the cancellation arrives and the partial evidence stays on the station.
Sequence numbers can collide between the loop and the runner. The loop loads its counter once at start; the runner delivers the finished log from its own process and advances the counter there. The loop does not reload before publishing idle, so it can emit a number the runner has already used - and the receiver drops anything at or below what it has already accepted, by design, because that is the replay defence. One of the two messages can be lost.
A relay-only estate cannot be alerted on when it goes quiet
This one is structural rather than a defect, and it decides whether the relay alone is enough for you.
Everything the relay carries reaches a hosted archive only when the control node forwards it, with heliograph push. That is deliberate: a hosted poller reading the relay would race your own control node, and a relay deletes on collection, so whichever side asked first would silently eat the other's logs.
The consequence is that a relay-only estate gets the archive and the history, and gets no gone-quiet alerting. Nothing reaches the service while your control node is closed, so the service cannot tell a station that has stopped reporting from a laptop that is shut. You cannot alert on silence when your only source of it is also silent.
Running a git transport alongside closes it, and costs nothing: the station pushes to the repository whether anybody is watching or not.
What DBHQ can and cannot claim
Can: the hosted relay cannot read your content, and cannot cause a station to run anything. The server source is public precisely so that is checkable rather than trusted, and it holds no keys.
Cannot: that the relay learns nothing at all. It sees message sizes, timings, which estate is active and when. Traffic analysis is not addressed and is not claimed to be.
The full account is in docs/specs/2026-09-06-relay-encryption-design.md, including what was corrected in it and why.