Synology DSM Scheduled Task Runs, But Your PHP Script Does Nothing


I set up a nightly job in DSM’s Task Scheduler. The task history said completed, exit code 0. The database it was supposed to update was untouched. Every night, cleanly, successfully, nothing.

What’s actually happening

On a Synology NAS, the PHP that runs your website and the PHP available to a scheduled task are not the same thing.

Web Station serves your site through a specific PHP-FPM profile, with the extensions and php.ini you configured in the DSM UI. A Task Scheduler entry runs as a shell command, using whatever php is on the system path — a different binary, often a different version, with a different (usually much barer) configuration.

So the script starts, hits a missing PDO driver or a missing extension, dies, and — depending on how error output is configured — reports success anyway. Exit code 0 from a task that printed a fatal error to a stream nobody captured looks exactly like a task that worked.

The fix: call it over HTTP instead

The most reliable pattern I’ve found is to stop running PHP from the shell entirely and have the scheduler make a web request. Then the script runs in exactly the environment you already tested, because it is the environment you already tested:

curl -sS --max-time 300 "http://localhost/app/cron.php?key=YOUR_SECRET"

Guard the endpoint so it isn’t publicly triggerable:

<?php
// cron.php — only local calls with the right key may run this
$key = $_GET['key'] ?? '';
if (!hash_equals(getenv('CRON_KEY'), $key)) { http_response_code(403); exit; }
if (($_SERVER['REMOTE_ADDR'] ?? '') !== '127.0.0.1') { http_response_code(403); exit; }

Use hash_equals, not ===, so the comparison doesn’t leak timing information. Read the key from the environment rather than hardcoding it in a file that ends up in a backup.

Make the run leave evidence

Success that produces no output is the reason this went unnoticed for as long as it did. So the job now writes what it actually did, and the write is the last statement — a partial run can’t fake a complete one:

file_put_contents(
    __DIR__ . '/cron-last.txt',
    date('Y-m-d H:i:s') . " ok rows={$updated}\n"
);

Then anything can check it — a dashboard, another machine, you on a Monday:

cat /volume1/web/app/cron-last.txt
# 2026-07-31 03:00:04 ok rows=118

A timestamp older than the interval means the job stopped, whatever DSM’s history claims. This matters more than it sounds: DSM is reporting on whether it launched a process, which is a genuinely different question from whether your work happened. It’s an honest witness testifying about something you didn’t ask.

One master task beats many

A practical note after doing this for a while: DSM’s scheduler is not pleasant to manage once you have a dozen entries, and each one is a separate thing that can be silently disabled.

Register one task that runs every few minutes and hits a single dispatcher endpoint. Let PHP decide what’s due, based on timestamps you control:

// due() reads a state file and returns true at most once per interval
if (due('nightly-cleanup', 86400))  run_cleanup();
if (due('hourly-sync',      3600))  run_sync();

Now your schedule lives in code you can read, version, and test — not in a UI form you’ll forget you filled in eight months ago.

The thing I’d tell myself earlier

“The task completed” and “the work happened” are two different claims, and DSM can only make the first one. I spent a week trusting a green checkmark that was, strictly speaking, telling the truth about something else entirely.

Comments

Loading comments…