How Taxiway connects to your server without SSH
Lloyd Owen
20 August 2026 · 9 min read
Almost every self-hosted deployment platform works the same way: you install a control panel somewhere, you give it an SSH key with root, and it reaches into your servers to do its work. Taxiway does the opposite, and that one inversion is why it can be free, why the footprint is a single Go binary, and why it works on a machine with no public IP at all.
I want to walk through the actual protocol, because “no inbound SSH” sounds like marketing until you can see the frames.
The shape
There are two halves. We run the control plane: an API that holds your configuration, a gateway that speaks to agents, and a Postgres database. You run the workloads: a Go binary called the agent, Traefik at the edge, and your containers.
The important part of that drawing is that the arrow between the two zones points
leftwards. Your server initiates. We never do. There is no port on your machine that
belongs to Taxiway, no key of ours in your authorized_keys, and nothing for a scanner to
find. The only ports you open are 80 and 443, and those are for your visitors, not for us.
Enrolment: one token, exchanged once
When you add a server in the dashboard you get a one-line install command with a short enrolment token in it. The agent starts, reads the token, and dials the gateway:
raw, _, err := websocket.Dial(dialCtx, opts.GatewayURL, nil)
Fifteen seconds to connect, and a read limit of 1 << 20, one mebibyte, on any single
frame, because an agent should never be talked into allocating an arbitrary buffer by
whatever is on the other end of the socket.
The first frame it sends is hello, and it is also the machine’s introduction:
{
"type": "hello",
"time": "2026-08-20T09:14:02Z",
"payload": {
"token": "a1b2c3d4e5f6",
"version": "0.14.2",
"hostname": "production-01",
"platform": "linux/arm64",
"cpus": 4,
"memory_total_bytes": 8329424896,
"disk_total_bytes": 84271263744,
"capabilities": { }
}
}
The gateway checks the token, records the host’s shape, and replies enrolled:
{
"type": "enrolled",
"payload": {
"server_id": "srv_2f9a…",
"agent_credential": "…"
}
}
That is the last time the enrolment token is ever used. It is swapped, once, for a
long-lived credential scoped to that one server, which the agent writes to disk and
presents on every reconnect. Revoke a server in the dashboard and the credential stops
working. There is no key to hunt down and delete from a ~/.ssh directory on a box you
may have forgotten you own.
Everything rides one socket
Every message on the wire is the same envelope: a type, a JSON payload and a timestamp:
type Message struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
Time time.Time `json:"time"`
}
That is deliberately boring, and it means one connection carries the lot:
desired_state: the gateway telling the agent what should be running: images, env, labels, volumes, domains.report: the agent telling the gateway what actually is running, and how the last deploy went.heartbeat: host vitals every thirty seconds.service_metrics: per-container CPU, memory, network and disk, sampled from Docker.log: container output, streamed as it happens.console: an interactive shell session, multiplexed by session id, with input, output and terminal resize frames.db_query/db_result: running a query from the dashboard against a database that has no public port.backup_run/backup_result,import_run/import_result,restart,deploy_cancel.
No second port, no separate log shipper, no sidecar. If the WebSocket is up, all of it works; if it is down, none of it does, and your containers carry on serving traffic regardless, because nothing in the request path goes anywhere near us.
The heartbeat is doing more work than it looks
Every thirty seconds:
{
"cpu_usage_percent": 18.4,
"memory_usage_percent": 61.2,
"disk_usage_percent": 44.0,
"docker_health": "ok",
"network_slots_total": 31,
"network_slots_used": 12
}
The first four fields are obvious. The last two are there because of a genuinely annoying Docker behaviour: Docker allocates every project network from a fixed address pool, and that pool runs out long before your disk or your RAM does. We report it on the heartbeat rather than once at connect, because the number moves as projects are created and destroyed, and a stale figure would refuse to create a project that would in fact have fitted perfectly well.
That is the kind of thing you only learn by running the thing.
Reconnection, and why the agent is boring on purpose
Networks fail. The agent’s response is unglamorous: redial, with an exponential backoff that starts at one second, doubles each time, and caps at five minutes.
const (
backoffMin = time.Second
backoffMax = 5 * time.Minute
)
There is no clever failover, no queue that grows without bound, no exotic transport. A process that reconnects reliably and does nothing surprising in the meantime is worth more than one that is clever, because this thing runs unattended on machines I will never look at.
On top of the reconnect there is a full sync every five minutes: the agent asks for the complete desired state rather than trusting that it has seen every incremental update since it started. If a patch went missing during a blip, the next sync silently repairs it. Doing the dumb, complete thing on a slow timer removes an entire category of bug.
One writer per fact
The other half of keeping this honest is on our side. Each resource carries three things:
target: the spec you want. The API writes this, and nothing else does.applied: the spec of the deployment that is actually live. The gateway writes this when the agent reports, and nothing else does.runtime: telemetry. Also the gateway’s, from heartbeats and metrics.
Whether a service is healthy, out of sync, or has unsaved changes is computed when you ask for it, never stored. There is no status column to go stale, no cache to invalidate, and no possible disagreement between two writers about what is true, because there are never two writers.
What this actually buys you
Nothing to expose. The single most common way self-hosted infrastructure gets owned is an SSH port with a weak or over-shared key. We do not ask you for one.
It works behind NAT. A machine under your desk, a box on a home connection, a VM on a corporate VLAN with no route in. All fine. It only has to be able to reach the internet outbound on 443.
Revocation is one click. The credential is per-server and ours to invalidate. Compare that with auditing which of your servers still trusts a key issued to a panel you stopped using last year.
The footprint is one binary. No PHP-FPM pool, no Node process, no Postgres and no Redis running on your host just so you can deploy to it.
The honest caveats
Two of them, and I would rather you heard them here.
If our gateway is unreachable, you cannot start a new deploy. Your containers keep running, Traefik keeps serving, your database keeps accepting connections, and nothing in the traffic path involves us, but the control plane is genuinely a dependency for making changes. If that is unacceptable to you, a fully self-hosted tool is the right answer and I would not argue.
And the agent has real power on your host: it talks to the Docker socket, so it can start and stop containers and read their logs. That is the job. It is worth understanding rather than glossing over.
If you want to poke at this, the install is one command on any Linux box with Docker, and it is free. If you want to argue with any of the above, I would genuinely like to hear it: support@taxiway.cloud.