Testing Apps on Fly.io: Health Checks, Multi-Region, and CI Integration
Fly.io has become a go-to platform for teams that want Heroku-style simplicity with genuine multi-region distribution. You push code, and Fly.io runs it close to your users in 30+ regions worldwide. But with that power comes a real testing challenge: how do you verify that your app is healthy not just in one region but across all the regions you've deployed to? How do you validate rolling deployments without taking down production? And how do you wire all of this into a CI pipeline that gives you confidence before traffic hits real users?
This guide covers practical testing strategies for Fly.io deployments — from configuring health checks in fly.toml to writing smoke tests that run automatically after every deploy.
Understanding Fly.io's Deployment Model
Before writing tests, it helps to understand what you're testing. Fly.io runs your app as Machines — lightweight Firecracker microVMs that boot in milliseconds. When you deploy, Fly.io performs a rolling update: new Machines start, health checks run, and only if checks pass does Fly.io route traffic to them.
This means your health check configuration is your first line of defense. Get it wrong, and either bad deployments slip through or good ones get rolled back.
Configuring Health Checks in fly.toml
Your fly.toml is the foundation of your deployment configuration. Here's a production-ready health check setup:
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 1
[[http_service.checks]]
grace_period = "10s"
interval = "15s"
method = "GET"
path = "/health"
protocol = "http"
timeout = "5s"
tls_skip_verify = false
[http_service.checks.headers]
X-Internal-Check = "fly-health"
[checks]
[checks.db_connection]
grace_period = "20s"
interval = "30s"
method = "GET"
path = "/health/db"
protocol = "http"
timeout = "10s"
port = 8080The /health endpoint should be lightweight — just enough to confirm the process is alive. The /health/db check is more thorough. Here's what these endpoints should look like in a Node.js app:
// health.js
app.get('/health', (req, res) => {
res.json({
status: 'ok',
region: process.env.FLY_REGION,
app: process.env.FLY_APP_NAME,
machine: process.env.FLY_MACHINE_ID,
timestamp: new Date().toISOString()
});
});
app.get('/health/db', async (req, res) => {
try {
await db.query('SELECT 1');
res.json({ status: 'ok', db: 'connected' });
} catch (err) {
res.status(503).json({ status: 'error', db: err.message });
}
});Including FLY_REGION in your health response is useful because it lets you verify which region a request is being served from — critical for multi-region debugging.
Testing Multi-Region Deployments
Fly.io lets you run Machines in multiple regions simultaneously. Testing multi-region behavior means verifying that:
- Each region is actually serving traffic
- Data replication is working (if you use LiteFS or Fly Postgres with read replicas)
- Region-specific configuration is correct
Here's a bash script that validates all your configured regions:
#!/bin/bash
# test-regions.sh — validate all Fly.io regions are healthy
APP_NAME="your-app-name"
REGIONS=("iad" "lhr" "sin" "syd")
FAILED=0
for region in "${REGIONS[@]}"; do
echo "Testing region: $region"
# Use the Fly.io anycast address with region header
RESPONSE=$(curl -s -o /tmp/response.json -w "%{http_code}" \
-H "fly-prefer-region: $region" \
"https://${APP_NAME}.fly.dev/health")
if [ "$RESPONSE" != "200" ]; then
echo " FAIL: HTTP $RESPONSE from $region"
FAILED=$((FAILED + 1))
continue
fi
SERVING_REGION=$(jq -r '.region' /tmp/response.json)
echo " OK: Serving from $SERVING_REGION (requested $region)"
if [ "$SERVING_REGION" != "$region" ]; then
echo " WARN: Region mismatch — possibly no machine in $region, routed to $SERVING_REGION"
fi
done
if [ $FAILED -gt 0 ]; then
echo "Region tests failed: $FAILED/${#REGIONS[@]}"
exit 1
fi
echo "All regions healthy"Using the Machines API for Deployment Validation
The Fly.io Machines API gives you programmatic control over your deployment. You can use it to verify Machine states after a deploy:
#!/bin/bash
# check-machines.sh — verify all machines are in expected state after deploy
APP_NAME="your-app-name"
FLY_API_TOKEN="${FLY_API_TOKEN}"
EXPECTED_STATE="started"
MACHINES=$(curl -s \
-H "Authorization: Bearer ${FLY_API_TOKEN}" \
"https://api.machines.dev/v1/apps/${APP_NAME}/machines")
TOTAL=$(echo "$MACHINES" | jq length)
STARTED=$(echo "$MACHINES" | jq "[.[] | select(.state == \"${EXPECTED_STATE}\")] | length")
echo "Machines total: $TOTAL, started: $STARTED"
if [ "$STARTED" -ne "$TOTAL" ]; then
echo "ERROR: Not all machines are in state '${EXPECTED_STATE}'"
echo "$MACHINES" | jq '.[] | {id, region, state}'
exit 1
fi
# Check version consistency — all machines should run the same image
VERSIONS=$(echo "$MACHINES" | jq -r '.[].image_ref.tag' | sort -u)
VERSION_COUNT=$(echo "$VERSIONS" | wc -l | tr -d ' ')
if [ "$VERSION_COUNT" -gt 1 ]; then
echo "WARN: Multiple versions running:"
echo "$VERSIONS"
fi
echo "Deployment verified: $TOTAL machines running version $(echo "$VERSIONS" | head -1)"flyctl in CI Pipelines
The most reliable pattern for Fly.io CI integration is to deploy, wait for health checks, then run smoke tests. Here's a complete GitHub Actions workflow:
# .github/workflows/deploy.yml
name: Deploy and Test
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy to Fly.io
run: flyctl deploy --remote-only --wait-timeout 300
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
- name: Wait for deployment to stabilize
run: |
echo "Waiting for health checks to pass..."
sleep 15
# Poll until app responds consistently
for i in {1..10}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://your-app.fly.dev/health)
if [ "$STATUS" = "200" ]; then
echo "Health check passed on attempt $i"
break
fi
echo "Attempt $i: got HTTP $STATUS, retrying..."
sleep 5
done
- name: Run smoke tests
run: ./scripts/smoke-tests.sh
env:
APP_URL: https://your-app.fly.dev
- name: Verify machine states
run: ./scripts/check-machines.sh
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}Writing Effective Smoke Tests
Smoke tests after a Fly.io deploy should cover the critical paths your users depend on. Here's a structured approach using bash and curl:
#!/bin/bash
# smoke-tests.sh
APP_URL="${APP_URL:-https://your-app.fly.dev}"
FAILED=0
run_test() {
local name="$1"
local result="$2"
local expected="$3"
if [ "$result" = "$expected" ]; then
echo " PASS: $name"
else
echo " FAIL: $name (expected: $expected, got: $result)"
FAILED=$((FAILED + 1))
fi
}
echo "=== Smoke Tests: $APP_URL ==="
# Test 1: Basic health check
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/health")
run_test "Health endpoint returns 200" "$STATUS" "200"
# Test 2: Database connectivity
DB_STATUS=$(curl -s "$APP_URL/health/db" | jq -r '.status')
run_test "Database connection is healthy" "$DB_STATUS" "ok"
# Test 3: API authentication endpoint exists
AUTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"wrong"}' \
"$APP_URL/api/auth/login")
run_test "Auth endpoint reachable (returns 401 for bad creds)" "$AUTH_STATUS" "401"
# Test 4: Static assets load
ASSET_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/favicon.ico")
run_test "Static assets accessible" "$ASSET_STATUS" "200"
# Test 5: Response time is acceptable
RESPONSE_TIME=$(curl -s -o /dev/null -w "%{time_total}" "$APP_URL/health")
RESPONSE_MS=$(echo "$RESPONSE_TIME * 1000" | bc | cut -d. -f1)
if [ "$RESPONSE_MS" -lt 2000 ]; then
echo " PASS: Response time acceptable (${RESPONSE_MS}ms)"
else
echo " FAIL: Response time too slow (${RESPONSE_MS}ms)"
FAILED=$((FAILED + 1))
fi
echo ""
if [ $FAILED -gt 0 ]; then
echo "Smoke tests FAILED: $FAILED tests failed"
exit 1
fi
echo "All smoke tests passed"Testing Rolling Deployments
Fly.io performs rolling deployments by default — old Machines keep running while new ones start. You can verify this behavior with a script that monitors availability during a deploy:
#!/bin/bash
# monitor-during-deploy.sh
# Run this in one terminal while deploying in another
APP_URL="https://your-app.fly.dev"
INTERVAL=2
DOWNTIME=0
REQUESTS=0
echo "Monitoring $APP_URL during deployment..."
echo "Press Ctrl+C to stop"
while true; do
START=$(date +%s%N)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$APP_URL/health")
END=$(date +%s%N)
LATENCY=$(( (END - START) / 1000000 ))
REQUESTS=$((REQUESTS + 1))
TIMESTAMP=$(date +%H:%M:%S)
if [ "$STATUS" != "200" ]; then
DOWNTIME=$((DOWNTIME + 1))
echo "$TIMESTAMP FAIL HTTP $STATUS ${LATENCY}ms [downtime: $DOWNTIME/$REQUESTS]"
else
echo "$TIMESTAMP OK HTTP $STATUS ${LATENCY}ms"
fi
sleep $INTERVAL
doneContinuous Monitoring with HelpMeTest
Running smoke tests after each deploy catches regressions, but it doesn't tell you about problems that develop over time — memory leaks, database connection exhaustion, or third-party API degradation. For ongoing confidence, tools like HelpMeTest complement your deploy pipeline by running full browser-based tests on a schedule.
HelpMeTest's Robot Framework and Playwright-based tests can simulate real user journeys across your Fly.io regions, with AI-powered test generation that creates tests from plain English descriptions. With usage-based pricing ($0.003/run, no base fee) covering unlimited tests and parallel execution, you can run comprehensive multi-region checks every few minutes without worrying about test infrastructure costs.
A typical setup pairs the CI smoke tests above with HelpMeTest monitoring runs every 5 minutes, giving you both immediate post-deploy confidence and ongoing production health visibility.
Validating fly.toml Configuration Changes
When you change fly.toml — especially health check settings or machine sizing — validate the configuration before deploying:
# Validate fly.toml syntax
flyctl config validate
# Show planned changes without deploying
flyctl deploy --dry-run
# Check current health check status
flyctl checks list
# Watch health check results in real time
flyctl checks list --watchAfter any configuration change, verify that your health checks are firing correctly:
# Tail logs to see health check requests
flyctl logs --app your-app-name | grep "health"
# Check machine status
flyctl status --app your-app-name
# SSH into a machine to debug health check behavior
flyctl ssh console --app your-app-nameTesting Fly.io Postgres
If you're using Fly Postgres, add a dedicated test for your database cluster health:
#!/bin/bash
# test-fly-postgres.sh
PG_APP="your-app-db"
echo "Testing Fly Postgres cluster: $PG_APP"
# Check cluster status
flyctl postgres status --app "$PG_APP"
# Verify leader election
MACHINES=$(flyctl machines list --app "$PG_APP" --json)
LEADER=$(echo "$MACHINES" | jq -r '[.[] | select(.checks[]?.status == "passing")] | length')
echo "Healthy machines: $LEADER"
# Test connection string works
flyctl postgres connect --app "$PG_APP" -c "SELECT version();"Putting It All Together
Here's the complete testing pipeline for a Fly.io deployment:
- Pre-deploy: Run unit and integration tests in CI
- Deploy:
flyctl deploy --remote-only --wait-timeout 300 - Post-deploy: Wait for Fly.io health checks, then run smoke tests
- Validate machines: Confirm all Machines are in
startedstate and on the same version - Multi-region check: Hit each configured region and verify responses
- Continuous monitoring: HelpMeTest runs full E2E tests on a schedule
The key insight is that Fly.io's built-in health checks and rolling deploy behavior give you a safety net, but they only check what you configure. Smoke tests and continuous monitoring catch what health checks miss — subtle regressions, performance degradation, and user-facing issues that only appear under real conditions.
With this setup, you can deploy to Fly.io with confidence, knowing that bad deploys will be caught before they reach users and that any post-deploy issues will surface immediately.