Back to Blog
Tutorial11 min read2026-08-02

Schedule Database Cleanup with a Node.js Cronjob

Deploy a scheduled Node.js script that deletes expired database rows every night. Cron syntax, DATABASE_URL injection, execution logs, and how to test a cronjob without waiting 24 hours.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Databases accumulate expired sessions, old logs, and soft-deleted records that should be purged periodically. You could run a cleanup script manually, but you'll forget. You could add it to your API server's startup code, but then it runs every time the server restarts, wasting CPU. Cronjobs are the right tool: schedule a script to run at 2 AM every night, delete expired rows, log what happened, and exit.

PandaStack cronjobs are standalone containers that run on a schedule, have access to the same environment variables as your apps (like DATABASE_URL), and shut down after completing. This guide builds a Node.js script that deletes expired sessions from a PostgreSQL database, deploys it as a cronjob that runs nightly, and shows how to manually trigger it for testing.

The cleanup script

Create a directory for the cronjob:

mkdir database-cleanup && cd database-cleanup
npm init -y
npm install pg

Create cleanup.js:

import pg from 'pg'
const { Client } = pg

async function cleanup() {
  const databaseUrl = process.env.DATABASE_URL
  if (!databaseUrl) {
    console.error('DATABASE_URL not set')
    process.exit(1)
  }

  const client = new Client({ connectionString: databaseUrl })

  try {
    await client.connect()
    console.log('Connected to database')

    // Delete sessions older than 30 days
    const sessionResult = await client.query(
      `DELETE FROM sessions WHERE updated_at < NOW() - INTERVAL '30 days'`
    )
    console.log(`Deleted ${sessionResult.rowCount} expired sessions`)

    // Delete soft-deleted users older than 90 days
    const userResult = await client.query(
      `DELETE FROM users WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '90 days'`
    )
    console.log(`Deleted ${userResult.rowCount} soft-deleted users`)

    console.log('Cleanup complete')
  } catch (err) {
    console.error('Cleanup failed:', err)
    process.exit(1)
  } finally {
    await client.end()
  }
}

cleanup()

Update package.json:

{
  "name": "database-cleanup",
  "type": "module",
  "scripts": {
    "start": "node cleanup.js"
  },
  "dependencies": {
    "pg": "^8.11.0"
  }
}

The script connects to Postgres via DATABASE_URL, deletes rows matching the cleanup criteria, logs the counts, and exits. If the database connection fails or a query errors, the script exits with code 1 (which PandaStack logs as a failed cronjob run).

Test it locally with a test database:

export DATABASE_URL="postgresql://user:pass@localhost:5432/testdb"
npm start

You should see:

Connected to database
Deleted 0 expired sessions
Deleted 0 soft-deleted users
Cleanup complete

Push to GitHub:

git init
git add .
git commit -m "Add database cleanup cronjob"
git remote add origin https://github.com/yourname/database-cleanup.git
git push -u origin main

Deploy as a cronjob via the dashboard

Go to https://dashboard.pandastack.io/cronjobs and click New Cronjob. Fill in:

  • Name: database-cleanup
  • Repository: yourname/database-cleanup
  • Branch: main
  • Schedule: 0 2 * * * (2 AM UTC every day)
  • Start command: npm start

The schedule uses standard cron syntax:

* * * * *
│ │ │ │ │
│ │ │ │ └─── Day of week (0-7, Sunday = 0 or 7)
│ │ │ └───── Month (1-12)
│ │ └─────── Day of month (1-31)
│ └───────── Hour (0-23)
└─────────── Minute (0-59)

Examples:

  • 0 2 * * * — 2 AM every day
  • */15 * * * * — Every 15 minutes
  • 0 0 * * 0 — Midnight every Sunday
  • 0 3 1 * * — 3 AM on the first of every month

Click Create. PandaStack builds the cronjob container but doesn't run it yet — the first execution happens at the next scheduled time (2 AM the following day).

Link the database

In the cronjob's Settings tab, find the Databases section and click Link Database. Select your Postgres database. PandaStack injects DATABASE_URL as an environment variable. The next scheduled run will use this connection string.

Manually trigger the cronjob (for testing)

You don't want to wait until 2 AM to verify the script works. In the cronjob's Runs tab, click Trigger Now. PandaStack starts the container immediately, runs npm start, captures the logs, and shuts down the container.

Check the Logs tab:

Connected to database
Deleted 3 expired sessions
Deleted 0 soft-deleted users
Cleanup complete

The script ran successfully. Future runs will execute on the schedule.

Deploy via the API

For CI or scripting, create the cronjob via the API:

curl -X POST https://api.pandastack.io/v1/cronjobs \
  -H "Authorization: Bearer psk_live_your_token_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "database-cleanup",
    "repositoryName": "yourname/database-cleanup",
    "branch": "main",
    "schedule": "0 2 * * *",
    "startCommand": "npm start",
    "databaseId": "db-abc123"
  }'

The databaseId field links the database, so DATABASE_URL is set from the start. Get the database ID via:

curl -H "Authorization: Bearer psk_live_your_token_here" \
  https://api.pandastack.io/v1/databases

Deploy via the CLI

If you prefer the CLI:

panda cronjobs create database-cleanup \
  --repo yourname/database-cleanup \
  --schedule "0 2 * * *" \
  --start "npm start"

Then link the database via the dashboard or API.

Execution history and failure alerts

Every cronjob run is logged with:

  • Start time and duration
  • Exit code (0 = success, non-zero = failure)
  • stdout/stderr logs

If a run fails (exit code 1), PandaStack can send an alert via email, Slack, or webhook. Configure this in the cronjob's Settings → Alerts tab. For example, send a Slack message if the cleanup script fails:

  1. 1Create a Slack incoming webhook URL
  2. 2Add it to the cronjob's alert settings
  3. 3Choose "On Failure" as the trigger
  4. 4The next failed run sends a Slack message with the error logs

What breaks and how to fix it

Cronjob runs but does nothing: The DATABASE_URL is unset. Check that the database is linked in the cronjob settings, and verify the variable via panda cronjobs env database-cleanup.

Script times out: The default cronjob timeout is 10 minutes. If your cleanup query takes longer (massive table, no indexes), it times out. Either add indexes to speed up the DELETE query, or run the cleanup in batches:

let deleted = 0
let batchSize = 0
do {
  const result = await client.query(
    `DELETE FROM sessions WHERE id IN (
      SELECT id FROM sessions WHERE updated_at < NOW() - INTERVAL '30 days' LIMIT 10000
    )`
  )
  batchSize = result.rowCount
  deleted += batchSize
} while (batchSize > 0)
console.log(`Deleted ${deleted} sessions in batches`)

Cronjob doesn't run at the scheduled time: Check the schedule syntax via panda cronjobs info database-cleanup. If the schedule is correct but the cronjob still doesn't run, check the Runs tab for error messages. A common issue: the container failed to start because dependencies didn't install (missing package.json or broken npm install).

Database connection pool exhausted: The cleanup script opens a connection and holds it for the duration of the query. If the query takes 5 minutes and you're running other apps that use the same database, you might hit the connection limit. Close the connection as soon as the cleanup finishes (the finally block in the script does this).

Choosing the schedule

Cronjob schedules use the server's timezone (UTC). If you want the script to run at 2 AM local time, convert to UTC. For example, PST (UTC-8) means 2 AM PST is 10 AM UTC:

0 10 * * *

For a cronjob that should run "every night at 2 AM regardless of timezone," use UTC and let users know in the docs.

Cleanup strategies for different workloads

Sessions: Delete rows older than the session TTL (e.g., 30 days). If you're using Redis for sessions, this is automatic — Redis expires keys via TTL. For SQL sessions, a nightly cronjob is simpler than triggers or partitions.

Logs: Partition the table by month and drop old partitions:

DROP TABLE logs_2025_01;

This is instant (no DELETE scan), but requires table partitioning setup.

Soft-deleted records: Keep them for 90 days (for recovery), then hard-delete. The cronjob script above does this with deleted_at < NOW() - INTERVAL '90 days'.

Audit logs: Never delete — archive to object storage instead. A cronjob exports old rows to S3 and deletes them from Postgres:

const rows = await client.query(`SELECT * FROM audit_logs WHERE created_at < NOW() - INTERVAL '1 year'`)
await uploadToS3(rows.rows)
await client.query(`DELETE FROM audit_logs WHERE created_at < NOW() - INTERVAL '1 year'`)

Comparing cronjobs to scheduled tasks in the app

Why deploy a separate cronjob instead of using node-cron or setInterval in your API server?

AspectCronjob containerIn-app scheduler
IsolationRuns in its own containerShares resources with the API
ScalingOne instance, guaranteedRuns on every API instance (need leader election)
LogsCentralized per runMixed with API logs
Failure handlingExplicit exit codes, alertsSilent failures unless you add monitoring

For cleanup tasks that should run once on a schedule, cronjobs are clearer. For tasks that must run every few seconds (like a queue poller), an in-app scheduler or a dedicated worker container is better.

Next steps

You've deployed a Node.js cronjob that deletes expired database rows on a schedule, linked a Postgres database so DATABASE_URL is injected automatically, and triggered the job manually for testing. The same pattern works for:

  • Sending weekly summary emails to users
  • Regenerating sitemap.xml nightly for a content site
  • Syncing data from an external API every hour
  • Running database backups (though PandaStack's managed databases include automatic backups)

For Python cronjobs, swap npm start for python cleanup.py. For Go, compile a binary and run it. The cronjob container supports any language your app uses.

References

  • [Cron syntax reference](https://crontab.guru/)
  • [PandaStack cronjobs guide](https://docs.pandastack.io/cronjobs)
  • [PostgreSQL DELETE performance](https://www.postgresql.org/docs/current/dml-delete.html)
  • [Node.js pg library](https://node-postgres.com/)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also