If you run a handful of Docker services and want them kept alive without Kubernetes, you have two common options: scripts that SSH in and run docker, or a platform with its own control loop. This post explains the second design as Levelrail builds it, why we chose it, and what it costs. After reading you can judge whether a reconcile loop and an agent suit your setup, or whether a lighter tool fits better.
Checked on 2026-11-01 against the current Levelrail release.
Docker orchestration choices at a glance#
| Choice | Instead of |
|---|---|
| A reconcile loop driven by desired state | A script that runs commands per deploy |
| Docker’s event stream | Polling docker inspect on a timer |
| An agent that dials out with mutual TLS | SSH from the control plane |
| The Docker Engine API | Shelling out to the docker CLI |
How a level-triggered reconcile loop works#
Desired state lives in a database. A loop compares it with what runs and fixes the difference. The pattern comes from Kubernetes controllers.
The key word is level-triggered. An edge-triggered system reacts to events: “a container died, so restart it”. Miss the event and you never recover. A level-triggered system asks every time: what should exist, and what does exist? An interrupted pass does no harm, because the next pass re-derives the work from current state.
desired state (database) --+
+--> diff --> act --> status condition + reason
observed state (Docker) --+Every pass ends by writing a status condition with a reason string, so the dashboard can say why something is unhealthy, not only that it is.
Writing reconcilers takes care. A controller cannot assume the container it is about to create does not exist, or that an operation from a moment ago finished. Each reconciler needs a test for the half-succeeded case: container created, start failed.
Why Docker events beat polling#
Observed state comes mainly from Docker’s event stream. A periodic resync runs as a backstop for events the stream missed. A system that polls every server on a timer burns CPU while nothing happens, and the cost grows with apps and servers. Reacting to events keeps idle work low.
What the other options look like#
| Option | Why not |
|---|---|
| Docker Swarm | Building on its update semantics couples you to its limits |
| Nomad | Business Source License |
| Kubernetes or K3s | Brings back the surface area this project exists to avoid |
SSH plus shelled docker commands | Pushes you toward polling or accepting gaps |
| One-shot SSH with no agent | No idle cost, but the transport becomes the failure point |
Other self-hosted tools make different choices, and they suit different people. By their own documentation, Coolify and Kamal manage servers over SSH, Dokploy and CapRover build on Docker Swarm, and Dokku runs commands on the host with no daemon. Kamal has no server component, which gives it zero idle cost and the fewest moving parts. See the full comparison for a fair side-by-side, and verify any detail against each project, because they move fast.
How the reverse-dialed agent works#
On extra nodes, Levelrail runs levelrail-agent. It connects out to the control plane over gRPC with mutual TLS, and the control plane never opens a connection to a node. Three things follow:
- Managed nodes need no inbound port for control traffic.
- The setup works behind NAT.
- The agent streams Docker events up instead of waiting for polls.
Enrollment uses a one-time join token that the agent trades for a client certificate. On a single server the agent runs inside the control plane process over an in-memory transport with the same interface, so one server and ten servers share a code path.
No shell out to docker#
Nothing in the reconcile path calls the docker CLI or SSH. A narrow runtime interface is the only way controllers touch containers, and the Engine API carries every call. That helps testing, because you can swap in a fake runtime that fails on command, with no Docker daemon. An API returns typed errors, while a command line returns strings that change between versions.
What this design costs#
- A persistent agent is one more thing to install and keep running, where SSH-only tools need nothing on the target.
- A control plane with its own state is more to back up than a tool with none. Levelrail keeps state in embedded SQLite files in one data directory.
- Cross-node networking over the WireGuard mesh, the default multi-node path, runs so far only locally across two Docker daemons, not over a real WAN.
If you manage one box and want the least machinery possible, a smaller tool may serve you better. If you run a handful to dozens of services and want the platform quiet as that number grows, this trade fits.
What can go wrong#
| Symptom | Cause and fix |
|---|---|
| Node shows offline | The agent lost its session. Containers keep running because the node’s Docker owns them. Restart the agent. |
| Deploy fails on a remote node | Check the failure code with levelrail-cli apps deploys show <name>. |
| App unhealthy with no obvious cause | Read the status condition reason: levelrail-cli apps status <name>. |
What I have not verified yet#
The competitor descriptions above come from each project’s public documentation at the time of writing and from our own research notes. They may be out of date. I have not benchmarked idle CPU against any SSH-based tool.
FAQ#
Is this Kubernetes?#
No. It borrows the controller pattern, but runs one Go binary against the Docker Engine API.
Do I need an agent on a single server?#
No. The agent runs inside the control plane process.
What happens if the control plane is down?#
Containers keep running, because Docker owns them. Management stops until it returns.
Where next?#
See how this shows up in a deploy in zero downtime deploys for Docker apps. Reference: Architecture, Feature status, the reconciler ADR and the agent ADR.
Levelrail is open source under Apache 2.0. If this saved you time, a star on the repo helps other self-hosters find it.