DEV Community

Roberto Luna
Roberto Luna

Posted on

Moving Daily Jobs from GitHub Actions to cron‑job.org for a Render‑Free API

Moving Daily Jobs from GitHub Actions to cron‑job.org for a Render‑Free API

TL;DR: I replaced the internal setTimeout‑based schedulers and GitHub Actions cron with an external cron‑job.org trigger plus a lightweight “wake” endpoint. This prevents the Render free instance from sleeping and guarantees daily email, report, and cleanup jobs run on schedule.


The Problem

Our API (playamxcrm-api) runs on Render’s free tier. Render idles the container after ~15 minutes of inactivity, which means any setTimeout/setInterval‑based internal schedulers never fire. The symptom was obvious in the logs:

[cron] Scheduler started…
[error] TimeoutError: Request failed with status code 503 (Service Unavailable)
Enter fullscreen mode Exit fullscreen mode

The daily jobs (email digests, monthly reports, tour cleanup) were supposed to be kicked off by a GitHub Actions workflow (cron-diario.yml). However, the workflow hit the same sleeping API, causing 30‑second request cut‑offs enforced by cron‑job.org and occasional 503 responses from Render.


What I Tried First

  1. Rely on internal timers – startEmailCron(), startReportCron(), etc., were started on server boot.
  2. GitHub Actions cron – A workflow ran curl -X POST https://playamxcrm-api.onrender.com/internal/cron/daily at 07:00 UTC.

Both approaches failed:

  • The internal timers never resumed after the container slept.
  • The GitHub Actions job sometimes executed before the container woke, hitting Render’s 30 s request timeout and returning 503 Service Unavailable.

I attempted to add a “keep‑alive” ping from the workflow before the real job, but the 30 s limit on cron‑job.org made it unreliable.


The Implementation

1. External Cron Service (cron‑job.org)

I switched to a dedicated external cron that calls a lightweight endpoint every minute to keep the API alive, and a separate endpoint for the actual daily job at 07:00 UTC.

.github/workflows/cron-diario.yml (trimmed diff)

- name: ⏰ Cron diario — API PlayaMXCRM
+ name: ⏰ Cron diario — API PlayaMXCRM (respaldo manual)

# The API runs on Render free plan, which sleeps after ~15 min without traffic.
# We now rely on an external cron service (cron-job.org) to trigger the job.
Enter fullscreen mode Exit fullscreen mode

The workflow now only serves as a manual fallback; the real schedule lives in cron‑job.org.

2. New Environment Variable

Added CRON_SECRET to differentiate external calls from internal ones.

.env.example (diff)

- # ── Cron externo ─────────────────────────────────────────────────────────────
- # Si se define, los jobs diarios (correos, reporte mensual, resumen sema
+ # ── Cron externo ─────────────────────────────────────────────────────────────
+ # Si se define, los jobs diarios (correos, reporte mensual, resumen semanal) 
+ # se ejecutan sin depender de los timers internos.
+ CRON_SECRET=super‑secret‑string
Enter fullscreen mode Exit fullscreen mode

The secret is read in the controller to bypass the internal “sleep guard”.

3. Cron Trigger Controller

Refactored apps/api/src/cron/cron-trigger.controller.ts to:

  • Detect external triggers via CRON_SECRET.
  • Return early for keep‑alive pings (HTTP GET) and only run the heavy job on a POST with a valid secret.
@@
- * Por qué existe: la API corre en el plan free de Render, que la duerme tras ~15 min sin
- * tráfico. Los setTimeout/setInterval de startEmailCron() & cía. nunca lleg
+ * Por qué existe: la API corre en el plan free de Render, que la duerme tras ~15 min sin
+ * tráfico. Un cron externo (cron‑job.org) mantiene la instancia viva y dispara
+ * los jobs diarios de forma fiable.
@@
- export async function cronTrigger(req: Request, res: Response) {
-   // original logic that relied on internal timers
- }
+ export async function cronTrigger(req: Request, res: Response) {
+   const secret = req.headers['x-cron-secret'] as string | undefined;
+
+   // Keep‑alive ping – just return 200 so the service stays awake.
+   if (req.method === 'GET') {
+     return res.status(200).json({ status: 'awake' });
+   }
+
+   // Validate secret for POST (actual job)
+   if (secret !== process.env.CRON_SECRET) {
+     return res.status(401).json({ error: 'Invalid cron secret' });
+   }
+
+   try {
+     await runDailyJobs(); // email, report, tour cleanup, etc.
+     return res.status(200).json({ status: 'jobs executed' });
+   } catch (err) {
+     console.error('Cron job failed', err);
+     return res.status(500).json({ error: 'Job execution error' });
+   }
+ }
Enter fullscreen mode Exit fullscreen mode

4. Wake Endpoint (Web Layer)

Added a public GET endpoint that cron‑job.org calls every minute. It simply forwards the request to the API’s /internal/cron/wake route, keeping the container from sleeping.

apps/web/src/app/api/wake/route.ts

/**
 * GET /api/wake — wakes the Render API without making the caller wait.
 *
 * The API sleeps after ~15 min of inactivity; this endpoint is hit by
 * cron‑job.org every minute to keep it alive.
 */
import { NextResponse } from 'next/server';

export async function GET() {
  try {
    // Fire‑and‑forget request to the internal cron endpoint
    fetch('https://playamxcrm-api.onrender.com/internal/cron/wake', {
      method: 'GET',
      timeout: 5_000,
    });
    return NextResponse.json({ status: 'wake sent' }, { status: 200 });
  } catch (e) {
    console.error('Wake failed', e);
    return NextResponse.json({ error: 'wake failed' }, { status: 500 });
  }
}
Enter fullscreen mode Exit fullscreen mode

The route is public (no auth) because it only triggers a lightweight GET on the API, which is safe and idempotent.

5. Tests Adjusted

Updated the test suite to mock the new secret header and to differentiate keep‑alive GET from job POST.

apps/api/src/__tests__/cron-trigger.test.ts (excerpt)

import request from 'supertest';
import { app } from '../../main';

describe('Cron Trigger', () => {
  it('responds 200 to keep‑alive GET', async () => {
    const res = await request(app).get('/internal/cron/daily');
    expect(res.status).toBe(200);
    expect(res.body.status).toBe('awake');
  });

  it('rejects POST without secret', async () => {
    const res = await request(app).post('/internal/cron/daily');
    expect(res.status).toBe(401);
  });

  it('executes jobs with valid secret', async () => {
    const res = await request(app)
      .post('/internal/cron/daily')
      .set('x-cron-secret', process.env.CRON_SECRET!);
    expect(res.status).toBe(200);
    expect(res.body.status).toBe('jobs executed');
  });
});
Enter fullscreen mode Exit fullscreen mode

6. Mobile Table Fix (Side Note)

While reviewing the PR, I also patched a CSS overflow issue that caused wide


Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-10-12

#playadev #buildinpublic

Top comments (0)