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.

GitHub logo glincker / levelrail

Self-hosted alternative to Vercel and Coolify. Git push, HTTPS, logs, rollback. No Kubernetes.

Levelrail logo

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.

GitHub stars License: Apache 2.0 Latest release CI status Last commit levelrail-cli on npm MCP server Discord

Website · Docs · Install · Compare · FAQ · Roadmap · Discussions

English · 简体中文 · Español · Português (BR) · Deutsch · Français · 日本語

Levelrail: your own Vercel, on your server. The lightweight, self-hosted alternative to Vercel, Heroku and Coolify.

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).

Levelrail dashboard tour: fleet overview, apps list, deploy history and built-in metrics










200 MB

idle RAM, 1.5% of one CPU
measured, method included

1 binary

Go control plane, Docker runtime
no Kubernetes

360

one-click service templates
smoke-tested

240+

MCP tools for coding agents
scoped, read-only by default

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)