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.

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_KEYS and ALERT_HMAC_SECRET generated with openssl 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_size set and Chromium --disable-dev-shm-usage active
  • Rate limiting per API key enabled
  • Health endpoint monitored (/health)
  • restart: unless-stopped on 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 ci fails in Docker with EUSAGE: the build requires package-lock.json. It must be committed — generate it with the npm i command in step 1 (it exists locally right after install) and check it into the repo. If you switch to npm install in the Dockerfile instead, the build is non-reproducible and image caching breaks.
  • INSERT INTO api_keys fails 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 log listening on :3000, then run the insert.
  • pg fails to connect when running scripts outside Docker (connection refused or auth errors): the npm scripts load .env via node --env-file; if you invoke node src/app.js directly without it, DATABASE_URL is undefined. Use npm 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-stopped recovers it; for longer horizons restart the worker nightly with a cron (docker compose restart worker).
  • LCS too slow on huge pages: innerText of a 50k-word page makes the DP table large. Guard with if (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 the NOISE regex 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 snapshots loses 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 /preview returns 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.

Related guides