What happens when a host stops your bot
Docker, systemd, Kubernetes and most platforms follow the same sequence:
- Send SIGTERM to the main process: "please stop".
- Wait for a grace period.
- Send SIGKILL if the process is still alive. SIGKILL cannot be caught; the process dies mid-work.
Default grace periods from the official documentation:
| Runtime | Default wait before SIGKILL | Setting |
|---|---|---|
docker stop |
10 seconds | --time, or stop_grace_period in Compose |
| systemd | 90 seconds (DefaultTimeoutStopSec) |
TimeoutStopSec= in the unit |
| Kubernetes | 30 seconds | terminationGracePeriodSeconds |
Managed platforms have their own values; check your host's docs and give your shutdown code a few seconds less than whatever it allows.
What goes wrong without a handler
- Duplicate replies. A new instance starts while the old one is still connected. With Telegram long polling the two compete for updates (the second gets 409 Conflict); with Discord both sessions receive events and both answer.
- Lost work. A message was received, the reply was half-sent, and SIGKILL ended it.
- Re-delivered updates. A polling bot that has not confirmed its last offset gets the same updates again after restart.
- Corrupted files. A SQLite write or a JSON state file interrupted halfway.
The shutdown recipe
On SIGTERM:
- Stop accepting new work (stop polling, close the gateway connection, stop the HTTP server from accepting connections).
- Let in-flight handlers finish, with a timeout.
- Flush logs, close the database, commit queue acknowledgements.
- Exit with code 0.
Node.js
grammY (long polling):
import { Bot } from "grammy";
const bot = new Bot(process.env.BOT_TOKEN);
// ... handlers
const stop = () => bot.stop(); // finishes the current batch, then resolves start()
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
await bot.start();
await db.close(); // runs after polling has stopped
discord.js:
async function shutdown() {
console.log("Shutting down");
await client.destroy(); // closes the gateway connection
await db?.close();
process.exit(0);
}
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
Webhook servers (Express, Fastify, Hono on Node): call server.close() to stop accepting connections and let in-flight requests complete before exiting.
Python
python-telegram-bot handles this for you: Application.run_polling() and run_webhook() listen for SIGINT, SIGTERM and SIGABRT by default (the stop_signals parameter), stop fetching updates and run your post_shutdown hook:
async def post_shutdown(app):
await app.bot_data["db"].close()
app = Application.builder().token(TOKEN).post_shutdown(post_shutdown).build()
app.run_polling()
discord.py: register a SIGTERM handler that closes the client.
import asyncio, signal
async def main():
async with client:
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.create_task(client.close()))
await client.start(TOKEN)
asyncio.run(main())
loop.add_signal_handler works on Unix, which is what Docker images and Linux servers run.
The PID 1 trap in Docker
Signals go to the container's PID 1. If your Dockerfile uses the shell form:
CMD python -m bot.main # /bin/sh is PID 1
then /bin/sh receives SIGTERM and may not forward it, so your handler never runs and Docker kills the bot after 10 seconds. Use the exec form:
CMD ["python", "-m", "bot.main"]
Starting through npm start adds another wrapper process; prefer CMD ["node", "index.js"]. If your process spawns children, run the container with --init (or init: true in Compose) so a minimal init forwards signals and reaps zombies. The full Dockerfile is in deploying a Python bot with Docker.
systemd
[Service]
ExecStart=/opt/bot/.venv/bin/python -m bot.main
Restart=always
KillSignal=SIGTERM
TimeoutStopSec=20
Restart=always brings the bot back after crashes; TimeoutStopSec bounds how long a stop can take. Full setup in the Vultr VPS with systemd tutorial.
Zero-overlap deploys
For polling and gateway bots, one running instance is the rule. If your platform starts the new version before stopping the old one, either disable overlap for that service or make the new instance wait until the old one has disconnected. Webhook bots are easier: several instances can serve requests at once, so rolling deploys are safe as long as handlers tolerate duplicates. More in keeping a bot online 24/7 and the reliability and rate limits guide.
Frequently asked questions
How do I test my shutdown handler?
Run the bot in Docker and execute docker stop <container>. Check the logs for your shutdown message and that the stop takes less than the grace period. Locally, kill -TERM <pid> does the same.
Should the bot send a "going offline" message?
Usually not. Restarts are frequent and short; announcements are noise. Log it instead.
What exit code should I use?
Exit 0 after a requested shutdown. A non-zero exit tells the supervisor something failed, which may trigger alerts or restart backoff.
Sources
- Docker docs, docker container stop: docs.docker.com/reference/cli/docker/container/stop
- Docker docs, Dockerfile CMD and exec form: docs.docker.com/reference/dockerfile
- systemd, systemd-system.conf (DefaultTimeoutStopSec): freedesktop.org/software/systemd/man/latest/systemd-system.conf.html
- Kubernetes docs, pod termination: kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle
- python-telegram-bot, Application.run_polling: docs.python-telegram-bot.org
- grammY, long polling and stopping the bot: grammy.dev/guide/deployment-types