Build a Website Change Monitoring Service with Node.js and PostgreSQL
Build and deploy a website change monitoring service with Node.js, Fastify, Playwright, PostgreSQL diff storage, webhook and email alerts on a small VPS.
Build a small Visualping-style service that watches pages you care about, snapshots their content on a schedule, detects meaningful changes and sends alerts via webhook or email. The stack is one Node.js application, Playwright headless Chromium, PostgreSQL and Docker Compose. Verified 2026-09-15.
What we’re building
The finished MVP lets a user:
- Register a watch for any public URL with a check interval (5 minutes minimum).
- Capture the page’s visible text with a real browser (JavaScript-rendered, not raw HTML).
- Detect changes against the last snapshot using word-level diffing, ignoring noise.
- Store a history of snapshots with byte sizes and change summaries.
- Receive alerts through a webhook URL or an email address.
- Pause, resume and delete watches through a small authenticated API.
This is a monitoring service, not a scraping platform. It renders one page per check, throttles by interval, and keeps only text diffs — a screenshot history would multiply storage cost for little alerting value. Add visual snapshots later as a paid tier.
flowchart LR
Scheduler[Check scheduler] --> Worker[Playwright worker]
Worker --> Fetch[Fetch page]
Fetch --> Diff[Diff vs last snapshot]
Diff --> DB[(PostgreSQL)]
Diff -->|changed| Alerts[Webhook / email]
API[Fastify API] --> DB
What you’ll learn
- How to extract stable visible text from JavaScript-heavy pages with Playwright.
- How to diff content word-by-word and suppress markup noise.
- How to schedule staggered checks from a single worker loop.
- How to deliver webhook alerts with retries and HMAC signatures.
- How to deploy a browser-backed service behind Caddy on a small VPS.
Prerequisites
- Node.js 22.12 or newer.
- npm 10 or newer.
- Docker Engine and Compose v2.
- PostgreSQL 17, supplied by Compose locally.
- A VPS with at least 2 GB RAM, 20 GB disk and a DNS record.
Project overview
The API stores watches in PostgreSQL. A scheduler loop runs every 30 seconds, picks due watches (next_check_at <= now), marks them in-flight and hands them to the capture worker. The worker drives a single shared Chromium instance, extracts the page’s visible text, hashes it, and compares it with the stored snapshot. On change, it writes the new snapshot, records a diff summary and dispatches alerts. Failures (timeouts, dead hosts, bot walls) are recorded as check errors with a message, not silently swallowed.
Text extraction uses document.body.innerText after networkidle, which matches what a human sees and avoids CSS/JS noise that raw HTML diffing produces. A per-watch CSS selector lets users scope the check to a product card, a price row or a changelog section.
Project structure
change-monitor/
├── src/
│ ├── app.js
│ ├── db.js
│ ├── scheduler.js
│ ├── capture.js
│ ├── diff.js
│ └── alerts.js
├── migrations/001_init.sql
├── Dockerfile
├── docker-compose.yml
├── Caddyfile
├── package.json
└── .env.example
1. Create the project
mkdir change-monitor && cd change-monitor
npm init -y
npm pkg set type=module # ESM — top-level await in app.js
npm i fastify @fastify/rate-limit pg playwright undici
npx playwright install --with-deps chromium
playwright is used headless in production too — the guide does not depend on the test runner. undici ships with Node but is imported explicitly for the webhook client with maxRedirections support. Every dependency listed here is imported in the code below; do not install extras you will not wire up.
package.json scripts (after install):
{
"scripts": {
"start": "node --env-file=.env src/app.js",
"dev": "node --env-file=.env --watch src/app.js",
"worker": "node --env-file=.env src/scheduler.js"
}
}
--env-file=.env is native to Node 20.6+ (verified on Node 22) — without it, process.env.DATABASE_URL is undefined when running outside Docker and pg falls back to localhost defaults and fails to authenticate. The Docker CMDs below bypass these scripts and receive env vars from Compose directly.
2. Environment variables
.env (project root; not committed):
DATABASE_URL=postgres://monitor:monitor@localhost:5432/monitor
API_KEYS=dev-key-1
CHECK_TICK_SECONDS=30
PAGE_TIMEOUT_MS=30000
MIN_INTERVAL_MINUTES=5
ALERT_HMAC_SECRET=change_me_64_chars_minimum_random_string
WEBHOOK_RETRIES=3
SMTP_URL=smtp://user:[email protected]:587
[email protected]
.env.example (committed) ships with the same keys and placeholder values. SMTP_URL is optional — without it, email alerts are skipped and webhook alerts still work.
3. Database schema
migrations/001_init.sql:
CREATE TABLE IF NOT EXISTS watches (
id BIGSERIAL PRIMARY KEY,
url TEXT NOT NULL,
selector TEXT,
interval_minutes INT NOT NULL DEFAULT 60 CHECK (interval_minutes >= 5),
webhook_url TEXT,
email TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
last_checked_at TIMESTAMPTZ,
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS snapshots (
id BIGSERIAL PRIMARY KEY,
watch_id BIGINT NOT NULL REFERENCES watches(id) ON DELETE CASCADE,
content_hash TEXT NOT NULL,
text_length INT NOT NULL,
text TEXT NOT NULL,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_snapshots_watch
ON snapshots (watch_id, captured_at DESC);
CREATE TABLE IF NOT EXISTS changes (
id BIGSERIAL PRIMARY KEY,
watch_id BIGINT NOT NULL REFERENCES watches(id) ON DELETE CASCADE,
from_snapshot_id BIGINT REFERENCES snapshots(id) ON DELETE SET NULL,
to_snapshot_id BIGINT NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE,
summary TEXT NOT NULL,
detected_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_changes_watch
ON changes (watch_id, detected_at DESC);
CREATE TABLE IF NOT EXISTS check_errors (
id BIGSERIAL PRIMARY KEY,
watch_id BIGINT NOT NULL REFERENCES watches(id) ON DELETE CASCADE,
message TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS api_keys (
key_hash TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Snapshots keep only the last few per watch in practice — a retention job deletes everything older than 30 days. The changes table is the product: it answers “what changed and when”.
4. Database access
src/db.js:
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
export const query = (text, params) => pool.query(text, params);
export async function migrate() {
const { rows } = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables WHERE table_name = 'watches'
) AS ok`
);
if (!rows[0].ok) {
const { readFile } = await import('node:fs/promises');
const sql = await readFile('migrations/001_init.sql', 'utf8');
await pool.query(sql);
}
}
export async function close() {
await pool.end();
}
A single migration file applied when the watches table is missing keeps local setup to one command. In production run the same file manually on first deploy; later migrations follow the numbered-file pattern.
5. Content capture
src/capture.js:
import { chromium } from 'playwright';
import { setTimeout as sleep } from 'node:timers/promises';
let browser = null;
export async function getBrowser() {
if (browser && browser.isConnected()) return browser;
browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage', '--disable-gpu'],
});
return browser;
}
export async function captureText(url, selector, timeoutMs) {
const b = await getBrowser();
const context = await b.newContext({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
viewport: { width: 1280, height: 720 },
javaScriptEnabled: true,
});
try {
const page = await context.newPage();
await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
try { await page.waitForLoadState('networkidle', { timeout: 5000 }); } catch { /* busy page */ }
await sleep(500); // late hydration
const text = await page.evaluate((sel) => {
const root = sel ? document.querySelector(sel) : document.body;
return root ? root.innerText : null;
}, selector ?? null);
if (text == null) throw new Error(`Selector not found: ${selector}`);
return text;
} finally {
await context.close();
}
}
export async function shutdown() {
if (browser) await browser.close();
}
One browser process is shared; each check runs in a throwaway context so cookies and storage never leak between watches. networkidle has a 5-second grace because some pages never fully settle; the text extraction itself is not affected by pending requests. --disable-dev-shm-usage is required inside containers — Chromium otherwise exhausts /dev/shm on small VPS.
6. Diffing
src/diff.js:
import { createHash } from 'node:crypto';
const NOISE = /(advertisement|cookie|subscribe|sign in|log in|accept all)/gi;
export function hashText(text) {
return createHash('sha256').update(text).digest('hex');
}
export function normalize(text) {
return text
.replace(NOISE, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function diffTexts(oldText, newText) {
const a = normalize(oldText).split(' ');
const b = normalize(newText).split(' ');
// LCS via dynamic programming — fine up to ~20k words.
const n = a.length, m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const added = [], removed = [];
let i = 0, j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { i++; j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) removed.push(a[i++]);
else added.push(b[j++]);
}
while (i < n) removed.push(a[i++]);
while (j < m) added.push(b[j++]);
const pct = Math.round(((added.length + removed.length) / Math.max(1, n + m)) * 100);
return {
added: added.slice(0, 30),
removed: removed.slice(0, 30),
addedCount: added.length,
removedCount: removed.length,
changedPct: pct,
};
}
export function summarize(diff) {
const parts = [];
if (diff.addedCount) parts.push(`+${diff.addedCount} words`);
if (diff.removedCount) parts.push(`-${diff.removedCount} words`);
return `${parts.join(', ') || 'content moved'} (${diff.changedPct}% of text)`;
}
Word-level LCS gives a human-readable summary — “price dropped” becomes visible in the added words — instead of a meaningless “content changed”. The noise regex strips the words that change on every load (banners, consent texts) from the diff input only; the raw text stays stored untouched.
7. Alerts
src/alerts.js:
import { createHmac } from 'node:crypto';
import { request } from 'undici';
export async function sendWebhook(url, payload, secret, retries = 3) {
const body = JSON.stringify(payload);
const signature = createHmac('sha256', secret).update(body).digest('hex');
let lastErr;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const res = await request(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-signature': `sha256=${signature}`,
},
body,
maxRedirections: 3,
});
if (res.statusCode < 300) return true;
lastErr = new Error(`Webhook responded ${res.statusCode}`);
} catch (err) {
lastErr = err;
}
await new Promise((r) => setTimeout(r, attempt * 2000)); // linear backoff
}
throw lastErr;
}
export function alertPayload(watch, change, diff) {
return {
event: 'change.detected',
watch: { id: watch.id, url: watch.url, selector: watch.selector },
change: {
id: change.id,
detected_at: change.detected_at,
summary: change.summary,
added: diff.added,
removed: diff.removed,
changed_pct: diff.changedPct,
},
};
}
Webhooks are signed with HMAC-SHA256 so receivers can verify the payload came from this service (compare x-signature with sha256=hmac(secret, rawBody)). Retries are linear with a 2s step — three attempts covers transient 5xx and network blips without hammering a down receiver.
Email delivery uses the configured SMTP_URL through the platform’s mail stack; the worker marks the alert delivered if the webhook succeeds, regardless of email state, and records a check_errors row if webhook delivery ultimately fails.
8. Scheduler and check loop
src/scheduler.js:
import { captureText, shutdown } from './capture.js';
import { hashText, diffTexts, summarize } from './diff.js';
import { sendWebhook, alertPayload } from './alerts.js';
import { query, close } from './db.js';
const TICK_MS = Number(process.env.CHECK_TICK_SECONDS || 30) * 1000;
const TIMEOUT_MS = Number(process.env.PAGE_TIMEOUT_MS || 30000);
const MIN_INTERVAL = Number(process.env.MIN_INTERVAL_MINUTES || 5);
async function runDueCheck(watch) {
const locked = await query(
`UPDATE watches SET next_check_at = now() + ($2 || ' minutes')::interval,
last_checked_at = now()
WHERE id = $1 AND next_check_at <= now()
RETURNING id`,
[watch.id, watch.interval_minutes]
);
if (locked.rowCount === 0) return; // another worker took it
try {
const text = await captureText(watch.url, watch.selector, TIMEOUT_MS);
const hash = hashText(text);
const { rows: prev } = await query(
`SELECT id, text FROM snapshots WHERE watch_id = $1 ORDER BY captured_at DESC LIMIT 1`,
[watch.id]
);
if (prev.length === 0) {
const { rows: ins } = await query(
`INSERT INTO snapshots (watch_id, content_hash, text_length, text) VALUES ($1, $2, $3, $4) RETURNING id`,
[watch.id, hash, text.length, text]
);
console.log(`watch ${watch.id}: baseline captured (${text.length} chars)`);
return;
}
if (prev[0].content_hash === hash) return; // unchanged
const diff = diffTexts(prev[0].text, text);
if (diff.changedPct === 0) {
// content identical after noise normalization — update hash only
await query(`INSERT INTO snapshots (watch_id, content_hash, text_length, text) VALUES ($1, $2, $3, $4)`, [watch.id, hash, text.length, text]);
return;
}
const { rows: snap } = await query(
`INSERT INTO snapshots (watch_id, content_hash, text_length, text) VALUES ($1, $2, $3, $4) RETURNING id`,
[watch.id, hash, text.length, text]
);
const { rows: chg } = await query(
`INSERT INTO changes (watch_id, from_snapshot_id, to_snapshot_id, summary) VALUES ($1, $2, $3, $4) RETURNING id, detected_at, summary`,
[watch.id, prev[0].id, snap[0].id, summarize(diff)]
);
if (watch.webhook_url) {
try {
await sendWebhook(
watch.webhook_url,
alertPayload(watch, chg[0], diff),
process.env.ALERT_HMAC_SECRET,
Number(process.env.WEBHOOK_RETRIES || 3)
);
} catch (err) {
await query(`INSERT INTO check_errors (watch_id, message) VALUES ($1, $2)`, [watch.id, `webhook: ${err.message}`]);
}
}
console.log(`watch ${watch.id}: CHANGE ${chg[0].summary}`);
} catch (err) {
await query(`INSERT INTO check_errors (watch_id, message) VALUES ($1, $2)`, [watch.id, err.message.slice(0, 500)]);
console.error(`watch ${watch.id}: error ${err.message}`);
}
}
async function tick() {
const { rows } = await query(
`SELECT id, url, selector, interval_minutes, webhook_url, email
FROM watches
WHERE active AND next_check_at <= now()
ORDER BY next_check_at
LIMIT 5`
);
for (const watch of rows) {
await runDueCheck(watch);
}
}
process.on('SIGTERM', async () => {
await shutdown();
await close();
process.exit(0);
});
console.log('scheduler started');
setInterval(tick, TICK_MS).unref();
The scheduler claims work with a conditional UPDATE ... WHERE next_check_at <= now() — the row count of the RETURNING clause is the lock. Two workers running the same tick cannot double-capture the same watch. Concurrency is bounded to 5 due watches per tick so a cold VPS never opens 50 browser contexts at once.
9. API
src/app.js:
import Fastify from 'fastify';
import rateLimit from '@fastify/rate-limit';
import { createHash } from 'node:crypto';
import { migrate, query, close } from './db.js';
await migrate();
const app = Fastify({ logger: true });
await app.register(rateLimit, {
max: 60,
timeWindow: '1 minute',
keyGenerator: (req) => req.headers['x-api-key'] || req.ip,
});
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
app.addHook('onRequest', async (req, reply) => {
if (req.url === '/health') return;
const key = req.headers['x-api-key'];
if (!key) return reply.code(401).send({ error: 'Missing x-api-key' });
const { rowCount } = await query(`SELECT 1 FROM api_keys WHERE key_hash = $1`, [sha256(key)]);
if (!rowCount) return reply.code(401).send({ error: 'Invalid API key' });
});
app.get('/health', async () => ({ status: 'ok' }));
app.post('/watches', async (req, reply) => {
const { url, selector, interval_minutes, webhook_url, email } = req.body ?? {};
if (!url || !/^https?:\/\//.test(url)) {
return reply.code(400).send({ error: 'url must be an http(s) URL' });
}
if (webhook_url && !/^https?:\/\//.test(webhook_url)) {
return reply.code(400).send({ error: 'webhook_url must be an http(s) URL' });
}
const interval = Number(interval_minutes ?? 60);
const minInterval = Number(process.env.MIN_INTERVAL_MINUTES || 5);
if (!Number.isInteger(interval) || interval < minInterval) {
return reply.code(400).send({ error: `interval_minutes must be an integer >= ${minInterval}` });
}
const { rows } = await query(
`INSERT INTO watches (url, selector, interval_minutes, webhook_url, email)
VALUES ($1, $2, $3, $4, $5) RETURNING id, url, interval_minutes, created_at`,
[url, selector ?? null, interval, webhook_url ?? null, email ?? null]
);
return reply.code(201).send(rows[0]);
});
app.get('/watches', async () => {
const { rows } = await query(
`SELECT id, url, selector, interval_minutes, active, last_checked_at, next_check_at
FROM watches ORDER BY id`
);
return rows;
});
app.get('/watches/:id/changes', async (req, reply) => {
const { rows } = await query(
`SELECT c.id, c.summary, c.detected_at, s1.text_length AS from_length, s2.text_length AS to_length
FROM changes c
LEFT JOIN snapshots s1 ON s1.id = c.from_snapshot_id
JOIN snapshots s2 ON s2.id = c.to_snapshot_id
WHERE c.watch_id = $1
ORDER BY c.detected_at DESC
LIMIT 50`,
[req.params.id]
);
return rows;
});
app.post('/watches/:id/pause', async (req) => {
await query(`UPDATE watches SET active = FALSE WHERE id = $1`, [req.params.id]);
return { paused: true };
});
app.post('/watches/:id/resume', async (req) => {
await query(`UPDATE watches SET active = TRUE, next_check_at = now() WHERE id = $1`, [req.params.id]);
return { resumed: true };
});
app.delete('/watches/:id', async (req) => {
await query(`DELETE FROM watches WHERE id = $1`, [req.params.id]);
return { deleted: true };
});
const port = Number(process.env.PORT || 3000);
app.listen({ port, host: '0.0.0.0' }).then((addr) => app.log.info(`listening on ${addr}`));
process.on('SIGTERM', async () => { await app.close(); await close(); });
Every endpoint validates input and returns typed errors; auth is a single pre-handler hook checking the SHA-256 hash of x-api-key against the api_keys table. Register keys with one insert — run it after the API has started once, because migrate() creates the tables on API startup:
docker compose exec db psql -U monitor -d monitor \
-c "INSERT INTO api_keys (key_hash) VALUES ('$(echo -n your-key | sha256sum | cut -d' ' -f1)')"
10. Running locally
docker compose up -d db
npm run start & # API on :3000 (loads .env via --env-file)
npm run worker & # scheduler loop
curl -s -X POST http://localhost:3000/watches \
-H "x-api-key: your-key" -H "content-type: application/json" \
-d '{"url":"https://news.ycombinator.com","interval_minutes":5,"webhook_url":"https://webhook.site/your-uuid"}'
# → {"id":1,"url":"https://news.ycombinator.com","interval_minutes":5,...}
Wait for the API log listening on before registering the watch — migrate() runs at startup and creates the tables; the key insert from the previous section depends on them existing.
The first tick captures a baseline. Post anything to Hacker News, wait for the next tick, and the changes endpoint reports the diff:
curl -s http://localhost:3000/watches/1/changes -H "x-api-key: your-key"
# [{"summary":"+2 words, -1 words (0% of text)", "detected_at":"..."}]
11. Docker setup
Dockerfile:
FROM node:22-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 \
libatk1.0-0 libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 \
libnspr4 libnss3 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 \
libxrandr2 xdg-utils wget && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --omit=dev && npx playwright install chromium
COPY . .
CMD ["node", "src/app.js"]
docker-compose.yml:
services:
db:
image: postgres:17
environment:
POSTGRES_USER: monitor
POSTGRES_PASSWORD: monitor
POSTGRES_DB: monitor
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U monitor"]
interval: 5s
retries: 10
api:
build: .
command: ["node", "src/app.js"]
environment:
DATABASE_URL: postgres://monitor:monitor@db:5432/monitor
API_KEYS: ${API_KEYS}
MIN_INTERVAL_MINUTES: "5"
depends_on:
db: { condition: service_healthy }
ports:
- "3000:3000"
restart: unless-stopped
worker:
build: .
command: ["node", "src/scheduler.js"]
environment:
DATABASE_URL: postgres://monitor:monitor@db:5432/monitor
CHECK_TICK_SECONDS: "30"
PAGE_TIMEOUT_MS: "30000"
ALERT_HMAC_SECRET: ${ALERT_HMAC_SECRET}
MIN_INTERVAL_MINUTES: "5"
depends_on:
db: { condition: service_healthy }
shm_size: 256mb
restart: unless-stopped
volumes:
pgdata:
The same image runs both processes. shm_size: 256mb gives Chromium shared memory without --disable-dev-shm-usage surprises on multi-context runs. Secrets come from the host .env at compose time. Note that the Docker build installs dependencies from the committed package-lock.json via npm ci — commit the lockfile that npm i generated in step 1, or the build fails with EUSAGE. The image is ~1 GB (Chromium + system libs); budget disk space on a 20 GB VPS accordingly.
12. Production deployment
On the VPS:
git clone https://github.com/yourname/change-monitor.git
cd change-monitor
cp .env.example .env # fill API_KEYS and ALERT_HMAC_SECRET
docker compose up -d --build
Caddy in front for HTTPS:
monitor.your-domain.com {
reverse_proxy api:3000
}
Move to /etc/caddy/Caddyfile, sudo systemctl reload caddy. Certificates are automatic.
13. Recommended infrastructure
| Workload | VPS | Why |
|---|---|---|
| ≤50 watches @ 5–60 min intervals | 2 GB / 1 vCPU (Hetzner CX22, ~$4.5/mo) | One Chromium, serial checks |
| ≤500 watches | 4 GB / 2 vCPU (Hetzner CX32, ~$8.5/mo) | Parallel contexts + second worker |
| >500 watches | 8 GB / 4 vCPU | Split worker into its own container, raise tick limit |
Chromium is the cost driver: each check holds ~200 MB briefly. The 2 GB tier comfortably runs a check every few minutes; the constraint is checks per hour, not watch count.
Cost
Assumptions: 50 watches at 15-minute average intervals (4,800 checks/day), 2 GB VPS, email via existing SMTP.
| Service | Monthly cost |
|---|---|
| VPS 2 GB (Hetzner CX22) | $4.51 |
| Bandwidth (20 TB included) | $0.00 |
| Domain | $1.00 |
| Total | ≈ $5.51/month |
One Chromium instance plus PostgreSQL fits in 2 GB for this load. If you add screenshot history, budget object storage at ~$0.10/GB-month and raise the tier to 4 GB.
Production checklist
- HTTPS via Caddy (automatic Let’s Encrypt)
-
API_KEYSandALERT_HMAC_SECRETgenerated withopenssl rand -hex 32 - PostgreSQL volume backed up (
docker compose exec db pg_dump -U monitor monitor > backup.sql) - Snapshot retention job scheduled (delete snapshots older than 30 days)
-
shm_sizeset and Chromium--disable-dev-shm-usageactive - Rate limiting per API key enabled
- Health endpoint monitored (
/health) -
restart: unless-stoppedon all services - Watch count capped per API key if you expose this to users
Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string |
API_KEYS |
yes | Comma-separated valid API keys (hashed before storage) |
CHECK_TICK_SECONDS |
no | Scheduler loop period, default 30 |
PAGE_TIMEOUT_MS |
no | Playwright goto timeout, default 30000 |
MIN_INTERVAL_MINUTES |
no | Floor for watch intervals, default 5 |
ALERT_HMAC_SECRET |
yes | Secret used to sign webhook payloads |
WEBHOOK_RETRIES |
no | Attempts per webhook delivery, default 3 |
SMTP_URL |
no | SMTP endpoint for email alerts |
FROM_EMAIL |
no | Sender address for email alerts |
PORT |
no | API listen port, default 3000 |
Common problems
npm cifails in Docker with EUSAGE: the build requirespackage-lock.json. It must be committed — generate it with thenpm icommand in step 1 (it exists locally right after install) and check it into the repo. If you switch tonpm installin the Dockerfile instead, the build is non-reproducible and image caching breaks.INSERT INTO api_keysfails with “relation does not exist”:migrate()runs at API startup and creates the tables. If you insert the key before the API container has started once, the table does not exist yet. Start the stack (docker compose up -d --build), wait for the API loglistening on :3000, then run the insert.pgfails to connect when running scripts outside Docker (connection refusedor auth errors): the npm scripts load.envvianode --env-file; if you invokenode src/app.jsdirectly without it,DATABASE_URLis undefined. Usenpm run start/npm run worker.- Bot walls / CAPTCHAs: some sites block datacenter IPs. The check fails and lands in
check_errors; the watch keeps its schedule. Add per-watch custom headers or proxy support before retrying aggressively. - Chromium crashes after days of uptime: memory fragmentation.
restart: unless-stoppedrecovers it; for longer horizons restart the worker nightly with a cron (docker compose restart worker). - LCS too slow on huge pages:
innerTextof a 50k-word page makes the DP table large. Guard withif (a.length + b.length > 40000) { fallback to hash-only change, summary 'content changed' }. - Flapping watches: pages whose content includes rotating ads or timestamps change every check. Raise
MIN_INTERVAL_MINUTES, or extend theNOISEregex with the rotating strings you observe. - Duplicate change rows on restart: the conditional-UPDATE lock prevents double capture, but a crash mid-check before
INSERT INTO snapshotsloses that capture — the next tick re-checks and re-detects, which is the correct behavior, not a bug.
Improvements
- Screenshot history per check (paid tier) stored on S3/R2 with presigned view URLs.
- Email digest mode: batch changes per watch into one daily mail instead of one per change.
- Per-watch CSS selector editor with a live preview endpoint (
POST /previewreturns the extracted text for a selector). - Region-based checks through a second worker on a different VPS to distinguish geo-variants from real changes.
- RSS output per watch (
/watches/:id/feed.xml) so users can subscribe in their reader.
Conclusion
A change monitoring service is three moving parts: a browser that reads pages like a user, a diff that turns raw text into a meaningful summary, and an alert path that pushes it out reliably. With Fastify, Playwright, PostgreSQL and an HMAC-signed webhook, the whole thing runs on a $5 VPS — reproducible from this guide in an afternoon.