Grow your network onto additional machines over a WireGuard tunnel.
One box eventually fills up. Additional service servers carry more profile containers while the coordinator keeps doing what it does: TLS, routing, the database, and deciding where new services go. This guide adds one server; repeat it for each.
The coordinator talks to a remote server's agent, and proxies profile traffic to its containers, over a WireGuard tunnel. That keeps the internal secret and user traffic encrypted between machines, and means the containers never need public ports.
On a fresh Ubuntu server, repeat the base steps from the Single-Server Setup: deploy user + firewall (step 2) and Docker (step 7). It needs no nginx, certificate, or PostgreSQL.
Install WireGuard and generate a keypair on both machines:
sudo apt install -y wireguard
umask 077
wg genkey | sudo tee /etc/wireguard/privatekey | wg pubkey | sudo tee /etc/wireguard/publickey
Pick a subnet that neither machine already routes. Check first with ip route on both hosts: cloud providers often occupy 10.x ranges and Docker takes 172.x, so something like 192.168.99.0/24 is usually the safe island. The examples below use coordinator = 192.168.99.1, new server = 192.168.99.2.
Coordinator, /etc/wireguard/wg0.conf (keys are pasted literal values, not file paths):
[Interface]
Address = 192.168.99.1/24
ListenPort = 51820
PrivateKey = <coordinator privatekey contents>
[Peer]
PublicKey = <server publickey contents>
Endpoint = <server public IP>:51820
AllowedIPs = 192.168.99.2/32
PersistentKeepalive = 25
New server, /etc/wireguard/wg0.conf:
[Interface]
Address = 192.168.99.2/24
ListenPort = 51820
PrivateKey = <server privatekey contents>
[Peer]
PublicKey = <coordinator publickey contents>
Endpoint = <coordinator public IP>:51820
AllowedIPs = 192.168.99.1/32
PersistentKeepalive = 25
Lock down the files, open the firewall for the tunnel, and bring it up (both machines; swap the peer IP accordingly):
sudo chmod 600 /etc/wireguard/wg0.conf
sudo ufw allow from <peer public IP> to any port 51820 proto udp
sudo ufw allow in on wg0
sudo systemctl enable --now wg-quick@wg0
Verify: sudo wg show reports a handshake on both ends, and each machine can ping the other's tunnel address.
Back up
/etc/wireguard/somewhere safe. Losing the keys means regenerating and re-pairing.
On the new server, build the profile image and configure the agent exactly as in the single-server guide (steps 8 and 9), with two differences in the agent's .env:
WG_ADDRESS=192.168.99.2
INTERNAL_SECRET=<same value as the coordinator's .env>
SERVER_NAME=sa1
DOMAIN=yourdomain.site
NODE_API_URL=https://yourdomain.site
WG_ADDRESS is this server's tunnel address. The compose file publishes the agent, and binds every profile container, to that address, so nothing is reachable from the public internet. (This matters: Docker's published ports bypass ufw, so the bind address is the real protection.)"${WG_ADDRESS}:8000:8000"; the remote agent uses port 8000 (only the coordinator's own agent runs on 8001).Also create the data directories and Docker network (the relevant parts of prepare-profiles.sh; no sudoers rule is needed here, since nginx runs only on the coordinator):
sudo mkdir -p /var/lib/nodeyou/profiles /var/lib/nodeyou/compose
sudo chown -R you:you /var/lib/nodeyou
docker network create nodeyou
Start the agent and check it from the coordinator, across the tunnel:
docker compose up -d --build # on the new server
curl http://192.168.99.2:8000/api/internal/health # on the coordinator
When you later update the agent's code,
docker compose up -d --buildapplies it.--force-recreatealone only re-applies env and compose changes on the old image; code changes need--build.
In the app's Admin pane, Service Servers > Add server:
sa1).192.168.99.2.8000.The moment the row exists with status active, placement includes it: new services go to the least-loaded active server with free capacity. See Network Administration for capacity changes and draining.
DOMAIN/NODE_API_URL/INTERNAL_SECRET. Don't point two coordinators at one agent.python -m scripts.rewrite_profile_nginx in the API's venv on the coordinator, and re-provision the affected profiles so their containers re-bind (deactivate + activate; data files are preserved).draining, wait for its services to deactivate or migrate, then offline.