I just moved my self-hosted OpenClaw from one cloud VM to another. The whole thing — from “let me have a look at what’s running” to “the bot replied to my Telegram message on the new box” — took about 90 minutes, and the bot was offline for less than two of them.
The interesting part isn’t that it worked. The interesting part is what shape the plan had to take so that nothing ended up in a half-migrated state, and how OpenClaw itself helped me think through the migration before I touched a single file.
Why move at all
The honest answer: AWS was costing me roughly 10× what an equivalent VM costs at a smaller European host. For a workload that’s just a single always-on Node process, a SQLite database, and a Telegram poller, EC2 is wildly overpriced. I’d been putting up with it because the box already worked and inertia is a hell of a thing — but every cost report I generated kept making the same point, and eventually it got loud enough to act on.
The disk situation tipped me over the edge. The old root volume had been quietly creeping up; I wasn’t out of space, but backups, npm caches, and a growing SQLite database would have started squeezing each other within a few weeks. The fix on AWS is to attach a bigger volume, which costs more. The fix elsewhere is to provision a VM that already comes with ten times the disk for a fraction of the monthly bill. So I did that.
Two things shaped how I approached this:
- The bot uses Telegram long-polling, not webhooks. The bot calls
getUpdateswith an offset stored locally. That means there’s no DNS or webhook URL tied to the old IP — the new box just needs to keep polling from where the old one left off. Convenient. - But polling has a split-brain failure mode. If both the old and new bot run at the same time, both will call
getUpdates, both will advance the offset, and Telegram will hand each instance a different slice of the queue. Messages get processed twice. Or once. Or not at all. The cutover sequence has to make this impossible.
That second point ended up being the load-bearing constraint of the whole plan.
Inventory before touching anything
Before any action, I had OpenClaw inventory the source machine. Not “look at the home directory” — actually catalog:
- Every running systemd service and what command it executes
- Every globally-installed npm package, with exact versions
- Every manually-installed apt package
- Every hand-rolled config file under
~/.config - Active listening ports
- Mount points and disk usage by directory
- Cron jobs (the OpenClaw-managed ones, not just the OS crontab)
- Firewall rules
Two things fell out of this that wouldn’t have surfaced if I’d just rsync’d and crossed my fingers:
One service had been quietly broken for months. It was enabled and listed as a unit, but its ExecStart referenced a CLI flag the binary no longer accepted. The journal had no successful runs, ever. On a “lift and shift” migration I would have re-enabled it on the new box and watched it fail there too. Discovering this before the cutover meant I just didn’t recreate it. No mystery, no time wasted debugging.
Another service had API keys baked into its systemd unit as Environment= lines. That’s a common pattern, but it meant I couldn’t blindly copy the unit — the path inside ExecStart referenced the old host’s user and a now-irrelevant mount point. The new unit had to be authored fresh, with the secrets carried over but the paths reasoned out from scratch.
The strategy: reinstall the runtime, restore the data
There were two legitimate options:
- Verbatim copy: rsync the entire home directory and
/mnt/data, recreate users with matching UIDs, fix up symlinks, hope nothing in~/.cachewas machine-specific. - Hybrid: install the runtime cleanly on the new box (apt packages, npm globals at the same versions, the user’s pip site-packages), then restore the data — config, credentials, the SQLite DB, scheduled jobs, the Telegram offset.
I chose hybrid. Three reasons:
- The user account name differs between the old and new boxes. Every hardcoded path in every config file would need rewriting either way; if I’m doing that work, I’d rather start from a clean filesystem layout than untangle symlinks pointing into someone else’s home directory.
- 9 GB of the source disk was npm cache and a global node_modules tree. Both are fully derivable. Why pay to copy them?
- A clean reinstall surfaces version drift. If a package no longer exists at that version, I want to know now, not three weeks later when something silently degrades.
Three categories of files
Once you start moving things, you discover that not all files behave the same way. I ended up sorting everything into three buckets:
Stable code — safe to migrate before cutover. Scripts, skills, blog content, vendored repos. These don’t change while the bot is running, so I could rsync them, rewrite paths, and verify them at leisure with the old box still serving traffic.
Volatile state — must be re-synced after the source stops. The Telegram offset file. The cron job-state file. The agent’s running session logs. The SQLite database. Anything the live bot is actively writing to. Pulling these too early means the new box has stale state; pulling them while both bots are running means split-brain.
Derived caches — don’t bother. Node modules, npm cache, browser sandbox caches. These get rebuilt on first use.
The migration’s middle phase was just systematically working through bucket one while the bot kept serving. The cutover is solely about bucket two.
The cutover sequence
This is the only part that has to be sequenced precisely:
- Stop the source services. This is the moment that ends split-brain risk. Once Telegram polling has stopped on the source, no further offset advances happen.
- Final delta-rsync of just the volatile-state directories. With
--deleteso the destination matches exactly. This is fast — most of those directories are KB-scale, not MB. - Rewrite paths in the freshly-synced JSON state files. (Doing this before the delta-sync would just have my edits overwritten.)
- Start the destination services. They pick up the offset from the source’s last poll and keep going.
- Disable, don’t delete, the source services. This is a rollback hedge. If something breaks on the new box in the next few days, I can re-enable on the source and lose only the messages received in the meantime.
The total downtime — wall-clock time between step 1 and the destination service being ready to receive — was under two minutes. Anyone messaging during that window saw a slight delay, not a lost message: Telegram queues up to 24 hours of updates per bot.
Verification before declaring done
It’s tempting to look at systemctl status showing active (running) and call the migration done. That’s not enough. The service can be running while the bot is broken. I checked, in order:
- The expected ports were actually listening (the service can be “active” but still failing during startup).
- The journal had no auth errors or
ENOENTon hardcoded paths (this is where missed path rewrites surface). - The SQLite database opened and showed the expected tables.
- The Telegram offset file existed and had a sensible-looking
lastUpdateId. - A live message round-trip from my phone produced a reply.
That last one is the only test that actually proves the bot works. The others rule out classes of failure.
The bot finished its own migration
The most satisfying part happened a minute after I sent that first Telegram message. OpenClaw woke up on the new box, noticed it was on a new box, and quietly kept migrating itself.
Without being asked, it:
- Spotted two rows in the SQLite database that still had old absolute paths embedded in JSON-typed columns — paths I hadn’t grepped for because they were inside serialised values, not text fields. It rewrote them and verified the affected scripts still ran.
- Noticed that the Google services CLI was missing on the new host. The old box had it installed years ago and I’d forgotten it wasn’t part of the apt or npm inventories I’d captured. Calendar and Drive integrations were silently failing the first time they were called. The bot installed the CLI, re-ran the auth flow against the existing credentials, and confirmed both integrations were back online.
- Cross-checked its own scheduled jobs against the new filesystem and flagged the disk-space-monitor cron — the one I’d already noticed pointed at a mount that doesn’t exist on the new host — as needing an updated threshold.
I didn’t prompt any of that. I sent a “are you alive” message; it came back with “yes, and by the way I fixed three things.” It noticed it had been moved, treated the new environment as an open task, and worked through it the way it would any other small project.
That’s the part of self-hosted AI tooling that’s genuinely new. A normal service migration ends when I declare it done. This one kept going on its own until everything actually worked, and then told me what it had done. The bot finishing its own migration is the migration story I didn’t realise I was going to get.
What I’d do differently
A few things I’d tighten next time:
- Capture the source inventory as a script, not as a series of ad-hoc commands. The next time I do this — and there will be a next time — I want to run one tool and get a structured report.
- Author the new systemd units before any data sync. Keeping them in version control means the next migration is just
git pull && systemctl enable, not “remember what was in the env block.” - Don’t carry over secrets verbatim. I preserved the API keys to keep the cutover atomic, but rotating them post-migration is the right move. They lived in plaintext on the old disk for a year; assume they’re worth rotating.
The role of the AI assistant
OpenClaw was the one driving the migration — I was reviewing its plan, approving the destructive steps, and watching the verification output. What it was good at:
- Surfacing the things I would have missed (the broken service, the path rewrites needed inside JSON state files, the cron job that referenced a mount point that doesn’t exist on the new box)
- Sequencing the cutover so split-brain wasn’t possible
- Being explicit about which files were safe to touch when
What it wasn’t good at, and where I had to course-correct:
- Initial plan suggested re-creating the broken service on the new box. I had to ask “wait, was this actually running on the source?” before that got caught.
- The first start of the destination service tried to use the old
--foregroundflag from the broken unit. Easy fix, but only because I was reading the journal, not assuming success.
The pattern I keep coming back to: the assistant is fast at doing, accurate at planning, and exactly average at noticing when something is subtly wrong. Verification is still my job. So is asking “are you sure?” when something looks off. As long as I keep doing those two things, the rest is leverage.