Cron Jobs
The two scheduled jobs Launchy ships — weekly batch publication and highlight cleanup.
Launchy has exactly two cron routes under app/api/cron/. Both require a Bearer $CRON_SECRET header for auth.
The jobs
1. weekly-batch (core)
Endpoint: POST /api/cron/weekly-batch
What it does (see app/api/cron/weekly-batch/route.ts):
- Authenticates via timing-safe comparison on
Authorization: Bearer $CRON_SECRET - Computes current ISO week + year
- Creates a
batchesrow if one doesn't exist for that week/year (idempotent guard) - Selects accepted submissions scheduled for that week
- Inserts
productsrows - Sets
dofollow = trueon all Boost/Highlight products + the topdofollow_top_nfree products by vote count - Sets
batches.published_at = now() - Creates and sends a Plunk campaign (weekly newsletter)
- Revalidates
/and/batches
No Redis lock — idempotency is purely DB-driven.
2. cleanup-highlights
Endpoint: POST /api/cron/cleanup-highlights
What it does (see app/api/cron/cleanup-highlights/route.ts):
- Authenticates the same way
- Finds products with
tier = 'highlight'andhighlight_expires_at <= now() - Clears
highlight_expires_at = nullon each (tier stayshighlight, but the UI stops treating the product as actively highlighted because the expiry is unset) - Revalidates
/
Idempotent — running it again does nothing.
Auth
Both routes require Authorization: Bearer $CRON_SECRET.
- Generate:
openssl rand -base64 32 - Set as
CRON_SECRETin your env - Vercel Crons supply
x-vercel-cron-signatureautomatically — the route accepts this in place of the Bearer header
Provider setup
Vercel
Launchy ships vercel.json at repo root already configured:
{
"crons": [
{ "path": "/api/cron/weekly-batch", "schedule": "0 6 * * 1" },
{ "path": "/api/cron/cleanup-highlights", "schedule": "0 0 * * *" }
]
}Times are UTC. If you change launch_day / launch_hour_utc in settings, edit the weekly-batch schedule here and redeploy. Vercel Hobby allows 2 crons — these two fit exactly.
Coolify (Scheduled Tasks)
If you deployed with Coolify (see Self-hosted with Coolify), use the built-in Scheduled Tasks runner — no host-side crontab needed.
- Coolify app → Scheduled Tasks tab → + Add
- Task 1: Weekly batch
- Name:
Weekly batch - Frequency:
0 6 * * 1(Monday 06:00 UTC — match yourlaunch_day+launch_hour_utcsettings) - Container: the Launchy app container
- Command:
curl -sS -X POST -H "Authorization: Bearer $CRON_SECRET" "$BETTER_AUTH_URL/api/cron/weekly-batch"
- Name:
- Task 2: Cleanup highlights
- Name:
Cleanup highlights - Frequency:
0 * * * *(every hour) - Command:
curl -sS -X POST -H "Authorization: Bearer $CRON_SECRET" "$BETTER_AUTH_URL/api/cron/cleanup-highlights"
- Name:
Coolify injects the app's env vars into the task context, so both $CRON_SECRET and $BETTER_AUTH_URL resolve at runtime.
Notes:
$BETTER_AUTH_URLis already your public production URL (e.g.,https://your-domain.com) — reusing it avoids duplicating config- If you prefer an app-only env var for crons, add
CRON_BASE_URLto the app's env vars and reference it instead - The Run Now button on each task triggers an immediate execution (useful for testing)
- Coolify's Logs tab shows stdout/stderr + response body for each run, with next-run timestamp
- Failed runs are visible in the task status badge — combine with the Discord webhook inside the cron route for alerting
Self-hosted (system cron)
⚠️ System crontab does NOT source your .env or .bashrc. You have two options:
Option A — source an env file inside the cron command:
# Drop in /etc/cron.d/launchy (runs as root):
0 6 * * 1 root . /opt/launchy/.env.cron && curl -sS -X POST -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/weekly-batch
0 0 * * * root . /opt/launchy/.env.cron && curl -sS -X POST -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/cleanup-highlightsWith /opt/launchy/.env.cron containing:
export CRON_SECRET=your-actual-secret-hereFile permissions: chmod 600 /opt/launchy/.env.cron so only root can read.
Option B — hardcode the secret in crontab (simpler but less clean):
0 6 * * 1 curl -sS -X POST -H "Authorization: Bearer your-secret-here" https://your-domain.com/api/cron/weekly-batch
0 0 * * * curl -sS -X POST -H "Authorization: Bearer your-secret-here" https://your-domain.com/api/cron/cleanup-highlightsRun chmod 600 /etc/crontab (or ~/crontab for user-owned).
External scheduler (cron-job.org, EasyCron)
- URL:
https://your-domain.com/api/cron/weekly-batch - Method: POST
- Custom header:
Authorization: Bearer <CRON_SECRET> - Schedule:
0 6 * * 1
Repeat for cleanup-highlights.
Schedule syntax
Standard cron: minute hour day-of-month month day-of-week.
| Cron | Meaning |
|---|---|
0 6 * * 1 | Monday 06:00 |
0 * * * * | Every hour at :00 |
*/15 * * * * | Every 15 minutes |
All times are UTC unless your cron daemon is configured otherwise.
Testing locally
Simulate the weekly flow in-memory without hitting the real endpoints:
bun scripts/simulate-crons.tsOr hit endpoints directly against local dev:
curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
http://localhost:3000/api/cron/weekly-batchFailure handling
If a route returns non-200:
- Vercel Crons retry per their policy
- System cron / cron-job.org retry per the scheduler's setting
Logs:
- Vercel: Project → Functions → filter by cron path
- Self-hosted: redirect curl output to a log file (as shown above)
- cron-job.org: execution history in dashboard with response bodies
Adding a new cron
- Create
app/api/cron/my-job/route.ts:import { NextRequest, NextResponse } from "next/server" import { env } from "@/lib/env" import { timingSafeEqual } from "crypto" export async function POST(request: NextRequest) { const auth = request.headers.get("authorization") ?? "" const expected = `Bearer ${env.CRON_SECRET ?? ""}` const vercelSig = request.headers.get("x-vercel-cron-signature") const authOk = auth.length === expected.length && timingSafeEqual(Buffer.from(auth), Buffer.from(expected)) if (!authOk && !vercelSig) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } // your job here return NextResponse.json({ ok: true }) } - Add to
vercel.jsonor system crontab - Test with
curl
Jobs that could be useful to add
Not shipped by default — add if you need them:
- Sponsor renewal reminder — email sponsors whose
ends_atis 7 days away - Publish scheduled blog posts — flip drafts whose
published_at <= now() - Inactive user cleanup — delete unverified accounts older than N days