Skip to content

Adopting an existing fleet

You already have servers running, maybe a lot of them, maybe across more than one cloud, maybe with firewall rules you wrote by hand years ago. Adopting means bringing those servers under managed without taking anything down and without losing the protection you have today.

The golden rule managed follows: nothing that could break a running server happens until you've proven it's safe. In particular, your firewall is only ever switched to managed's control last, after the private network is up and every rule is in place, and there's a command that tells you, in plain language, whether flipping that switch would cut off something that's working right now.

The idea in one picture

Each server you adopt walks through these stages. You can stop, check, and fix at any point, the next stage doesn't start until you say so.

StageWhat happensCommandHow you know it's safe to continue
1. AdoptThe server is added to managed. Nothing on it changes, no firewall, no lock-down, no reinstall.add it to servers:, then managed applyIt shows up in managed status
2. Join the private networkThe server joins managed's encrypted private network (WireGuard), so it can talk to the rest of your fleet privately, even across clouds.managed apply --only networkThe network check passes (every box can reach every other)
3. Check the firewallmanaged compares the firewall it would set up against what's actually running on the box, and flags anything that would break. Read-only, nothing changes.managed firewall diff <server>Verdict is clean (or warn you've reviewed)
4. Shadow the firewallmanaged writes its firewall rules but doesn't turn the firewall on yet. Your existing rules stay in charge.managed apply --only firewall --servers <server>Re-run firewall diff, nothing left to break
5. Cut over & lock downmanaged turns the firewall on, hardens the box, and switches you to the deploy user. This is the one switch-flip, do it with a second SSH session open.managed apply --allow-adopt --servers <server>The box is still reachable and its services still work
6. Check the control portThe management port is closed by default (the agent dials out to the dashboard instead). Open it only if something of yours needs direct access.(optional) set agent.allow_from, then managed applyfirewall diff shows the port closed or scoped

Stages 1–4 are completely reversible and change nothing your services depend on. Stage 5 is the first real cut-over, and even that has an instant escape hatch (ufw disable puts your old rules back in seconds).

Step by step

1. Adopt the server (nothing changes yet)

Adopting many servers at once? managed can pull them straight from your cloud account, or take a pasted list of addresses — each box is probed over SSH and added to the pool, nothing on it changes:

bash
managed server scan                            # what's on my cloud accounts?
managed server import --provider do_prod --all # pull them all in
managed server import --file hosts.txt         # or a list, one user@ip per line

(The dashboard has the same flow under Servers → Add a server → Import from your cloud account.) Then continue from stage 2 below for the whole batch.

To adopt a single box declaratively instead, point managed at it. With just an ip:, managed will SSH in but won't harden it or touch its firewall:

yaml
servers:
  crawler-1:
    ip: 203.0.113.10
    ssh_user: root
bash
managed apply

The box is now tracked. Because you didn't pass --allow-adopt, managed leaves it exactly as it was (you'll see a note that its firewall isn't managed yet).

2. Put it on the private network

bash
managed apply --only network

This gives the box an address on managed's WireGuard overlay (a flat 10.100.0.0/24 private network that spans every server, on every cloud) and verifies it can reach every other box. Your existing private network keeps working alongside it, this is purely additive.

3. Check the firewall, before you touch it

bash
managed firewall diff crawler-1

This is the safety check. It reads the box read-only and tells you exactly what turning managed's firewall on would do, which ports it would open, which it would narrow, and crucially any port that's in use right now that no rule would keep open:

Firewall preview
  crawler-1 (firewall currently off)
    ✓ open    from 10.100.0.5 to any port 4949 proto tcp
    ✗ port 13337 would be closed, it's in use right now

  ✗ Not safe yet, turning on the firewall would close a port that's in use
    hint: Run 'managed firewall import crawler-1' to keep those ports open, then check again

It exits 0 if safe, 2 if there's something to review, 3 if something would break, so the dashboard (and you) can gate on it.

To fix a flagged port, declare it so managed keeps it open. The quickest way is:

bash
managed firewall import crawler-1

which suggests a firewall.allow block for every in-use port that has no rule:

yaml
servers:
  crawler-1:
    ip: 203.0.113.10
    firewall:
      allow:
        - port: 13337
          from: private    # only your own network (the safe default)
        - port: 443
          from: public     # change to public for a port that serves the internet

from: can be private (your own network), overlay (the encrypted mesh only), public (the internet), or a specific IP/CIDR like 10.0.0.0/8. Paste the rules you want, then run managed firewall diff crawler-1 again until it's clean. (Ports that belong to a managed service get their rule from the service package automatically, so you only declare the ones managed doesn't already know about.)

In the dashboard's Adopt wizard this is a one-click "keep these ports open" on the firewall step, no editing required.

4. Shadow the firewall

bash
managed apply --only firewall --servers crawler-1

managed writes its rules without enabling the firewall. Your hand-written rules are still the ones in force, so there's zero risk, this just stages the new rules so you can confirm they match before the switch. Run firewall diff once more; it should now be clean.

5. Cut over and lock down

Open a second SSH session to the box and leave it connected (your safety line), then:

bash
managed apply --allow-adopt --servers crawler-1

This turns the firewall on, upgrades packages, creates the deploy user, and disables root SSH, and it does all of that after the network and rules are in place, so nothing that was working gets cut off. Confirm the box is still reachable and its services still respond. If anything looks wrong, sudo ufw disable in your held session instantly restores your old firewall.

6. Check the control port

The management agent talks to the dashboard over an outbound connection, so its port stays closed by default — there's nothing to lock down. If an earlier version of managed left the port open, the next managed apply closes it. Only if something of yours needs to call the agent directly do you open it, scoped to those sources:

yaml
agent:
  allow_from:
    - overlay
    - 203.0.113.50   # a monitoring box of yours
bash
managed apply

See Configuration → Agent for the details.


Example migration: a two-cloud content platform

Here's how this plays out on a real-shaped fleet. The names are made up, but the shape is a genuine production system: a content-crawling platform with about forty servers split across two clouds, all currently firewalled by hand.

What we're starting with

GroupServersCloudStateful?Notes
Crawlerscrawler-1crawler-16DigitalOcean (US)NoStateless workers. Each runs a page renderer and a local cache (Redis bound to 127.0.0.1 on a non-standard port).
Ingest workersworker-1, worker-2DigitalOceanNoAccept inbound jobs on a public port range.
Pusherspusher-1, pusher-2DigitalOceanNoPublic 80/443.
Cache / queuecache-1, queue-1DigitalOceanYes (in-memory)Redis cache and job queue.
GUID allocatorguid-1DigitalOceanYesA small single MySQL that hands out IDs to every crawler. A single point, handle with care.
Main databasedb-mainOVH (bare metal, US)YesMySQL primary plus a read/write splitter in front of it.
DB replicadb-replica-euOVH (bare metal, EU)YesLive replica of db-main.
Posts shardsdb-posts-1, db-posts-2OVHYesSharded content storage.
Searchsearch-1OVHYesSingle-node search index.
Appweb-1, web-2OVHNoThe web application.
Monitoringmonitor-1DigitalOceanYes (history)Scrapes a metrics port on every box.

Three facts make this a classic hand-rolled fleet, and exactly the situation adopting is built for:

  1. The firewall is raw iptables (default-deny, persisted by hand) and the managed-friendly firewall tool (ufw) is installed but switched off. If you naively turned ufw on, every rule you didn't re-declare would vanish.
  2. The two clouds aren't one network. Every box has a public IP and a cloud-private IP, but DigitalOcean's private network and OVH's private network can't reach each other, so today, cross-cloud links (the OVH app talking to the DigitalOcean GUID allocator) are forced over the public internet.
  3. Some services listen on non-standard ports (that crawler-local cache on a custom port, bound to localhost), the kind of detail a blind takeover gets wrong.

The WireGuard overlay fixes #2 for free: once every box is on the 10.100.0.0/24 private network, the OVH app reaches the DigitalOcean allocator over an encrypted private address instead of the public internet.

The order: cheap and recoverable first, precious and stateful last

We roll the fleet through the stages one group at a time, and we pick the order by blast radius:

  1. A single crawler first. crawler-1 is the safest possible test: it's stateless, it exposes almost nothing (just SSH and the metrics port the monitor scrapes), and if it blips for a minute the system just retries. We take it all the way through cut-over and watch it for a day.
  2. The rest of the crawlers, then the ingest workers and pushers. Same shape, now with confidence. The ingest workers have public ports, so firewall diff matters more here, it makes sure those public ports stay open.
  3. Cache, queue, GUID allocator, monitoring. Stateful but simple. The crawler's localhost-only cache on its custom port shows up in firewall diff as not a problem (it's loopback, nothing off-box can reach it, so closing the public firewall doesn't break it). The GUID allocator goes carefully: it's a single point every crawler depends on, so we declare its port rules to allow exactly the crawler group over the overlay, run firewall diff until clean, and keep a second session open.
  4. Search, posts shards, the app.
  5. The main database cluster, dead last. db-main (primary + splitter) and db-replica-eu carry live replication and are the one place a mistake is unrecoverable. We do not let managed reconfigure or restart a live database as part of adoption; we adopt the box (network + firewall) but leave the database's own configuration alone, and we keep the cross-cloud replication link on its current path until the overlay has been carrying real load long enough to trust.

A worked first box: crawler-1

bash
# 1. Adopt, add to servers:, then apply with NO --allow-adopt. Nothing on the box changes.
managed apply

# 2. Join the private network.
managed apply --only network

# 3. Check, read-only. crawler-1 exposes SSH (always kept) and the metrics port (4949).
#    Its local cache on the custom port is loopback-only, so it's correctly ignored.
managed firewall diff crawler-1
#   → declare a port rule allowing monitor-1 → 4949 over the overlay, re-check until "clean"

# 4. Shadow the rules (firewall still off, old rules still in charge).
managed apply --only firewall --servers crawler-1
managed firewall diff crawler-1            # confirm: clean

# 5. Cut over, second SSH session held open. Firewall on, box hardened.
managed apply --allow-adopt --servers crawler-1

# Watch it for a day. If anything's wrong: `sudo ufw disable` in the held session.

Then repeat for crawler-2, the rest of the group, and onward down the order, each group only after the previous one has settled.

Why "DB last" really matters here

The main database has two properties adoption treats with extra caution:

  • It's stateful and live. Adopting a box never wipes or reinstalls what's on it, but you should still not point a database package at a running primary and let it rewrite the config or restart the daemon. Adopt the box for network and firewall only; manage the database's own config the way you do today until you're ready to hand that over deliberately.
  • Its replica link crosses clouds. That OVH→OVH (and OVH→DigitalOcean) traffic is the highest-stakes thing on the overlay. Keep it on its current path until the overlay has proven itself under real load on the cheaper tiers, then move it with eyes open, watching replication lag the whole time.

By the time you reach the database, you've already run every other group through the exact same steps, so the procedure is boring, which is precisely what you want when the stakes are highest.

A guided version is coming

The steps above are the building blocks. A point-and-click migration wizard in the dashboard, that walks a whole fleet through these stages, runs firewall diff for you, and refuses to advance a server until it's safe, is on the roadmap (see plans/ADOPT_FLEET_WIZARD.md in the repository). The commands here are what it drives under the hood, so anything you learn now carries straight over.