Skip to content

Apps Script

A free cron job for side projects, with Google Apps Script

No server, no paid plan. How I use a Google Apps Script time-driven trigger as a cron job that tells my backend to send overdue debt reminder emails.

Hau Le8 min read
The Google Apps Script logo: four rounded bars in red, yellow, green and blue fanned out on a light grey background

Side projects keep needing small scheduled jobs: send a reminder email every morning, clean up old records every night, sync something once an hour. A real cron job usually means a server that is always on, or a paid plan on your hosting platform.

For my own projects I use something I already had: Google Apps Script. It's free with a Google account, it has scheduled triggers built in, and it emails me when a run fails. This post shows how I set it up for a job that sends reminder emails for overdue debts.

Short version: keep the job's logic in your backend behind one protected endpoint. Let Apps Script be the clock: a time-driven trigger calls that endpoint on a schedule, and Google handles the rest.

Why Apps Script makes a good personal cron

Apps Script (opens in a new tab) is Google's JavaScript platform for automating Google Workspace. Most people use it for Sheets macros, but it has three features that make it a handy scheduler:

  • Time-driven triggers. Run any function every few minutes, hours, days, weeks or months, with no server.
  • Failure notifications. If a run throws an error, Google emails you, daily or right away.
  • An execution log. Every run is listed with its status, duration and console.log output.

It's also free, and if your project already lives around a Google Sheet, it's right next to your data.

How it compares with the usual options for a small project:

OptionTrade-off
crontab on a serverExact and flexible, but you need a server running all the time
Hosting platform cronsBuilt in, but free tiers often limit how often or how precisely jobs run
GitHub Actions schedulesFree for public repos, but scheduled runs can be delayed under load
Apps Script triggersFree, no infrastructure, failure emails; runs within a time window, not to the minute

For a reminder email, "sometime between 8 and 9 in the morning" is precise enough.

How the pieces fit

The setup has two parts:

  1. The backend exposes one endpoint, POST /jobs/debts/overdue. It finds overdue debts and sends the reminder emails. It only accepts requests that carry a secret token in an x-jobs-token header.
  2. Apps Script holds one small function that calls that endpoint, and a time-driven trigger that runs the function on a schedule.

All the business logic stays in the backend, where it's tested and versioned with the rest of the app. Apps Script only knows a URL and a token. If I move off Apps Script one day, I point another scheduler at the same endpoint and nothing else changes.

Step 1: Write the job function

My Apps Script project, Dept Reminding, already holds other helper scripts for the app (email templates, QR codes, Firebase uploads). I added one file for the job:

The Apps Script editor for the Dept Reminding project, with a file list on the left and the jobTriggerAutoRemind function open: a POST request to a jobs endpoint with a JSON content type and an x-jobs-token header
The job file in the Dept Reminding project. The domain and token are placeholders.
async function jobTriggerAutoRemind() {
  const res = await UrlFetchApp.fetch("https://[domain.com]/jobs/debts/overdue", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-jobs-token": `[Secret key]`,
    },
    payload: JSON.stringify({
      meta: "auto-trigger-from-app-script",
    }),
  });

  console.log(res);

  return res;
}

What each part does:

  • UrlFetchApp.fetch is Apps Script's HTTP client. It sends the POST to the job endpoint.
  • x-jobs-token is the shared secret the backend checks before it does anything.
  • payload.meta tags the request, so the backend logs show that the run came from the Apps Script trigger and not from a manual call.

You can run the function straight from the editor (select it next to Run) and check the Execution log before scheduling anything.

Step 2: Move the secret out of the code

Anyone who can open the project can read a token written in the code. Apps Script has a better place for it: Project Settings → Script Properties. Add two properties there, JOBS_URL and JOBS_TOKEN, and read them at run time.

While I was there, I tightened a few other things:

function jobTriggerAutoRemind() {
  const props = PropertiesService.getScriptProperties();

  const res = UrlFetchApp.fetch(props.getProperty("JOBS_URL"), {
    method: "post",
    contentType: "application/json",
    headers: { "x-jobs-token": props.getProperty("JOBS_TOKEN") },
    payload: JSON.stringify({ meta: "auto-trigger-from-app-script" }),
    muteHttpExceptions: true,
  });

  const code = res.getResponseCode();
  console.log(code, res.getContentText());

  // Throwing marks the run as failed, so the trigger's failure email reaches you.
  if (code >= 300) throw new Error(`Overdue job failed with HTTP ${code}`);
}
  • No async/await. UrlFetchApp.fetch is synchronous, so they aren't needed.
  • contentType is the option UrlFetchApp documents for the request body type.
  • muteHttpExceptions: true returns error responses instead of throwing straight away, so the log shows the status code and the body the backend sent back.
  • Logging the code and the body. Logging the response object itself doesn't show you much.
  • Throwing on a bad status, so a failed job shows up as failed and triggers the email.

Step 3: Add a time-driven trigger

Open Triggers (the alarm-clock icon in the left sidebar) and click Add Trigger:

The Add Trigger dialog for Dept Reminding: function jobTriggerAutoRemind, deployment Head, event source From spreadsheet, event type On open, and failure notifications set to Notify me daily
The Add Trigger dialog. For a cron job, change the event source from 'From spreadsheet' to 'Time-driven'.
  1. Choose which function to run: jobTriggerAutoRemind.
  2. Choose which deployment should run: Head, the latest saved code.
  3. Select event source: Time-driven. A project attached to a Sheet defaults to From spreadsheet, which runs on sheet events like opening it. That isn't a schedule.
  4. Select type of time based trigger: pick the cadence:
    • Minutes timer: every 1, 5, 10, 15 or 30 minutes
    • Hour timer: every 1, 2, 4, 6, 8 or 12 hours
    • Day timer: once a day, within a one-hour window you choose (for example 8am to 9am)
    • Week timer and Month timer: a day, plus an hour window
    • Specific date and time: a one-off run
  5. Failure notification settings: Notify me immediately for anything that matters, or Notify me daily for a digest.

Save, and approve the permissions prompt the first time. The trigger runs as you, with your account's access.

You can also create the trigger in code, which helps if you recreate projects often. Run this once:

function installTrigger() {
  ScriptApp.newTrigger("jobTriggerAutoRemind").timeBased().everyDays(1).atHour(8).create();
}

Times follow the project's time zone (Project Settings), so check it's yours before you pick an hour.

Step 4: Make the endpoint safe to call

The trigger is only the clock. The endpoint does the work, so it needs a few guards:

  • Check the token on every request and reject anything without it. Keep the token in your backend's environment variables, never in the repo.
  • Make the job idempotent. You'll run it by hand while testing, on top of the scheduled runs. Record which reminders went out so the same debt doesn't get two emails on the same day.
  • Keep it short. Respond once the work is done, or queue it and respond straight away. Either way the request has to finish well within Apps Script's run time limit.
  • Return a small summary, like how many reminders were sent. It ends up in the Apps Script execution log.

Limits to know

Apps Script is generous for personal use, but it has quotas. At the time of writing, for a free Google account:

  • A single run can take up to 6 minutes.
  • All trigger runs together can use up to 90 minutes a day (6 hours on Google Workspace).
  • 20 triggers per user per script.
  • 20,000 UrlFetchApp calls a day.

A job that makes one HTTP call a day is nowhere near any of these. Google lists the current numbers on the quotas page (opens in a new tab).

Two more things to keep in mind:

  • Timing isn't exact. A day timer runs somewhere inside its hour window. If you need a run at exactly 08:00:00, use a real scheduler.
  • It's tied to your account. If the account loses access, or you remove the project, the job stops. Fine for a side project, not for a business-critical system.

Wrap-up

For a personal project, Google Apps Script is a cron job you already have: free, no server, with logs and failure emails built in. Keep the logic in your backend behind one token-protected endpoint, let a time-driven trigger call it, and a scheduled job is about 20 lines of code and a few clicks away.

A desk at night with code on the monitor, a laptop and a flip clock showing 21:10

Article · October 2026 · 8 min

Vibe coding my portfolio end to end: Figma, Claude Code, Next.js and Vercel

How I built this site with Claude, from Figma design and planning to tokens, MDX content, GitLab CI and a Vercel deploy. Every step, with what I'd keep.

Read article