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_idbefore 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_URLand 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.