Build a Cron Job SaaS with Next.js and PostgreSQL

Build and deploy a practical cron job SaaS with Next.js, PostgreSQL, a polling worker and signed webhook requests on a small VPS.

Build a small Cronitor-style service that schedules HTTP jobs, records runs and signs outbound requests. The design uses one Next.js application, PostgreSQL and a worker loop. Verified 2026-08-22.

What we’re building

The finished MVP lets a user:

  • Create an HTTP job with a URL and cron expression.
  • Pause and resume jobs.
  • Execute due jobs from a database-backed worker.
  • Store status code, duration and error details for every run.
  • Sign each outgoing request with HMAC-SHA256.
  • Inspect a JSON dashboard of jobs and recent runs.
  • Deploy the API, worker and PostgreSQL with Docker Compose.

This is an internal-tool or small-SaaS foundation. It does not promise exactly-once execution: a process crash after the HTTP request and before the database update can produce a retry. Jobs must therefore be idempotent.

What you’ll learn

  • How to model schedules and execution leases in PostgreSQL.
  • How to build typed Next.js route handlers with validation and error handling.
  • How to run a worker safely with FOR UPDATE SKIP LOCKED.
  • How to sign webhook requests without putting secrets in URLs.
  • How to deploy a small multi-process app on a VPS.

Final architecture

flowchart LR
    Browser --> API[Next.js API]
    API --> DB[(PostgreSQL)]
    Worker[Worker process] --> DB
    Worker --> Target[Customer HTTP endpoint]
    Worker --> DB

The API and worker share the database but have separate responsibilities. The API never performs a job synchronously. The worker claims due rows, calls the target with a bounded timeout, and schedules the next attempt.

Prerequisites

  • Node.js 22.12 or newer.
  • npm 10 or newer.
  • Docker Engine and Compose v2.
  • PostgreSQL 17 for production, supplied by Compose locally.
  • A VPS with 1 GB RAM and a DNS record for production.

Project structure

cron-saas/
├── app/api/jobs/route.ts
├── app/api/jobs/[id]/route.ts
├── lib/db.ts
├── lib/validation.ts
├── worker.ts
├── migrations/001_init.sql
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── package.json
└── tsconfig.json

1. Create the project

npx [email protected] cron-saas --ts --eslint --app --src-dir=false --use-npm --import-alias '@/*'
cd cron-saas
npm install pg zod node-cron
npm install -D @types/pg tsx
mkdir -p app/api/jobs/'[id]' lib migrations

Add a worker script to package.json:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "worker": "tsx worker.ts"
  }
}

The worker does not use node-cron to schedule individual customer jobs. PostgreSQL is the durable scheduler; the worker only polls for rows whose next_run_at is due.

2. Configure PostgreSQL

Create .env.example:

DATABASE_URL=postgres://cron:change_me@db:5432/cron
JOB_TIMEOUT_MS=15000
WORKER_POLL_MS=5000
APP_URL=http://localhost:3000

Copy it for local use and change the password:

cp .env.example .env

Create migrations/001_init.sql:

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE jobs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
  url text NOT NULL CHECK (length(url) BETWEEN 1 AND 2048),
  cron_expression text NOT NULL CHECK (length(cron_expression) BETWEEN 5 AND 100),
  secret text NOT NULL CHECK (length(secret) >= 32),
  enabled boolean NOT NULL DEFAULT true,
  next_run_at timestamptz NOT NULL,
  lease_until timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE job_runs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
  started_at timestamptz NOT NULL DEFAULT now(),
  finished_at timestamptz,
  status_code integer,
  duration_ms integer,
  success boolean NOT NULL DEFAULT false,
  error text
);

CREATE INDEX jobs_due_idx ON jobs (next_run_at) WHERE enabled;
CREATE INDEX job_runs_job_started_idx ON job_runs (job_id, started_at DESC);

The secret is generated server-side and never returned by the list endpoint. The URL is validated before insertion, but production deployments should also restrict outbound network access to reduce SSRF risk.

3. Add the database client

lib/db.ts

import { Pool } from 'pg'

const connectionString = process.env.DATABASE_URL

if (!connectionString) {
  throw new Error('DATABASE_URL is not configured')
}

export const db = new Pool({
  connectionString,
  max: 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
})

All SQL below uses positional parameters. Never concatenate a request value into SQL.

4. Validate job input

lib/validation.ts

import { z } from 'zod'

export const jobInput = z.object({
  name: z.string().trim().min(1).max(120),
  url: z.string().url().max(2048).refine((value) => {
    const parsed = new URL(value)
    return parsed.protocol === 'http:' || parsed.protocol === 'https:'
  }, 'URL must use HTTP or HTTPS'),
  cronExpression: z.string().trim().min(5).max(100),
})

export type JobInput = z.infer<typeof jobInput>

This deliberately accepts a cron string as text. The worker validates it with node-cron before execution, so malformed schedules fail as a client error instead of becoming permanently stuck rows.

5. Create and list jobs

app/api/jobs/route.ts

import { randomBytes } from 'node:crypto'
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { jobInput } from '@/lib/validation'

export async function GET() {
  try {
    const result = await db.query(
      `SELECT id, name, url, cron_expression, enabled, next_run_at, created_at
       FROM jobs ORDER BY created_at DESC`,
    )
    return NextResponse.json({ jobs: result.rows })
  } catch {
    return NextResponse.json({ error: 'Could not load jobs' }, { status: 500 })
  }
}

export async function POST(request: NextRequest) {
  try {
    const parsed = jobInput.safeParse(await request.json())
    if (!parsed.success) {
      return NextResponse.json({ error: 'Invalid job input', details: parsed.error.flatten() }, { status: 400 })
    }

    const secret = randomBytes(32).toString('hex')
    const result = await db.query(
      `INSERT INTO jobs (name, url, cron_expression, secret, next_run_at)
       VALUES ($1, $2, $3, $4, now())
       RETURNING id, name, url, cron_expression, enabled, next_run_at, secret`,
      [parsed.data.name, parsed.data.url, parsed.data.cronExpression, secret],
    )
    return NextResponse.json({ job: result.rows[0] }, { status: 201 })
  } catch {
    return NextResponse.json({ error: 'Could not create job' }, { status: 500 })
  }
}

The secret is shown only in the creation response. In a real authenticated application, protect both routes with the current user’s session and add an owner_id column to every query.

6. Pause, resume and delete jobs

app/api/jobs/[id]/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'

type Context = { params: Promise<{ id: string }> }

export async function PATCH(request: NextRequest, context: Context) {
  try {
    const { id } = await context.params
    const body = await request.json()
    if (typeof body.enabled !== 'boolean') {
      return NextResponse.json({ error: 'enabled must be boolean' }, { status: 400 })
    }
    const result = await db.query(
      `UPDATE jobs SET enabled = $1, updated_at = now() WHERE id = $2
       RETURNING id, name, url, cron_expression, enabled, next_run_at`,
      [body.enabled, id],
    )
    if (result.rowCount === 0) return NextResponse.json({ error: 'Job not found' }, { status: 404 })
    return NextResponse.json({ job: result.rows[0] })
  } catch {
    return NextResponse.json({ error: 'Could not update job' }, { status: 500 })
  }
}

export async function DELETE(request: NextRequest, context: Context) {
  try {
    const { id } = await context.params
    const result = await db.query('DELETE FROM jobs WHERE id = $1 RETURNING id', [id])
    if (result.rowCount === 0) return NextResponse.json({ error: 'Job not found' }, { status: 404 })
    return new NextResponse(null, { status: 204 })
  } catch {
    return NextResponse.json({ error: 'Could not delete job' }, { status: 500 })
  }
}

The dynamic route uses the Next.js 15 async params API. The route still needs authentication before production use; an ID alone must never authorize a mutation.

7. Implement the worker

worker.ts

import { createHmac } from 'node:crypto'
import { db } from './lib/db'
import cron from 'node-cron'

const pollMs = Number(process.env.WORKER_POLL_MS ?? 5000)
const timeoutMs = Number(process.env.JOB_TIMEOUT_MS ?? 15000)

type Job = { id: string; url: string; cron_expression: string; secret: string }

async function claimJob(): Promise<Job | null> {
  const client = await db.connect()
  try {
    await client.query('BEGIN')
    const result = await client.query(
      `SELECT id, url, cron_expression, secret FROM jobs
       WHERE enabled AND next_run_at <= now()
       AND (lease_until IS NULL OR lease_until < now())
       ORDER BY next_run_at ASC FOR UPDATE SKIP LOCKED LIMIT 1`,
    )
    const job = result.rows[0] as Job | undefined
    if (!job) {
      await client.query('COMMIT')
      return null
    }
    await client.query(
      `UPDATE jobs SET lease_until = now() + interval '2 minutes', updated_at = now() WHERE id = $1`,
      [job.id],
    )
    await client.query('COMMIT')
    return job
  } catch (error) {
    await client.query('ROLLBACK')
    throw error
  } finally {
    client.release()
  }
}

async function runJob(job: Job) {
  const started = Date.now()
  let statusCode: number | null = null
  let error: string | null = null
  try {
    const controller = new AbortController()
    const timeout = setTimeout(() => controller.abort(), timeoutMs)
    const signature = createHmac('sha256', job.secret).update(job.id).digest('hex')
    try {
      const response = await fetch(job.url, {
        method: 'POST',
        headers: { 'content-type': 'application/json', 'x-cron-signature': signature },
        body: JSON.stringify({ jobId: job.id, triggeredAt: new Date().toISOString() }),
        signal: controller.signal,
      })
      statusCode = response.status
      if (!response.ok) error = `Target returned HTTP ${response.status}`
    } finally {
      clearTimeout(timeout)
    }
  } catch (caught) {
    error = caught instanceof Error ? caught.message.slice(0, 500) : 'Request failed'
  }
  await db.query(
    `INSERT INTO job_runs (job_id, finished_at, status_code, duration_ms, success, error)
     VALUES ($1, now(), $2, $3, $4, $5)
     UPDATE jobs SET next_run_at = now() + interval '1 minute', lease_until = NULL, updated_at = now()
     WHERE id = $1`,
    [job.id, statusCode, Date.now() - started, error === null, error],
  )
}

async function loop() {
  for (;;) {
    const job = await claimJob()
    if (job) {
      if (cron.validate(job.cron_expression)) await runJob(job)
      else await db.query('UPDATE jobs SET enabled = false, lease_until = NULL WHERE id = $1', [job.id])
    } else {
      await new Promise((resolve) => setTimeout(resolve, pollMs))
    }
  }
}

loop().catch(async (error) => {
  console.error(error)
  await db.end()
  process.exit(1)
})

Replace the run insert/update with a transaction. The complete transaction version is:

const client = await db.connect()
try {
  await client.query('BEGIN')
  await client.query(
    `INSERT INTO job_runs (job_id, finished_at, status_code, duration_ms, success, error)
     VALUES ($1, now(), $2, $3, $4, $5)`,
    [job.id, statusCode, Date.now() - started, error === null, error],
  )
  await client.query(
    `UPDATE jobs SET next_run_at = now() + ($1::text)::interval, lease_until = NULL, updated_at = now()
     WHERE id = $2`,
    ['1 minute', job.id],
  )
  await client.query('COMMIT')
} catch (error) {
  await client.query('ROLLBACK')
  throw error
} finally {
  client.release()
}

For a production scheduler, calculate the next occurrence from the cron expression rather than using a fixed minute. This MVP keeps the worker mechanics clear; add a cron parser such as cron-parser and store the next UTC occurrence before accepting real customer workloads.

8. Run locally

Create docker-compose.yml:

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: cron
      POSTGRES_PASSWORD: change_me
      POSTGRES_DB: cron
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./migrations:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cron -d cron"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  postgres_data:

Use a local URL in .env when running Node outside Compose:

DATABASE_URL=postgres://cron:change_me@localhost:5432/cron

Start the database and two processes:

docker compose up -d db
npm run dev
npm run worker

Create a test job:

curl -X POST http://localhost:3000/api/jobs \
  -H 'content-type: application/json' \
  -d '{"name":"health check","url":"https://httpbin.org/status/200","cronExpression":"* * * * *"}'
curl http://localhost:3000/api/jobs

For a private endpoint, verify the signature as a hex HMAC of the job ID with the secret returned once at creation. Do not log the secret or include it in a query parameter.

9. Containerize the application

Dockerfile

FROM node:22.12-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:22.12-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22.12-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app ./
EXPOSE 3000
CMD ["npm", "run", "start"]

Add the application and worker to Compose:

services:
  app:
    build: .
    command: npm run start
    env_file: .env
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy

  worker:
    build: .
    command: npm run worker
    env_file: .env
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: cron
      POSTGRES_PASSWORD: change_me
      POSTGRES_DB: cron
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./migrations:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cron -d cron"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  postgres_data:

Do not publish PostgreSQL’s port in production. Put Caddy, Nginx or a cloud load balancer in front of port 3000 and expose only HTTPS.

10. Deploy to a VPS

Install Docker, copy the repository and create a production .env:

git clone https://github.com/your-user/cron-saas.git
cd cron-saas
openssl rand -hex 32

Set DATABASE_URL to the Compose service name db, use a unique database password, and set APP_URL to the HTTPS domain. Then deploy:

docker compose up -d --build
docker compose ps
docker compose logs --tail=100 app worker

The migration directory is initialized only when the database volume is empty. For later schema changes, use versioned migrations and run them through a migration job instead of deleting the volume.

Security considerations

  • Add authentication and an owner_id before exposing job data to multiple customers.
  • Rate-limit job creation, mutation and manual-trigger endpoints.
  • Validate URL schemes and block private IP ranges to mitigate SSRF.
  • Set a short request timeout and cap response-body reads.
  • Rotate job secrets and provide a deliberate secret-regeneration action.
  • Keep DATABASE_URL and secrets out of Git, images and logs.
  • Use HTTPS and secure, HttpOnly session cookies when adding a browser login.
  • Return generic 500 responses; keep stack traces in server logs only.

Backups and reliability

Back up PostgreSQL daily and test restoration monthly:

docker compose exec -T db pg_dump -U cron -d cron > backup.sql
cat backup.sql | docker compose exec -T db psql -U cron -d cron

Run at least two worker replicas only after verifying idempotency. SKIP LOCKED prevents simultaneous claims, while lease_until allows another worker to retry a job after a crashed process. Add exponential retry and a dead-letter state before promising delivery guarantees.

Monitoring

Start with container health and database metrics:

docker compose ps
docker stats --no-stream
docker compose exec db pg_isready -U cron -d cron

Alert on worker restarts, database disk usage, jobs with an expired lease, and a growing count of unsuccessful runs. Add a /api/health endpoint that checks application configuration and PostgreSQL connectivity without exposing database errors.

Cost

Component Required monthly cost
1 GB VPS $5–8
Domain $1–2 averaged monthly
PostgreSQL on the same VPS $0 extra
Minimum $6–10

Optional costs:

  • Managed PostgreSQL: commonly $15–30/month depending on provider and storage.
  • Object storage for logs or exports: usually under $5/month at MVP volume.
  • Transactional email: free tier or roughly $5–15/month.

The estimate excludes taxes and assumes a small workload, one region and no paid support plan. Check provider pricing before purchasing.

Production checklist

  • Authentication and tenant ownership are implemented.
  • URL validation blocks private and metadata IP ranges.
  • HTTPS is active.
  • Database port is private.
  • Job secrets are never logged.
  • Request timeouts and response limits are enforced.
  • Idempotency behavior is documented for customers.
  • Daily backups run automatically.
  • A restore has been tested.
  • Worker, database and disk alerts exist.
  • Rate limiting protects public endpoints.
  • Schema migrations are versioned.

Common problems

Jobs remain due

Check that the worker has the same DATABASE_URL as the API and inspect docker compose logs worker. A stale lease_until should expire after two minutes; an always-future next_run_at means the schedule calculation needs correction.

PostgreSQL connection refused

Inside Compose, use db as the hostname, not localhost. From the host, use localhost only when the port is explicitly published.

The target rejects the request

Confirm it accepts POST requests, the x-cron-signature header is present, and the receiver computes HMAC-SHA256 over the exact job ID string.

A job runs twice

This is possible after a timeout or process crash. Make the target operation idempotent with an event ID and persist that ID on the receiver before applying side effects.

Improvements

  • Calculate exact next occurrences with a cron parser and store everything in UTC.
  • Add manual runs with a per-job rate limit.
  • Add run pagination and retention cleanup.
  • Add tenant billing and execution quotas.
  • Move high-volume execution to a queue after PostgreSQL polling becomes a bottleneck.
  • Add an allowlist mode for customers that do not need arbitrary URLs.

Conclusion

This MVP turns PostgreSQL into a durable schedule and lease store, while Next.js handles configuration and the worker handles outbound execution. It is inexpensive to deploy, easy to inspect and explicit about its at-least-once execution model—the right baseline before adding authentication, billing and scale.

Related guides