Moving a Telegram bot between hosts without losing a message
A small Telegram bot I wrote — a household assistant that lives entirely on button taps — used to run as a native binary on an old laptop under launchd. When I set up a proper server, I wanted to move it there. Moving it is not hard; moving it without two copies running at once takes a little care.
The one rule
Telegram bots that use long polling call getUpdates in a loop. The API hands each update to exactly one caller, and if two processes poll the same token they fight: each receives 409 Conflict errors, updates land on whichever poller asked first, and users see buttons that sometimes work. Nothing is corrupted, but it is a bad experience.
So the order matters: stop the old poller, then start the new one. If the old host is unreachable when you want to migrate — asleep, powered off, in a drawer — you need a plan for when it wakes up.
The state
The bot keeps everything in one SQLite database: who is paired with whom, the history of requests, preferences. Photos it forwards are stored as Telegram file_ids, not files, so there was nothing else to move. The database is snapshotted nightly with VACUUM INTO, and the snapshot is the thing to carry across.
On the new host the bot runs in a container with its database on a named volume. Seeding it is a two-step:
docker compose up --no-startso that Compose creates the volume and owns it (pre-creating the volume by hand makes Compose refuse to use it).- Copy the snapshot into the volume and
chownit to the bot’s non-root uid, because a volume populated by hand does not inherit ownership from the image the way an empty one does.
The safety catch
The container is defined behind a Compose profile. With the profile off, the bot is built and ready but not running; flipping it on is one line in the server-local .env. That means the whole deployment can be prepared, verified and even built in advance, and the actual switch is deliberate rather than a side effect of a deploy.
I also moved the bot’s health listener from the container’s loopback to all interfaces — it still publishes no ports, but the status page can now probe it from inside the compose network, and the first thing it reports after a switch is whether the bot is polling.
What happened
The old laptop was closed when I did the move, which is the easy case: nothing was polling, so I seeded the volume and turned the profile on. The bot came up, logged long polling, and wrote its first snapshot within ten seconds. Zero 409s in the logs since — which is the signal that tells me the old copy has not woken up.
One thing did look alarming for about a minute. On the very first start the bot crash-looped with getMe … context deadline exceeded: the Telegram client gives itself five seconds to say hello, and the Whisper container starting next to it was loading its model at the same time. I spent longer than I should have proving that Telegram was reachable from the container (it was, instantly, from every variant I tried) before noticing that the failures stopped the moment Whisper finished loading. The restart policy healed it without help; the lesson was to read the neighbours’ logs before suspecting the network.
The remaining step is permanent: on the old machine, launchctl unload -w so the agent does not start again at login. Until that runs, the status page will tell me if it does.
Things I would do again
- Treat the token as a lock. One poller, switched deliberately.
- Seed from the snapshot, not the live file. SQLite in WAL mode has companion files; the snapshot is a single consistent file.
- Gate the new instance behind a profile so “deployed” and “running” are separate decisions.
- Watch for
409as the health signal for “someone else is holding the lock”.