There Are No Health Checks — Build the Watchdog Yourself
There are no health checks here. Nothing sends an HTTP request to your app to decide whether it's alive, and nothing probes a port. The status you see comes from one thing: whether the container is running. That's a real advantage for bots and workers — you never have to bolt a fake web server onto a process that doesn't serve HTTP just to satisfy a probe. But it has a flip side worth planning for: the restart machinery reacts to a process that has exited, not to one that's stopped working. A deadlocked worker, a bot whose gateway connection died silently, a service returning 500s to every request — all of them look perfectly healthy. If you want those caught, you build the watchdog.
What actually gets restarted
Two mechanisms bring your app back, and both key on the same event:
- Your process exits and, on a zip deploy, a supervisor loop starts it again about five seconds later.
- The container exits and Docker's
unless-stoppedrestart policy recreates it — this is what happens when you brought your own image.
Neither one asks your app whether it's okay. There is no probe, no /health path, no timeout that trips. Exit and you get restarted; hang and you're on your own.
That trade is deliberate and mostly in your favour. Platforms that health-check by HTTP are the reason so many Discord bot tutorials tell you to run an Express server alongside the bot and ping it from an uptime service. You don't need that here — a bot is just a worker with no ports. But "nothing is watching" cuts both ways.
The failures that slip through
Worth naming, because they share a signature: green status, no logs, nothing happening.
- A dead gateway connection. Your bot's WebSocket drops and the library's reconnect logic gives up quietly. The process is alive and idle forever.
- A blocked event loop or deadlock. One synchronous call that never returns, and every subsequent event queues behind it.
- An exhausted connection pool. Every request waits on a connection that's never released. The app accepts work and completes none of it.
- An infinite retry loop. A poller hitting a rate limit and backing off forever, doing nothing useful but staying busy.
- A full volume. Writes fail, the app catches the exception, and it keeps running in a state where nothing persists. Disk overruns stop every app on the account, so this one escalates.
None of these exits. None of these turns anything red.
Pattern 1: crash on purpose
The single highest-value change, and it runs against most people's instinct: when your app reaches a state it can't recover from, exit non-zero.
Catching every exception to "stay up" is how you end up with a process that's running and useless. Exiting turns an invisible hang into a visible crash, which the platform already knows how to handle — it restarts you, the exit code lands in the event history, and a repeating failure becomes a diagnosable crash loop instead of silence.
Be selective. Exit on unrecoverable states — auth rejected, config missing, the client library reporting a permanently closed connection. Don't exit on a transient HTTP 503.
Pattern 2: an internal watchdog
For hangs your code can't detect directly, use a heartbeat: the working part of your app records a timestamp, and a separate timer checks that it's recent.
import os, threading, time
last_beat = time.monotonic()
def beat():
global last_beat
last_beat = time.monotonic()
def watchdog(max_silence=300):
while True:
time.sleep(30)
if time.monotonic() - last_beat > max_silence:
print("watchdog: no heartbeat, exiting", flush=True)
os._exit(1)
threading.Thread(target=watchdog, daemon=True).start()
Call beat() wherever real work happens — each event handled, each poll completed. If that stops, the watchdog exits and the restart machinery takes over.
Two details matter. Use os._exit rather than raising, since an exception on a background thread won't necessarily stop a hung main thread. And set max_silence generously above your quietest normal period — a bot in a slow guild may legitimately handle nothing for an hour, so heartbeat on the connection's keepalive rather than on user activity.
Pattern 3: watch from outside
An internal watchdog can't detect a process that's hung so completely it isn't running timers. For that you need something external.
For a web service, point an uptime monitor at a real URL — ideally one that touches your dependencies rather than returning a hardcoded OK. Uptime Kuma runs comfortably as a small app.
For a worker with no port, invert it: have the app call out on a schedule, and alert when the calls stop. Uptime Kuma's push monitor works this way — you give it a URL to hit, and it goes red if nothing arrives within the interval. That's a dead-man's switch, and it's the only external check that works for a process with nothing to connect to.
Either way, route the alert somewhere you'll actually see it — a self-hosted ntfy instance puts it on your phone.
Pattern 4: make the logs prove liveness
Cheapest of all: log one line per cycle at a low volume — a poller reporting how many items it processed, including zero. Then "is it alive" is answerable from the logs without adding infrastructure.
Keep it genuinely low-volume. Container logs are capped and rotate, so a chatty heartbeat scrolls away the errors you'll want later. One line every few minutes is plenty; one line per second is how you lose a stack trace.
FAQ
Does the platform health-check my app?
No. There's no HTTP probe and no port check. The status reflects whether the container is running.
What happens if my app hangs instead of crashing?
Nothing. It keeps reporting as running. Restarts are triggered by a process exiting, so a hung process stays hung until you notice.
Do I need a fake HTTP endpoint for my bot?
No. Workers with no ports are a first-class shape here — that workaround exists for platforms that require an HTTP health check.
How do I detect a hung app?
Add an internal watchdog that exits when a heartbeat goes stale, and an external check. For port-less workers, use a push-style monitor that alerts when the app stops checking in.
Should my app exit on errors?
On unrecoverable ones, yes. Exiting converts a silent hang into a restart plus an exit code you can read. Keep handling transient errors normally.
Where do I see the exit code?
In the app's event history, and in the logs on a zip deploy, where the supervisor prints the code each time your process dies.
Wrapping up
"No health checks" is one of the reasons a bot or worker is easy to run here — nothing expects your process to answer HTTP, so nothing kills it for not doing so. The cost is that liveness is your problem. Exit deliberately when you can't recover, add a heartbeat watchdog for the hangs you can't detect any other way, and put one external check outside the box so a completely wedged process still reaches you. That's an afternoon of work, and it's the difference between finding out from your monitoring and finding out from a user.