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)
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
-
Rely on internal timers –
startEmailCron(),startReportCron(), etc., were started on server boot. -
GitHub Actions cron – A workflow ran
curl -X POST https://playamxcrm-api.onrender.com/internal/cron/dailyat 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.
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
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' });
+ }
+ }
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 });
}
}
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');
});
});
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)