Server-side Swift with Vapor is a joy to write — the type system catches an entire class of bugs at compile time, and performance on Linux is genuinely impressive. But type safety doesn't prevent your app from crashing in production, running out of memory, or losing connectivity to its database. External monitoring is just as important for a Vapor app as for any other server.
I've shipped several Vapor services to production, and one thing I've noticed: the Swift/Vapor ecosystem has less tooling around observability than Node or Python. There are fewer off-the-shelf health check libraries, fewer monitoring tutorials, and fewer examples of production-ready health endpoints. This tutorial fills that gap.
I'll walk through adding a /health route to your Vapor application and configuring Vigilmon to monitor it — covering HTTP probes, dependency health checks, and alert configuration.
Why Your Vapor App Needs a /health Endpoint
Vapor's router can return 404 for undefined routes and the app will still be "running" from the OS perspective. A health endpoint does something the OS can't: it verifies that the app's event loop is processing requests, that its dependencies are reachable, and that its internal state is consistent.
A good /health endpoint:
- Responds in under 100ms under normal conditions
- Returns
200 OKwith a JSON body when healthy - Returns
503 Service Unavailablewhen a critical dependency is down - Doesn't expose sensitive internal state
- Is excluded from authentication middleware
Step 1: Add a /health Route to Your Vapor App
Basic Health Check
Start with the simplest version: a route that proves the event loop is alive.
// Sources/App/routes.swift
import Vapor
func routes(_ app: Application) throws {
// ... your other routes ...
app.get("health") { req async throws -> Response in
let body: [String: String] = ["status": "ok", "service": "my-vapor-app"]
return try Response(
status: .ok,
headers: HTTPHeaders([("Content-Type", "application/json")]),
body: .init(data: JSONEncoder().encode(body))
)
}
}
This is the minimum viable health endpoint. It proves:
- The Vapor process is running
- The HTTP server is accepting connections
- The event loop is processing requests
Health Check with Database Connectivity
If your app depends on a PostgreSQL database (via Fluent), extend the health endpoint to verify database connectivity:
// Sources/App/routes.swift
import Vapor
import Fluent
struct HealthResponse: Content {
var status: String
var database: String
var timestamp: String
}
func routes(_ app: Application) throws {
app.get("health") { req async throws -> Response in
var dbStatus = "ok"
// Test database connectivity with a lightweight query
do {
_ = try await req.db.query(SQLRawExecute("SELECT 1"))
} catch {
dbStatus = "down"
}
let overall = dbStatus == "ok" ? "ok" : "degraded"
let statusCode: HTTPResponseStatus = overall == "ok" ? .ok : .serviceUnavailable
let response = HealthResponse(
status: overall,
database: dbStatus,
timestamp: ISO8601DateFormatter().string(from: Date())
)
return try Response(
status: statusCode,
headers: HTTPHeaders([("Content-Type", "application/json")]),
body: .init(data: JSONEncoder().encode(response))
)
}
}
Health Check with Redis Cache
If you use Redis via the redis package:
import Vapor
import Redis
app.get("health") { req async throws -> Response in
var redisStatus = "ok"
do {
let pong = try await req.redis.ping().get()
if pong != "PONG" { redisStatus = "degraded" }
} catch {
redisStatus = "down"
}
let overall = redisStatus == "ok" ? "ok" : "degraded"
let statusCode: HTTPResponseStatus = overall == "ok" ? .ok : .serviceUnavailable
let body: [String: String] = [
"status": overall,
"redis": redisStatus,
]
return try Response(
status: statusCode,
headers: HTTPHeaders([("Content-Type", "application/json")]),
body: .init(data: JSONEncoder().encode(body))
)
}
Exclude Health from Authentication Middleware
If you use authentication middleware globally, make sure the /health route bypasses it. In Vapor, this means registering the health route before applying middleware:
// Sources/App/configure.swift
public func configure(_ app: Application) async throws {
// Register health endpoint BEFORE auth middleware
app.get("health") { req async throws -> Response in
// ... health check logic ...
}
// Auth middleware applies to everything else
let protected = app.grouped(UserAuthMiddleware())
try routes(protected)
}
Vigilmon's probes don't carry authentication headers, so an authenticated health route will always return 401 and appear down.
Step 2: Verify the Health Endpoint
Before wiring up Vigilmon, verify the endpoint manually:
# Basic health check
curl -i https://your-vapor-app.example.com/health
# Expected response:
# HTTP/2 200
# content-type: application/json
# {"status":"ok","database":"ok","timestamp":"2026-06-30T12:00:00Z"}
Test failure conditions too:
# Simulate DB down by temporarily setting an invalid DB URL in your dev environment
# Then verify /health returns 503
curl -i https://your-vapor-app.example.com/health
# HTTP/2 503
# {"status":"degraded","database":"down","timestamp":"..."}
Step 3: Configure Vigilmon HTTP Monitor for Your Vapor App
- Log in to vigilmon.online and go to Monitors → New Monitor
- Choose HTTP / HTTPS
- Set the URL:
https://your-vapor-app.example.com/health - Set the check interval: 1 minute
- Under Expected response:
- Status code:
200 - Response body contains:
"status":"ok" - Response time threshold:
2000ms
- Status code:
- Under Alert channels, assign your Slack channel or PagerDuty integration
- Save
Vigilmon probes your endpoint from multiple geographic regions simultaneously. It uses multi-region consensus before opening an incident — a single slow probe from one region won't page you, but a genuine Vapor failure that all regions see will.
What This Monitors
| Vapor failure | Detected? |
|---|---|
| Process crash (Vapor exits) | ✓ — connection refused |
| Event loop blocked | ✓ — response time threshold exceeded |
| Database connectivity lost | ✓ — returns 503 |
| Redis down | ✓ — returns 503 |
| Deployment failure (no process) | ✓ — connection refused |
| Memory pressure causing slow responses | ✓ — response time threshold |
Step 4: Monitor Individual API Endpoints
Beyond the /health route, I recommend monitoring at least one representative business endpoint to catch routing misconfigurations, middleware failures, and partial startup failures.
In Vigilmon, add a second monitor:
- URL:
https://your-vapor-app.example.com/api/status(or any lightweight GET endpoint) - Expected status:
200 - Response time threshold:
3000ms - Check interval: 5 minutes (less frequent than the health endpoint)
This catches cases where the health endpoint passes but business routing is broken — for example, a middleware registered after the health route that's crashing all other requests.
Step 5: Alert Configuration for Vapor App Downtime
Configure alert channels in Vigilmon to match your on-call structure:
Production Vapor app → Slack #backend-alerts + PagerDuty (immediate page)
Staging environment → Slack #backend-alerts only (no page)
Individual API endpoints → Slack #backend-alerts (5-minute delay)
For response time alerting, Vapor should respond to health checks in under 50ms under normal load. Set your threshold at 2 seconds for a confident signal that something is wrong — not just a slow GC pause.
Step 6: Heartbeat Monitoring for Background Jobs
Vapor apps often run background jobs (periodic tasks, queue workers, scheduled jobs using Queues). These jobs can stall silently — the Vapor process stays running, the health endpoint returns 200, but background processing has stopped.
Add Vigilmon heartbeat pings to your background jobs:
// Sources/App/Jobs/ReportGenerationJob.swift
import Vapor
import Queues
import AsyncHTTPClient
struct ReportGenerationJob: AsyncJob {
typealias Payload = ReportRequest
func dequeue(_ context: QueueContext, _ payload: ReportRequest) async throws {
// ... do the work ...
try await generateReport(payload)
// Ping Vigilmon heartbeat after successful job completion
let heartbeatURL = Environment.get("VIGILMON_HEARTBEAT_URL") ?? ""
if !heartbeatURL.isEmpty {
let httpClient = HTTPClient(eventLoopGroupProvider: .singleton)
_ = try? await httpClient.get(url: heartbeatURL).get()
}
}
func error(_ context: QueueContext, _ error: Error, _ payload: ReportRequest) async throws {
// Don't ping on error — let the heartbeat timeout fire
context.logger.error("Report generation failed: \(error)")
}
}
Set up the heartbeat monitor in Vigilmon:
- Monitors → New Monitor → Heartbeat
- Name:
vapor-report-generation-worker - Expected interval: your job's maximum expected runtime + a buffer
- Grace period: 2x the expected interval
If your job stops running — because of a queue connection issue, a crash in the job runner, or an unhandled error that stops the queue consumer — the heartbeat timeout fires and you get a Vigilmon alert before any user notices.
Summary
Vapor's type safety makes your code more reliable at compile time, but it can't prevent runtime failures, dependency outages, or deployment issues. Here's the complete external monitoring setup:
| Monitor Type | What It Catches |
|---|---|
| HTTP probe on /health | Process crash, database connectivity, Redis health |
| HTTP probe on a business endpoint | Routing failures, middleware crashes |
| Heartbeat monitor | Background job and queue worker stalls |
Multi-region consensus in Vigilmon means confident, low-noise alerts — you get paged when your Vapor app is genuinely down, not when a single probe has a transient timeout.
Start monitoring free at vigilmon.online — your first Vapor monitor is running in under two minutes.