How to Monitor Your Rust/Axum App Uptime (Free, Multi-Region)
Your Axum service is fast, safe, and statically verified — but none of that prevents it from silently returning 500s at 3 AM, or a background task quietly stopping without a trace. Rust's ownership model and borrow checker guarantee memory safety, not uptime.
By the end of this guide you'll have external uptime monitoring, multi-region health checks, heartbeat monitoring for your tokio background jobs, Slack/Discord alerts, and a public status page — all running on the free tier in under 30 minutes.
Why Rust/Axum apps still go dark
Axum apps have two failure modes that go undetected without external monitoring:
Endpoint failures — a panic in a handler, an exhausted connection pool, or a bad deploy causes your routes to return 500s or time out. Your process is still running, but your users are hitting errors. Without an external probe, you find out from a user report.
Silent tokio task failures — a background task spawned with tokio::spawn panics and gets dropped. The future is silently cancelled. No crash. No alert. Your cron-style job just stopped running, and your application has no idea.
Both are solvable with an external monitoring tool actively checking from outside your infrastructure.
Step 1: Add a /health endpoint with Axum
Start by adding the required dependencies to Cargo.toml:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls"] }
chrono = { version = "0.4", features = ["serde"] }
Now add a /health handler that checks your critical dependencies:
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::Serialize;
use sqlx::PgPool;
use std::collections::HashMap;
#[derive(Serialize)]
struct HealthResponse {
status: String,
timestamp: String,
checks: HashMap<String, String>,
}
async fn health_handler(State(pool): State<PgPool>) -> impl IntoResponse {
let mut checks = HashMap::new();
let mut status = "ok".to_string();
match sqlx::query("SELECT 1").execute(&pool).await {
Ok(_) => {
checks.insert("database".to_string(), "ok".to_string());
}
Err(e) => {
checks.insert("database".to_string(), format!("error: {e}"));
status = "degraded".to_string();
}
}
let response = HealthResponse {
status: status.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
checks,
};
let status_code = if status == "ok" {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
(status_code, Json(response))
}
#[tokio::main]
async fn main() {
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPool::connect(&database_url)
.await
.expect("Failed to connect to database");
let app = Router::new()
.route("/health", get(health_handler))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
println!("Listening on :8080");
axum::serve(listener, app).await.unwrap();
}
The critical detail: return 503 Service Unavailable when a dependency check fails. This gives your monitoring tool a meaningful HTTP signal — not just "is the port open" but "is the application actually healthy."
If you don't have a database yet, a minimal health handler with no dependencies works the same way:
async fn health_handler() -> impl IntoResponse {
let body = serde_json::json!({
"status": "ok",
"timestamp": chrono::Utc::now().to_rfc3339(),
});
(StatusCode::OK, Json(body))
}
Actix-web variant
If you're using Actix-web instead of Axum, the pattern is nearly identical:
use actix_web::{get, web, App, HttpResponse, HttpServer, Responder};
use sqlx::PgPool;
#[get("/health")]
async fn health(pool: web::Data<PgPool>) -> impl Responder {
match sqlx::query("SELECT 1").execute(pool.get_ref()).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({
"status": "ok",
"timestamp": chrono::Utc::now().to_rfc3339(),
})),
Err(e) => HttpResponse::ServiceUnavailable().json(serde_json::json!({
"status": "degraded",
"error": e.to_string(),
})),
}
}
Step 2: Set up external uptime monitoring with Vigilmon
With /health live, point Vigilmon at it:
- Sign up at vigilmon.online — free tier, no credit card
- Click New Monitor → HTTP
- Enter
https://yourdomain.com/health - Set check interval (5 minutes on the free tier)
- Save
Vigilmon probes from multiple regions. If it receives a non-2xx response or hits a timeout, it opens an incident and alerts you immediately — before your users notice.
Run multiple monitors per service to get full coverage:
| Endpoint | What it catches |
|---|---|
| /health | Database connectivity, dependency failures |
| /api/v1/ping | API layer breakage after a bad deploy |
| / | Frontend or reverse proxy misconfiguration |
Step 3: Heartbeat monitoring for tokio background tasks
HTTP uptime checks catch server outages. They don't catch silent tokio task failures.
A tokio::spawned future that panics is simply dropped — the task handle's JoinHandle registers it as an error, but unless you're explicitly awaiting and inspecting that handle, you'll never know.
The heartbeat pattern: your background task pings a unique URL at the end of every successful run. If Vigilmon stops receiving the ping within the expected window, it fires an alert.
First, add reqwest to Cargo.toml:
reqwest = { version = "0.12", features = ["json"] }
Then use this pattern for a scheduled background task:
use std::time::Duration;
use tokio::time;
async fn ping_heartbeat(url: &str) {
if url.is_empty() {
return;
}
if let Err(e) = reqwest::get(url).await {
eprintln!("Heartbeat ping failed: {e}");
}
}
async fn run_daily_sync() -> anyhow::Result<()> {
// your actual job logic here
println!("Running nightly data sync...");
tokio::time::sleep(Duration::from_millis(100)).await; // simulate work
Ok(())
}
async fn start_scheduler(heartbeat_url: String) {
let mut interval = time::interval(Duration::from_secs(24 * 60 * 60));
loop {
interval.tick().await;
match run_daily_sync().await {
Ok(_) => {
ping_heartbeat(&heartbeat_url).await;
}
Err(e) => {
eprintln!("Daily sync failed, skipping heartbeat: {e}");
// don't ping — missing heartbeat triggers the Vigilmon alert
}
}
}
}
Wire it into your main:
#[tokio::main]
async fn main() {
let heartbeat_url = std::env::var("SYNC_HEARTBEAT_URL").unwrap_or_default();
// spawn the scheduler as a background task
tokio::spawn(start_scheduler(heartbeat_url));
// start the Axum server
let app = Router::new().route("/health", get(health_handler));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
In Vigilmon:
- Click New Monitor → Heartbeat
- Set the expected interval (e.g. 25 hours for a daily job — giving a 1-hour grace window)
- Copy the unique ping URL it generates
- Set it as your
SYNC_HEARTBEAT_URLenvironment variable
Now if your task panics, returns an error, or your scheduler loop exits unexpectedly, Vigilmon alerts you within one missed interval.
Multiple jobs, multiple heartbeats
Give each critical background job its own heartbeat URL — the pattern composes cleanly with Rust's environment variable handling:
struct HeartbeatUrls {
sync: String,
cleanup: String,
reports: String,
}
impl HeartbeatUrls {
fn from_env() -> Self {
Self {
sync: std::env::var("SYNC_HEARTBEAT_URL").unwrap_or_default(),
cleanup: std::env::var("CLEANUP_HEARTBEAT_URL").unwrap_or_default(),
reports: std::env::var("REPORTS_HEARTBEAT_URL").unwrap_or_default(),
}
}
}
Treat background tasks like uptime monitors — each one gets its own check.
Step 4: Webhook alerts to Slack or Discord
Set up alert delivery so you're notified the moment something breaks.
For Slack:
- Create an incoming webhook in your Slack workspace (Slack Apps → Incoming Webhooks)
- In Vigilmon, go to Notifications → New Channel → Slack
- Paste the webhook URL
- Enable it on your monitors
For Discord:
- In your Discord server, go to channel settings → Integrations → Webhooks
- Create a new webhook and copy the URL
- In Vigilmon, go to Notifications → New Channel → Discord
- Paste the Discord webhook URL
You'll receive alerts like:
🔴 ALERT: yourdomain.com/health is DOWN
Checked from: US-East, EU-West
Status: 503 Service Unavailable
Started: 4 minutes ago
And a recovery notification when it comes back:
✅ RESOLVED: yourdomain.com/health is back UP
Downtime: 9 minutes
Step 5: Add an uptime badge to your README
Vigilmon generates a live SVG badge for each monitor. Open any monitor in Vigilmon, copy the badge embed code, and drop it in your README.md:
[](https://vigilmon.online?utm_source=devto&utm_medium=article&utm_campaign=rust-tutorial)
The badge stays green as long as your monitor is up and flips red during an incident. It's a lightweight public signal that your service is healthy — useful for open-source crates or internal platform libraries.
For a full incident history and public-facing status page:
- Go to Status Pages → New Status Page in Vigilmon
- Name it and choose which monitors to show
- Share the public URL with your users
What you've built
| What | How |
|---|---|
| External uptime monitoring | /health route + Vigilmon HTTP monitor |
| Dependency health checks | sqlx::query("SELECT 1") inside the handler |
| tokio background task monitoring | Heartbeat ping at end of each successful run |
| Instant alerts | Slack/Discord webhook notifications |
| README status badge | Vigilmon badge embed |
| Public status page | Vigilmon status page |
The full setup costs $0 on the free tier and takes under 30 minutes. You'll catch the next silent failure — whether it's a panicking task or a 503 from a depleted connection pool — before your users notice.
Next steps
- Add
tower-http'sTraceLayerfor structured request logging alongside your health checks - Use
tokio::select!in your scheduler loop to handle graceful shutdown signals so tasks drain cleanly on deploy - Set a tight
reqwestclient timeout for heartbeat pings so a slow network never blocks your background task
Monitor your Rust app free at vigilmon.online — no credit card, monitors running in under a minute.