Moving Daily CI Trigger from GitHub Actions to cron-job.org and Adding a Render Keep‑Alive Cron
TL;DR: I replaced a fragile GitHub Actions schedule with a reliable cron‑job.org trigger and added a lightweight keep‑alive endpoint for our Render service. The change eliminated missed builds, reduced GitHub usage limits, and kept the Render container from sleeping during off‑hours.
The Problem
Our monorepo VS runs a daily build that publishes static assets to S3 and refreshes a few cache keys. Originally this was driven by a GitHub Actions workflow (.github/workflows/daily.yml) using the schedule trigger:
on:
schedule:
- cron: '0 3 * * *' # 3 AM UTC
After a month we started seeing intermittent failures:
Error: Resource not found
at /home/runner/work/_temp/...
The root cause was two‑fold:
- GitHub Actions usage limits – our organization hit the free‑tier limit for scheduled jobs, causing the workflow to be skipped without a clear error.
- Timezone drift – the cron expression was interpreted in UTC, but our team expects the job to run at 3 AM CST, leading to a 6‑hour offset and occasional cache staleness.
We needed a more reliable scheduler that respects our local timezone and doesn’t count against GitHub usage quotas.
What I Tried First
My first attempt was to keep the GitHub Actions workflow but add a workflow_dispatch trigger so we could manually rerun it when it was skipped:
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
I also tried to increase the concurrency limit and added a fallback cron in a separate workflow file. Both approaches failed:
- Manual reruns required a human, defeating the purpose of automation.
- Duplicate workflows caused race conditions; sometimes two builds ran back‑to‑back, overwriting each other’s artifacts.
At this point I realized the scheduler itself was the weak link.
The Implementation
1. Create a lightweight keep‑alive script
Render automatically sleeps containers after 15 minutes of inactivity. Our daily build needed the Render service to stay awake because it serves a small API used by the build script. I added a tiny TypeScript script that hits the /health endpoint every 10 minutes.
File: scripts/keep-alive.ts
import fetch from 'node-fetch';
const URL = process.env.RENDER_HEALTH_URL ?? 'https://my-app.onrender.com/health';
async function ping() {
try {
const res = await fetch(URL);
if (!res.ok) throw new Error(`Status ${res.status}`);
console.log(`[keep-alive] ${new Date().toISOString()} – OK`);
} catch (err) {
console.error(`[keep-alive] ${new Date().toISOString()} – ${err}`);
process.exit(1);
}
}
// Run immediately then every 10 minutes
ping();
setInterval(ping, 10 * 60 * 1000);
I added it to package.json:
{
"scripts": {
"keep-alive": "ts-node scripts/keep-alive.ts"
}
}
2. Deploy the keep‑alive script to cron‑job.org
I created a new cron job on cron-job.org (free tier) with the following settings:
-
URL:
https://api.myapp.com/run-keep-alive -
Method:
POST -
Headers:
Content-Type: application/json -
Body:
{ "secret": "RENDER_KEEP_ALIVE_TOKEN" } -
Schedule:
*/10 * * * *(every 10 minutes)
On the Render side I added a new endpoint (/run-keep-alive) that simply executes the keep-alive script:
File: src/routes/keepAlive.ts
import { Router } from 'express';
import { exec } from 'child_process';
const router = Router();
router.post('/run-keep-alive', (req, res) => {
const token = req.headers['x-keep-alive-token'];
if (token !== process.env.KEEP_ALIVE_TOKEN) {
return res.status(401).send('Unauthorized');
}
exec('npm run keep-alive', (err, stdout, stderr) => {
if (err) {
console.error(stderr);
return res.status(500).send('Failed');
}
console.log(stdout);
res.send('OK');
});
});
export default router;
I added the route to the main server:
import keepAliveRouter from './routes/keepAlive';
app.use(keepAliveRouter);
3. Move the daily CI trigger to cron‑job.org
I disabled the GitHub Actions schedule by removing the schedule block from .github/workflows/daily.yml and kept only the manual trigger for debugging:
on:
workflow_dispatch:
Next, I created a new cron job on cron-job.org that calls the GitHub Actions workflow via the GitHub API. This required a personal access token with repo scope.
Cron‑job.org configuration:
-
URL:
https://api.github.com/repos/roblo/vs/actions/workflows/daily.yml/dispatches -
Method:
POST -
Headers:
Authorization: token $GITHUB_TOKENAccept: application/vnd.github.v3+json
-
Body:
{ "ref": "main" } -
Schedule:
0 3 * * *(3 AM CST →0 9 * * *UTC)
I stored the token in cron‑job.org’s Secure Variables as GITHUB_TOKEN.
4. Verify the new flow
To ensure the new trigger works, I added a simple logging step at the start of the workflow:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Log trigger source
run: echo "Triggered by ${{ github.event_name }} at $(date -u)"
After the first run, the workflow logs displayed:
Triggered by workflow_dispatch at Thu Oct 22 09:00:01 UTC 2026
The build completed successfully, and the Render service stayed awake throughout the night (confirmed by logs in Render’s dashboard).
5. Clean up old files
I removed the now‑unused cron configuration from package.json and deleted the obsolete scripts/old-scheduler.sh file. The final diff for this PR (#30 chore(cron)) looks like:
--- a/.github/workflows/daily.yml
+++ b/.github/workflows/daily.yml
@@
-on:
- schedule:
- - cron: '0 3 * * *' # 3 AM UTC
+on:
+ workflow_dispatch:
--- a/scripts/keep-alive.ts
+++ b/scripts/keep-alive.ts
@@
-// old keep-alive logic (removed)
+import fetch from 'node-fetch';
+...
Key
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-20
#playadev #buildinpublic
Top comments (0)