# Apps on Vercel, Netlify, or Cloudflare Pages

Check an app on Vercel, Netlify, or Cloudflare Pages from outside, mark its deploys, and watch its scheduled jobs.

kogut is a long-running process with a persistent disk, so it cannot run on these
platforms. Run it on a small VPS, [Railway](https://docs.kogut.dev/installation/railway/), or
[Render](https://docs.kogut.dev/installation/render/), at another provider than your app; see
[Where to run kogut](https://docs.kogut.dev/high-availability/setups/). On Render, the disk needs a paid
instance type, such as **Starter**. [kogut edge](https://docs.kogut.dev/high-availability/kogut-edge/)
runs on Cloudflare's free Workers plan.

## Add a health route

A check of your home page only shows that the CDN answers. A health route shows that the
functions run and reach the database. In a Next.js App Router project:

```ts title="app/api/health/route.ts"
import { db } from "@/lib/db";

export const dynamic = "force-dynamic";

export async function GET() {
	try {
		await db.query("select 1");
		return Response.json({ db: "ok" });
	} catch {
		return Response.json({ db: "error" }, { status: 500 });
	}
}
```

`force-dynamic` keeps Next.js and the CDN from serving a cached answer. Replace
`db.query` with your database client's simplest query.

## Check the app from outside

Check the production domain, not a preview URL, which changes with every branch and can
sit behind deployment protection:

```toml title="kogut.toml"
[[service]]
name = "shop"

[[check]]
name = "Shop health"
service = "shop"
url = "https://shop.acme.com/api/health"
every = "1m"
expect = [
	{ status = "2xx" },
	{ json = "$.db", equals = "ok" },
	{ response_time_below = "2s", else = "degraded" },
]
```

`else = "degraded"` marks a slow cold start as degraded instead of down. A check every
minute calls the function about 43,200 times in 30 days. See
[Checks](https://docs.kogut.dev/monitoring/checks/).

## Mark deploys

Choose one way:

- **GitHub webhook with `deployment_status`.** Vercel and other platforms that report
  deploys to GitHub send this event; kogut records production deployments and skips
  previews. See [Deployments](https://docs.kogut.dev/deploys/github/#deployments).
- **GitHub webhook with `push`.** When the platform deploys every push to the default
  branch. The marker shows when the push arrives, a few minutes before the new version is
  live.
- **The deploy API from CI.** When a CI job deploys with the platform's command-line
  tool, call the [deploy API](https://docs.kogut.dev/deploys/api/) after the production deploy finishes:

  ```sh
  curl -fsS -X POST https://kogut.acme.com/api/deploys \
  	-H "Authorization: Bearer $KOGUT_TOKEN" \
  	-d service=shop -d version="$GITHUB_SHA"
  ```

## Watch scheduled jobs

Create a [heartbeat](https://docs.kogut.dev/monitoring/heartbeats/) with the job's schedule and store its
[ping URL](https://docs.kogut.dev/monitoring/heartbeats/#copy-the-ping-url) as a secret named
`KOGUT_PING_URL`. The schedules below use UTC, so leave the heartbeat's time zone at UTC.
Set the grace period to cover the job's runtime and any delay in starting it.

**Vercel Cron**

Vercel calls the route on the schedule in `vercel.json`. Add a `CRON_SECRET` environment
variable of 16 or more random characters. Vercel sends it as a bearer token, and the
route refuses other callers:

```json title="vercel.json"
{ "crons": [{ "path": "/api/cron/cleanup", "schedule": "0 3 * * *" }] }
```

```ts title="app/api/cron/cleanup/route.ts"
import { cleanup } from "@/lib/cleanup";

export const dynamic = "force-dynamic";

const ping = (path = "") =>
	fetch(`${process.env.KOGUT_PING_URL}${path}`).catch(() => undefined);

export async function GET(request: Request) {
	const secret = process.env.CRON_SECRET;
	if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
		return new Response("Unauthorized", { status: 401 });
	}
	await ping("/start");
	try {
		await cleanup();
	} catch (error) {
		await ping("/fail");
		throw error;
	}
	await ping();
	return new Response("OK");
}
```

**Supabase pg_cron**

Store the ping URL in Vault, then schedule the job with a `pg_net` request after its
statements:

```sql
select vault.create_secret('https://kogut.acme.com/ping/btp_...', 'kogut_ping_url');

select cron.schedule(
	'nightly-cleanup',
	'0 3 * * *',
	$$
	delete from sessions where expires_at < now();
	select net.http_get(
		(select decrypted_secret from vault.decrypted_secrets where name = 'kogut_ping_url')
	);
	$$
);
```

The statements run in one transaction, so the ping is sent only when the delete
succeeds.

**Cloudflare Workers**

Add the schedule under `[triggers]` with `crons = ["0 3 * * *"]` in the Worker's
Wrangler configuration, and store the URL with `npx wrangler secret put KOGUT_PING_URL`:

```js title="src/index.js"
import { cleanup } from "./cleanup.js";

export default {
	async scheduled(controller, env, ctx) {
		ctx.waitUntil(run(env));
	},
};

async function run(env) {
	const ping = (path = "") =>
		fetch(`${env.KOGUT_PING_URL}${path}`).catch(() => undefined);
	await ping("/start");
	try {
		await cleanup(env);
	} catch (error) {
		await ping("/fail");
		throw error;
	}
	await ping();
}
```

## Errors and visits in the browser

Add the [tracking script](https://docs.kogut.dev/web-apps/tracking-script/), which has a Next.js snippet.

## What kogut does not see

kogut does not read function logs, build logs, or the platform's metrics, and changes
nothing on the platform. There is no agent to install there.
