tutorial

How to Monitor Your Rust/Axum App Uptime (Free, Multi-Region)

Rust and Axum give you speed and safety — but zero built-in observability. Add external uptime monitoring, heartbeat checks for tokio background tasks, and Slack/Discord alerts in under 30 minutes, for free.

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:

  1. Sign up at vigilmon.online — free tier, no credit card
  2. Click New Monitor → HTTP
  3. Enter https://yourdomain.com/health
  4. Set check interval (5 minutes on the free tier)
  5. 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:

  1. Click New Monitor → Heartbeat
  2. Set the expected interval (e.g. 25 hours for a daily job — giving a 1-hour grace window)
  3. Copy the unique ping URL it generates
  4. Set it as your SYNC_HEARTBEAT_URL environment 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:

  1. Create an incoming webhook in your Slack workspace (Slack Apps → Incoming Webhooks)
  2. In Vigilmon, go to Notifications → New Channel → Slack
  3. Paste the webhook URL
  4. Enable it on your monitors

For Discord:

  1. In your Discord server, go to channel settings → Integrations → Webhooks
  2. Create a new webhook and copy the URL
  3. In Vigilmon, go to Notifications → New Channel → Discord
  4. 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:

[![Uptime](https://vigilmon.online/badge/your-monitor-id.svg)](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:

  1. Go to Status Pages → New Status Page in Vigilmon
  2. Name it and choose which monitors to show
  3. 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's TraceLayer for 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 reqwest client 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.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →