One VPS is full. Here is how I add a second server without Kubernetes
The question that keeps coming up in self-hosting threads goes like this: my single VPS is running out of memory, do I now have to learn Kubernetes? I wanted a straight answer for Levelrail, the self-hosted deploy platform I work on, so I spent an evening adding a second server to a throwaway instance and writing down every requirement the docs claim. I could run some of it. I will say which parts.
TL;DR
| Step | What happens | What to know |
|---|---|---|
| Pick a method | One pasted command, an SSH adoption, or a server created at a cloud provider | All three end with the same thing: an agent on the new box |
| Connect | The agent dials out to the control plane over mutual TLS | The new box needs no inbound port for control traffic |
| Preflight | Docker, CPU architecture, disk, clock, WireGuard, mesh port | Linux on amd64 or arm64 only |
| Place apps | Each app lives on exactly one node | No replica spreading across nodes yet |
What a second node is, and is not
A Levelrail install is one control plane binary. On a single host it also runs your containers through the local Docker daemon. When you add a node, a small agent (levelrail-agent) runs next to the Docker daemon on the new server and connects out to the control plane. Docker's own daemon on that box still owns the containers.
That outbound direction matters. The architecture doc says a managed node needs no inbound port for control traffic, and the control plane holds the other end of a session the agent opened. Enrollment uses a one-time join token and a certificate signing request, and the agent can pin the control plane's CA fingerprint.
This does not replace Kubernetes. Nothing spreads the replicas of one app across machines, and the multi-node doc says all replicas of one app run on one node today. What you get is placement: a second box that takes whole apps and databases.
The three ways to add the box
| Method | You run | Good for |
|---|---|---|
| Join command | One docker run or binary command on the new server | Any machine you can already log in to and want to see every step on |
| SSH adoption | One CLI command from your laptop | A fresh VPS where you do not want to paste anything |
| Provision at a provider | The CLI creates the server for you | Hetzner and DigitalOcean, per the CLI help |
Method 1: the join command
levelrail-cli nodes add --name worker-1 --preflight This prints a command for the new server with a single-use token and the CA fingerprint, then waits for the node and prints each preflight check. The token is valid for 15 minutes by default, and you can change that with APP_NODE_JOIN_TOKEN_TTL.
Before you run it for a real remote machine, set APP_AGENT_ADVERTISE_HOST on the control plane to the public IP or hostname the new server can reach. The docs explain why: with the loopback default, enrollment succeeds but the node's TLS session fails hostname verification on every attempt, and the node sits at pending forever. levelrail-cli doctor warns about it.
For a box that should keep the agent across reboots, the docs show installing the .deb or .rpm and letting systemd run it, with the token and CA fingerprint in /etc/levelrail-agent/agent.env.
Method 2: SSH adoption
levelrail-cli nodes ssh-provision \ --host 192.0.2.10 --user root --key-file ~/.ssh/id_ed25519 \ --name worker-1 --control-plane-addr controlplane.example.com:9443 The control plane connects, detects the OS and CPU, installs Docker with the get.docker.com script if missing, writes /etc/levelrail-agent.env (mode 0600) and a systemd unit, and waits for the agent to enroll. The install runs in the background, so you follow it with levelrail-cli nodes ssh-provisions show PROVISION_ID.
What the docs require: Linux with systemd on amd64 or arm64, SSH as root (or a user that can run everything as root without a prompt), the new server able to reach the control plane's agent port (9443 by default) outbound, and the control plane able to reach the server's SSH port.
The CLI checks host keys before it sends any credential. Without a pin flag the CLI prints the server's fingerprints and asks you to confirm. In a script you pass --host-key-fingerprint SHA256:..., which you read on the server with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub.
What I actually ran
I used a local build of the CLI and control plane (a development build from just before v1.0.1, so I read the v1.0.1 docs against it) on my laptop, a Mac with Docker Desktop, using levelrail-cli dev on a spare port. This was a throwaway instance with its own data directory, removed afterwards. It is not a real VPS, and that matters for what follows.
The join flow, with an agent on the same machine
I built levelrail-agent from the release source, minted a join command with nodes add, and ran the agent with the printed environment variables. It enrolled and the control plane issued it a certificate. Then I asked for the preflight report:
CHECK STATUS DETAIL control_plane PASS the agent reached the control plane and completed the mTLS handshake docker PASS Docker 28.3.0 answered on the local socket architecture FAIL darwin/arm64 is not a supported node platform (supported: linux on amd64, arm64) disk PASS 24548 MiB free clock PASS the node clock differs from the control plane by 4ms wireguard FAIL WireGuard needs a Linux kernel mesh_port PASS UDP 51820 is free on this node firewall UNKNOWN a node only dials out, so inbound reachability of UDP 51820 ... Two failures, both correct, because my "server" was a Mac. The check says so plainly instead of letting a doomed node limp along. The node never reached online here. I assume the same cause but did not confirm it. That run covered enrollment, the mTLS handshake and the preflight report, and stopped short of a working node.
The SSH flow, against a Debian container
For the SSH path I started a Debian 12 container running sshd, bound to a local port, and pointed ssh-provision at it with its host key pinned.
levelrail-cli nodes ssh-provision --host 127.0.0.1 --port 42510 --user root \ --key-file ./testkey --name w2e-vps \ --host-key-fingerprint SHA256:<fingerprint from ssh-keyscan> The provision connected, detected Debian GNU/Linux 12 (bookworm) on arm64, decided Docker was missing and tried to install it. It then failed, because my minimal container has no curl and no systemd:
docker: Docker not found, installing via get.docker.com docker: bash: line 1: curl: command not found failed: start Docker: Process exited with status 1 (output: Failed to enable unit, unit docker.service does not exist.) A container is not a VPS, so the failure says nothing about a real server. It did show the stage log and failure reason, which is how you will debug a bad adoption. The docs list a this host has no systemd message as an early failure, and I did not see that one. The run got as far as the Docker step first. I report it as I saw it.
I also changed one character of the pinned fingerprint to see whether the pin holds. The provision failed at the connect stage with a message naming the expected fingerprint and the ones the server presented, and sent no credential. That is the behavior you want from a pin.
What I did not run
I did not add a real remote VPS by either method. I did not run the agent on Linux. I did not place an app on a second node, enable the WireGuard mesh between two hosts, or test a node dropping off. The SSH doc says so itself: tests with a fake SSH server cover the flow, not a run against a real VPS or home server.
The part people miss: routing
Adding the node is the easy half. Your domains still end at the control plane, because the embedded Caddy ingress runs there and a worker has no public listener. Traffic reaches an app on a worker through the node's WireGuard mesh address.
For apps on a second server, turn the mesh on. That means the control plane's APP_AGENT_ADVERTISE_HOST is a public IP address (a hostname does not work with the mesh), inbound UDP 51820 is open on its firewall, and each agent runs with the network capability and TUN device the docs list. If the mesh path is down, the app can report healthy while browsers get a TLS failure. Levelrail flags that as a NoMeshIngressPath condition and in levelrail-cli doctor, and the multi-node doc is open that cross-host mesh is lightly proven, exercised against fakes and a single host rather than two real hosts.
Moving an app over
Once a node is online:
levelrail-cli nodes list levelrail-cli apps set-node myapp worker-1 --with-volumes Without --with-volumes the move creates empty volumes on the new node and leaves the old ones behind, which breaks anything with state. With it, Levelrail stops the app, copies each named volume over the existing transport, changes the placement and starts the app on the new node. The doc warns the move is not atomic and has no automatic rollback: a failure leaves the app suspended and placement unchanged, and a retry is safe.
Where this stops being enough
The docs list these limits.
- Each app runs on one node. A second server gives you capacity, not high availability for a single app.
- Agent upgrades are manual. The control plane only flags an outdated agent.
- What happens when a node stays unreachable is opt-in. The default policy is
off, and apps with node-local volumes and all databases are never moved automatically, because that could lose data. - Placement is a fit check plus a spread. Cordoned and offline nodes are never picked, and you can pin by labels.
If you need multiple replicas of one app behind a balancer across hosts, a scheduler built for that is the better tool today, and that is a fair reason to look elsewhere.
FAQ
Do I need Kubernetes to run apps on two servers?
Not for this. You need a control plane, an agent on the second server and a way for traffic to reach it, which is the mesh. You give up per-app replica scheduling across hosts.
Does the second server need an open inbound port?
For control traffic, no, the agent dials out to the control plane's agent port. For routing app traffic over the mesh, the control plane needs inbound UDP 51820, and the docs say workers reach each other on that port too.
Can I use a Raspberry Pi or a home server?
The SSH doc names a home server and a Raspberry Pi as targets, as long as it runs Linux with systemd on amd64 or arm64. No metadata service means you set provider, region and zone yourself with --provider, --region and --zone. I did not test either.
What if the SSH install fails halfway?
Read the stage log with nodes ssh-provisions show. Common fixes in the docs: pin the right fingerprint, allow outbound access to get.docker.com or install Docker yourself first (it leaves an existing Docker alone), and open outbound TCP to the agent port.
Closing thought
The flow is shorter than I expected, and the preflight is more honest than most installers. The piece I would want proof of before trusting it with production is the one I could not run: two real Linux hosts, the mesh between them, and a node going away mid-deploy. Has anyone here done that on a pair of cheap VPSes and kept notes?
If this saved you time, a star on the repo helps others find it.
glincker / levelrail
Self-hosted alternative to Vercel and Coolify. Git push, HTTPS, logs, rollback. No Kubernetes.
Levelrail
Your own Vercel, on your own servers.
Push to deploy with HTTPS, logs, metrics and one-step rollback. One Go binary, no Kubernetes. Apache 2.0.
Website · Docs · Install · Compare · FAQ · Roadmap · Discussions
English · 简体中文 · Español · Português (BR) · Deutsch · Français · 日本語
Turn any Linux server into a private cloud: Git deploys, HTTPS, databases with verified backups, metrics, logs and alerts, without Kubernetes and without a monitoring stack to bolt on. Pre-1.0: single-server is production ready, multi-node is beta (Feature status).
200 MBidle RAM, 1.5% of one CPU | 1 binaryGo control plane, Docker runtime | 360one-click service templates | 240+MCP tools for coding agents |
Install
curl -fsSL https://levelrail.com/install.sh | sudo sh Needs Linux (amd64 or arm64) with systemd and root; the installer adds Docker. Open ports…
Sources checked
- Levelrail docs at release/v1.0.1: add-node-over-ssh.md, add-node-anywhere.md, multi-node.md, architecture.md
-
levelrail-cli nodes --help,levelrail-cli nodes ssh-provision --help, and my own local runs described above (development build, macOS arm64 laptop, Docker Desktop 28.3.0, throwaway instance, removed afterwards)


