Synthetic Monitoring on a Shoestring: What Ping Checks Miss
A ping check tells you the server answered. It doesn't tell you checkout works. Here's how I script curl chains that actually catch real failures.
Ping checks are a lie you tell yourself. Your monitoring turns green, your phone stays quiet, and somewhere a customer is staring at a blank checkout page because a Redis session store went sideways. I've been burned by this enough times that I don't trust "is the host up" checks for anything that actually matters.
Synthetic transaction monitoring — scripting a real user flow and asserting on the results — is the honest version of uptime monitoring. The commercial tools (Datadog Synthetics, Checkly, New Relic Scripted Browsers) are fine if you have the budget. But I've built perfectly serviceable versions with bash, curl, and a cron job, and they've caught failures that $200/month SaaS monitors would have missed entirely.
What Ping Checks Actually Tell You
A standard HTTP check hits your homepage and looks for a 200. That's useful. It'll catch a crashed web server, a misconfigured nginx block, or a Let's Encrypt cert that expired because someone disabled the auto-renew hook. Worth doing.
What it won't catch:
- A busted database connection that 500s only on authenticated routes
- A payment form that renders but whose POST handler is throwing exceptions
- A broken API dependency that causes silent data loss
- A PDF generation job that returns 200 but produces a 0-byte file
- Session middleware that stopped working after a deploy
I had a Laravel app for a print management client where the quote submission form was 500ing for about four hours before anyone noticed. The homepage was fine. The CDN-cached product pages were fine. The /quote/submit POST endpoint was on fire. The ping check was green the whole time.
That was the last time I relied on a ping check for a revenue-critical path.
The Core Idea: Chain curl Calls Like a User Would
A synthetic transaction is just: do what the user does, in order, and assert the results match what you expect. With curl you get:
- Cookie jar persistence across requests (so sessions work)
- Full control over headers, POST bodies, follow-redirects
- Response code checking
- Body content matching with grep
- Timing data via
--write-out
Here's the base pattern I use. This is a real script structure I've adapted for multiple clients. It monitors a Laravel app's login → authenticated action → logout flow:
#!/usr/bin/env bash
# synthetic-check.sh — fails with exit code 1 on any assertion error
# Run via cron every 5 minutes; pipe failures to a webhook or pagerduty
set -euo pipefail
BASE_URL="https://app.example.com"
COOKIE_JAR="/tmp/synth-check-$$-cookies.txt"
LOG_FILE="/tmp/synth-check-$$.log"
EXPECTED_USER_TEXT="Dashboard"
TEST_EMAIL="monitor@example.com"
TEST_PASSWORD="${SYNTH_PASSWORD}" # from environment — never hardcode
cleanup() {
rm -f "$COOKIE_JAR" "$LOG_FILE"
}
trap cleanup EXIT
assert_status() {
local label="$1"
local expected="$2"
local actual="$3"
if [ "$actual" != "$expected" ]; then
echo "FAIL [$label]: expected HTTP $expected, got $actual" >&2
exit 1
fi
}
assert_body() {
local label="$1"
local pattern="$2"
local body_file="$3"
if ! grep -q "$pattern" "$body_file"; then
echo "FAIL [$label]: pattern '$pattern' not found in response" >&2
exit 1
fi
}
# Step 1: Fetch the login page and grab the CSRF token
CSRF=$(curl -s \
--cookie-jar "$COOKIE_JAR" \
--cookie "$COOKIE_JAR" \
"${BASE_URL}/login" | \
grep -oP 'name="_token" value="\K[^"]+' | head -1)
if [ -z "$CSRF" ]; then
echo "FAIL [csrf]: could not extract CSRF token from login page" >&2
exit 1
fi
# Step 2: POST credentials
STATUS=$(curl -s -o "$LOG_FILE" -w "%{http_code}" \
--cookie-jar "$COOKIE_JAR" \
--cookie "$COOKIE_JAR" \
-X POST "${BASE_URL}/login" \
-d "_token=${CSRF}&email=${TEST_EMAIL}&password=${TEST_PASSWORD}" \
--location) # follow the redirect after login
# After a successful Laravel login + redirect, we should land on the dashboard (200)
assert_status "login-post" "200" "$STATUS"
assert_body "login-redirect" "$EXPECTED_USER_TEXT" "$LOG_FILE"
# Step 3: Hit an authenticated API endpoint that exercises the database
STATUS=$(curl -s -o "$LOG_FILE" -w "%{http_code}" \
--cookie-jar "$COOKIE_JAR" \
--cookie "$COOKIE_JAR" \
"${BASE_URL}/api/user/profile")
assert_status "authed-api" "200" "$STATUS"
assert_body "authed-api-body" '"email"' "$LOG_FILE"
# Step 4: Logout
LOGOUT_CSRF=$(curl -s \
--cookie-jar "$COOKIE_JAR" \
--cookie "$COOKIE_JAR" \
"${BASE_URL}/dashboard" | \
grep -oP 'name="_token" value="\K[^"]+' | head -1)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
--cookie-jar "$COOKIE_JAR" \
--cookie "$COOKIE_JAR" \
-X POST "${BASE_URL}/logout" \
-d "_token=${LOGOUT_CSRF}" \
--location)
assert_status "logout" "200" "$STATUS"
echo "OK: all synthetic checks passed"
exit 0
Few things worth noting in there. The $$ in temp file names gives each run its own cookie jar so concurrent runs don't stomp each other. The trap cleanup EXIT makes sure temp files get deleted even on failure. And I'm pulling the password from the environment — never commit a monitoring credential to your repo.
Getting the Failure Alerts Out
The script exits 1 on failure. Wire that up however you like. I usually do one of two things:
Option A — cron with a curl webhook on failure:
# crontab -e
*/5 * * * * /opt/scripts/synthetic-check.sh || \
curl -s -X POST https://hooks.slack.com/services/YOUR/WEBHOOK \
-H 'Content-type: application/json' \
-d '{"text":"Synthetic check FAILED on app.example.com"}'
Option B — Laravel artisan command + scheduler:
If I'm already inside a Laravel project, I'll write the check as an Artisan command and schedule it. That gives me proper logging, the ability to use the app's own notification channels, and first-class access to Http:: client with assertion helpers.
// app/Console/Commands/SyntheticHealthCheck.php
public function handle(): int
{
$base = config('app.url');
$jar = new \GuzzleHttp\Cookie\CookieJar();
// Step 1: get CSRF
$loginPage = Http::withOptions(['cookies' => $jar])->get($base . '/login');
preg_match('/name="_token" value="([^"]+)"/', $loginPage->body(), $m);
$csrf = $m[1] ?? null;
abort_unless($csrf, 500, 'Could not extract CSRF token');
// Step 2: login
$response = Http::withOptions(['cookies' => $jar, 'allow_redirects' => true])
->asForm()
->post($base . '/login', [
'_token' => $csrf,
'email' => config('monitoring.synth_email'),
'password' => config('monitoring.synth_password'),
]);
if (! str_contains($response->body(), 'Dashboard')) {
$this->fail('Login redirect did not reach dashboard');
return self::FAILURE;
}
// Step 3: hit authenticated endpoint
$api = Http::withOptions(['cookies' => $jar])->get($base . '/api/user/profile');
if ($api->failed() || ! isset($api->json()['email'])) {
$this->fail('Authenticated API check failed');
return self::FAILURE;
}
$this->info('Synthetic check passed.');
return self::SUCCESS;
}
Schedule it in routes/console.php (Laravel 11+) or Kernel.php and you're done.
The Gotchas That Will Bite You
CSRF token extraction is fragile. If your blade template changes the input format even slightly, the grep breaks silently and you'll get a "CSRF token mismatch" 419 that looks like an auth failure. I add a separate assertion that the CSRF token was non-empty before ever trying to POST.
Rate limiting your own monitor. I've had clients with aggressive Cloudflare rules that started throttling the monitoring IP. Solution: whitelist the monitoring server's IP in Cloudflare, or use a fixed user-agent string and add it to the bypass list.
Test accounts and production data. The monitor user needs to exist in production. That means you need a process to keep that password rotated and the account in good standing. I usually create a dedicated monitor@yourdomain.com account with a role that has read-only access to the minimum needed paths.
Timing is not the same as availability. curl's --write-out '%{time_total}' gives you response time, but you have to decide what to do with it. I usually just log it and alert separately if the check times out. If you want p95 tracking, you need something more than cron.
The check itself can fail non-deterministically. A deploy mid-check can cause a session to be dropped. I usually run the check twice on failure before alerting, to avoid false pages at 2am.
When I'd Reach for This
This approach makes sense for any app where the happy path involves more than one HTTP request. E-commerce checkout, patient portal login, anything with a form that POSTs and redirects — ping checks are not enough.
I'd also use this when the budget doesn't support a Datadog Synthetics subscription. For a small client running a regional e-commerce store, $50-200/month for synthetic monitoring tooling is real money. A bash script on their existing VPS costs nothing.
Where I wouldn't rely on this alone: when you need browser-level JavaScript execution. If your checkout is a React SPA and the critical state lives in JS memory, curl can't see it. That's where Playwright-based synthetic monitoring earns its keep. I've started using Checkly for those cases — it's Playwright tests as a service, reasonably priced, and the DX is solid.
Also: this doesn't replace real user monitoring (RUM). RUM tells you what actual users experience. Synthetics tells you what a scripted user experiences on a schedule. You want both if uptime genuinely costs you money.
Closing
I've had this type of script page me at 3am for a broken payment endpoint that the ping check never would have caught, and I've thanked past-me for writing it every single time. The barrier to entry is one bash script and a cron job — there's no excuse for running mission-critical apps with nothing but a ping check. Ship something that actually walks the path your users walk.
Need help shipping something like this? Get in touch.