DEV Community

Daniel Ioni
Daniel Ioni

Posted on

Debugging a Production Health Check: Nginx, Cloudflare, Let's Encrypt and GitHub Actions

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"
Enter fullscreen mode Exit fullscreen mode

What I learned

There were several useful lessons here.

  1. 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.

  1. 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

  1. 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.

  1. 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
Enter fullscreen mode Exit fullscreen mode

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

devops #githubactions #nginx #cloudflare #node #javascript #cicd #letsencrypt

Top comments (0)