Back to Blog
Tutorial11 min read2026-08-01

Automate Database Cleanup with a Scheduled Cronjob

Run a nightly PostgreSQL cleanup task that deletes expired records. Cronjob scheduling with cron syntax, DATABASE_URL injection, and logging for audit trails.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Your PostgreSQL database accumulates soft-deleted records, expired sessions, and outdated analytics events that bloat the table and slow down queries. A cronjob that runs DELETE FROM sessions WHERE expires_at < NOW() every night keeps the database lean without manual intervention.

PandaStack cronjobs are containerized scripts that run on a schedule. You provide a Docker image or a Git repository with a runnable script, configure the cron expression, and the platform executes it in an ephemeral pod. The job gets the same environment variables as your web apps, including DATABASE_URL, so it can connect to the same managed database.

Write the cleanup script

Create a Node.js script that deletes expired records. In your repository, add cleanup.js:

const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: { rejectUnauthorized: false }
});

async function cleanup() {
  try {
    const result = await pool.query(
      'DELETE FROM sessions WHERE expires_at < NOW()'
    );
    console.log(`Deleted ${result.rowCount} expired sessions`);

    const orders = await pool.query(
      'DELETE FROM orders WHERE status = \'cancelled\' AND created_at < NOW() - INTERVAL \'30 days\''
    );
    console.log(`Deleted ${orders.rowCount} old cancelled orders`);

    await pool.end();
    process.exit(0);
  } catch (err) {
    console.error('Cleanup failed:', err);
    process.exit(1);
  }
}

cleanup();

The script deletes expired sessions and old cancelled orders, logs the counts, and exits. The exit code (0 for success, 1 for failure) gets recorded in the cronjob execution history.

Add a package.json:

{
  "name": "db-cleanup",
  "version": "1.0.0",
  "scripts": {
    "start": "node cleanup.js"
  },
  "dependencies": {
    "pg": "^8.11.3"
  }
}

Push to GitHub. The repository is now deployable as a cronjob.

Create the cronjob via the API

Schedule it to run every night at 2 AM:

curl -X POST https://api.pandastack.io/v1/cronjobs \
  -H "Authorization: Bearer $PANDASTACK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "db-cleanup",
    "repositoryName": "acme/db-cleanup",
    "branch": "main",
    "schedule": "0 2 * * *",
    "databaseId": "<your-database-id>"
  }'

The schedule field uses standard cron syntax: minute, hour, day-of-month, month, day-of-week. 0 2 * * * means "run at 02:00 every day."

PandaStack builds a container from the repository, then schedules it as a Kubernetes CronJob. The job pod gets DATABASE_URL injected because you linked it to a database with databaseId.

Verify it runs

Check the execution history in the dashboard (Cronjobs → db-cleanup → Runs). Each run shows:

  • Started at: Timestamp when the pod was created
  • Finished at: Timestamp when the script exited
  • Exit code: 0 (success) or 1 (failure)
  • Logs: stdout and stderr from the script

If the first run shows exit code 1, click Logs to see the error. Common failures: missing DATABASE_URL, wrong table name, or connection timeout.

Use the CLI

Create the cronjob from the command line:

panda login
panda cronjobs create \
  --name db-cleanup \
  --repo acme/db-cleanup \
  --branch main \
  --schedule "0 2 * * *"

Then link the database from the dashboard (Cronjobs → Settings → Database).

Trigger a manual run

Don't wait until 2 AM to test it. Trigger a one-off execution:

curl -X POST https://api.pandastack.io/v1/cronjobs/<cronjob-id>/trigger \
  -H "Authorization: Bearer $PANDASTACK_TOKEN"

Or use the dashboard: Cronjobs → db-cleanup → Trigger Now. The job runs immediately, and you can watch the logs stream in real time.

Schedule syntax examples

  • Every hour: 0 * * * *
  • Every 15 minutes: */15 * * * *
  • Weekdays at 9 AM: 0 9 * * 1-5
  • First day of the month at midnight: 0 0 1 * *
  • Every Sunday at 3 AM: 0 3 * * 0

The platform uses the standard cron format. If you need more complex logic (e.g., "every weekday except holidays"), handle it inside the script by checking the date and exiting early.

Prevent concurrent runs

If a cleanup job takes 20 minutes but the schedule is every 15 minutes, two jobs will run at the same time and potentially conflict. Add a concurrency policy to the cronjob configuration:

{
  "schedule": "*/15 * * * *",
  "concurrencyPolicy": "Forbid"
}

The Forbid policy skips a scheduled run if the previous one is still executing. The alternative is Replace, which kills the running job and starts a new one.

Add Slack notifications

If the cleanup job fails, you want to know immediately. Create a Slack webhook and pass it as an environment variable:

{
  "env": [
    { "name": "SLACK_WEBHOOK_URL", "value": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" }
  ]
}

Update cleanup.js to send a message on failure:

async function notifySlack(message) {
  const response = await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text: message })
  });
  return response.ok;
}

async function cleanup() {
  try {
    // ... cleanup logic
  } catch (err) {
    await notifySlack(`Database cleanup failed: ${err.message}`);
    process.exit(1);
  }
}

Now every failure posts to your Slack channel.

Cleanup for multiple databases

If you have separate databases for production and staging, create two cronjobs:

  1. 1db-cleanup-prod: Links to the production database, runs on the main branch
  2. 2db-cleanup-staging: Links to the staging database, runs on the develop branch

Both use the same repository and script, but get different DATABASE_URL values based on which database they're linked to.

Execution history retention

PandaStack keeps the last 30 runs' logs and metadata. Older runs are purged automatically. If you need long-term audit logs, write them to an external system (Datadog, Elasticsearch, or an S3 bucket) from inside the script.

Debugging a failed run

Exit code 1, no logs: The pod failed to start. Check the cronjob configuration (missing environment variable, wrong repository, invalid schedule syntax).

Logs show ENOTFOUND: The DATABASE_URL is missing. Verify the database is linked in the dashboard.

Runs are skipped: The concurrency policy is Forbid and the previous run is still executing. Either increase the interval or speed up the script.

Timezone confusion: Kubernetes uses UTC for cron schedules. 0 2 * * * is 2 AM UTC, which might be a different hour in your local timezone. Convert the schedule to UTC or document it clearly.

References

  • [Cron syntax reference](https://crontab.guru/)
  • [PandaStack cronjobs](https://docs.pandastack.io/cronjobs)
  • [PostgreSQL time functions](https://www.postgresql.org/docs/current/functions-datetime.html)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also