Introduction
Sometimes production is working perfectly, but your monitoring pipeline keeps telling you otherwise.
That was exactly the situation I encountered while setting up a production health check for the MyZubster Gateway.
The API was reachable through HTTPS:
curl -i https://gateway.myzubster.com/api/health
and returned:
{
"status": "ok",
"timestamp": "2026-08-16T17:27:03.710Z",
"uptime": 5825.922407856
}
HTTP status:
200 OK
Nginx was also happy:
nginx: configuration file /etc/nginx/nginx.conf test is successful
And Let's Encrypt had successfully issued the certificate:
Certificate is saved at:
/etc/letsencrypt/live/gateway.myzubster.com/fullchain.pem
So why was GitHub Actions still failing?
The production stack
The request path looked roughly like this:
GitHub Actions
│
▼
gateway.myzubster.com
│
▼
Cloudflare
│
▼
Nginx :443
│
▼
Node.js / Express
│
▼
/api/health
The first important step was proving that the application itself was healthy.
Testing the endpoint directly
I tested the origin while explicitly resolving the hostname:
curl -i --resolve gateway.myzubster.com:443:188.213.161.186 \
https://gateway.myzubster.com/api/health
The result:
HTTP/2 200
server: nginx
content-type: application/json; charset=utf-8
x-powered-by: Express
So the API was definitely responding.
DNS was also resolving through Cloudflare:
dig +short gateway.myzubster.com
returned:
172.67.190.57
104.21.19.214
At this point, the infrastructure looked healthy.
The GitHub Actions failure
The health-check workflow initially contained a validation step:
- name: Validate APP_URL secret env: APP_URL: ${{ secrets.APP_URL }} run: | if [ -z "$APP_URL" ]; then echo "APP_URL secret is not configured" exit 1 fi
The workflow failed with:
APP_URL secret is not configured
That was confusing because the repository did have an APP_URL secret.
I checked it with:
gh secret list \
--repo MyZubster-Ecosystem/MyZubsterGateway \
--env production
and got:
NAME UPDATED
APP_URL about 1 hour ago
The important detail was hidden in the command itself:
--env production
The secret wasn't a repository-level secret.
It was an environment secret.
The actual problem
The workflow was running the job without specifying the GitHub Actions environment.
Initially:
jobs:
health-check:
runs-on: ubuntu-latest
But the secret lived inside:
production
environment.
Therefore GitHub Actions wasn't exposing that secret to the job.
The fix was surprisingly small:
jobs:
health-check:
runs-on: ubuntu-latest
environment: production
That's it.
This tells GitHub Actions:
Run this job using the configuration and secrets associated with the production environment.
Verifying the fix
After updating the workflow, I committed the change:
git add .github/workflows/health-check.yml
git commit -m "fix: use production environment for health check"
git push origin main
The resulting commit was:
0f9c75551f73dcacc60e7a08298929be6801c761
I then verified that GitHub actually had the expected version:
gh api \
repos/MyZubster-Ecosystem/MyZubsterGateway/contents/.github/workflows/health-check.yml \
--jq '.content' | base64 -d
The relevant section was now:
jobs:
health-check:
runs-on: ubuntu-latest
environment: production
Running the health check manually
Instead of waiting for the five-minute schedule, I triggered the workflow manually:
gh workflow run health-check.yml \
--repo MyZubster-Ecosystem/MyZubsterGateway \
--ref main
Then:
gh run list \
--repo MyZubster-Ecosystem/MyZubsterGateway \
--workflow health-check.yml \
--limit 1
Result:
STATUS TITLE WORKFLOW BRANCH EVENT
✓ Production Health C... Production Healt... main workflow_dispatch
Success. ✅
The final verification
The most useful part was inspecting the complete workflow log:
gh run view 31962023275 \
--repo MyZubster-Ecosystem/MyZubsterGateway \
--log
GitHub Actions now showed:
APP_URL: ***
instead of:
APP_URL:
The health endpoint returned:
HTTP status: 200
Health check OK: HTTP 200
And the endpoint checks returned:
/api/health: HTTP 200
/api/status: HTTP 200
Finally:
✓ health-check in 6s
The complete workflow
The relevant production workflow ended up looking like this:
name: Production Health Check
on:
schedule:
- cron: '*/5 * * * *'
workflow_dispatch:
env:
CURL_TIMEOUT: 15
jobs:
health-check:
runs-on: ubuntu-latest
environment: production
steps:
- name: Validate APP_URL secret
env:
APP_URL: ${{ secrets.APP_URL }}
run: |
if [ -z "$APP_URL" ]; then
echo "APP_URL secret is not configured"
exit 1
fi
- name: Check API Health
env:
APP_URL: ${{ secrets.APP_URL }}
run: |
HEALTH_URL="${APP_URL%/}/api/health"
HTTP_CODE=$(curl \
-sS \
--max-time "$CURL_TIMEOUT" \
-o /tmp/health-response \
-w "%{http_code}" \
"$HEALTH_URL" || true)
echo "Health endpoint: $HEALTH_URL"
echo "HTTP status: $HTTP_CODE"
if [ "$HTTP_CODE" != "200" ]; then
echo "Health check FAILED with HTTP $HTTP_CODE"
cat /tmp/health-response 2>/dev/null || true
exit 1
fi
echo "Health check OK: HTTP $HTTP_CODE"
- name: Check API Endpoints
env:
APP_URL: ${{ secrets.APP_URL }}
run: |
FAILED=0
for endpoint in \
"/api/health" \
"/api/status"
do
CODE=$(curl \
-sS \
--max-time "$CURL_TIMEOUT" \
-o /dev/null \
-w "%{http_code}" \
"${APP_URL%/}$endpoint" || true)
echo "$endpoint: HTTP $CODE"
if [ "$CODE" != "200" ]; then
FAILED=1
fi
done
exit "$FAILED"
What I learned
There were several useful lessons here.
- A failing CI check doesn't necessarily mean production is broken
The API was healthy from the beginning.
The failure was in the configuration of the monitoring system, not the application.
- GitHub Actions environments matter
These two configurations are not equivalent:
secrets.APP_URL
with a repository secret, versus:
secrets.APP_URL
with an environment secret.
If the secret belongs to an environment, the job must target that environment:
environment: production
- Test the layers independently
Instead of immediately modifying the application, I verified:
Let's Encrypt certificate
Nginx configuration
HTTPS connection
Cloudflare/DNS resolution
Express /api/health
GitHub Actions secret availability
GitHub Actions endpoint checks
This made the actual failure much easier to isolate.
- gh is extremely useful for production debugging
Being able to trigger and inspect workflows directly from the server was particularly useful:
gh workflow run
gh run list
gh run view
gh secret list
gh api
It eliminates a lot of clicking around when debugging CI/CD.
Final result
The final architecture is now:
┌──────────────────┐
│ GitHub Actions │
│ every 5 minutes │
└────────┬─────────┘
│
│ HTTPS
▼
┌─────────────────────┐
│ gateway.myzubster │
│ .com │
└──────────┬──────────┘
│
Cloudflare
│
Nginx
│
Node / Express
│
┌──────────┴──────────┐
│ │
/api/health /api/status
│ │
200 200
And the production health check is now green:
✓ Production Health Check
✓ APP_URL available
✓ /api/health → 200
✓ /api/status → 200
✓ GitHub Actions job → success
A one-line GitHub Actions configuration change ended up being the difference between a permanently failing monitoring pipeline and a working production health check.
Sometimes the bug isn't in production. Sometimes the monitor needs monitoring too. 😄
Tags
Top comments (0)