024 —Workflow
Cron jobs over FTP: auditing a server you just inherited
A site lands in your lap with an FTP credential and a database dump. Somewhere in there, jobs are firing on a schedule. You need to know which ones, before one breaks at 03:00 on Sunday.
The handover email is two paragraphs. "Login is in the password manager. Site has been stable for years. Old dev left in 2023." Attached is an SFTP credential and a MySQL user. No shell, no control panel, no documentation about what runs on a schedule. Somewhere on that box, something is firing every five minutes, every night at 02:00, or once a month on the first. You need to find all of them before one breaks under your watch.
Cron jobs over FTP is a particular kind of forensic work. You cannot run crontab -l. You cannot ls /etc/cron.d/. What you can do is read the artefacts that scheduled jobs leave behind, cross-reference them with database state, and reconstruct the schedule from the outside in. This is the order I use on every legacy site takeover.
Filesystem clues you can actually see
Shared hosts almost always jail the FTP user to a single home directory. /etc is out of reach. But cron jobs leave fingerprints inside the docroot, and that is where you start.
Look first for log files the site itself writes. Most CMSes drop a log somewhere predictable:
/wp-content/debug.log
/wp-content/uploads/wc-logs/*.log
/sites/default/files/php_errors.log
/var/log/magento.log
/var/reports/
/storage/logs/laravel.logOpen them and grep for "cron". WordPress writes a line like WP-Cron: doing event 'woocommerce_cleanup_sessions' every time it fires. Magento writes cron_schedule entries to var/log/system.log. Drupal logs every cron run with username "Anonymous" and path "/cron". Even if the cron is triggered externally by a curl from /etc/crontab, the application itself logs the event, and that log is inside the FTP root.
Second, run a recursive mtime sweep. Cron-driven scripts write to disk. Backups land somewhere. Caches get rotated. Export feeds get regenerated. If you can list a directory with mtimes, look for files whose timestamps fall on a regular cadence:
backup-2026-04-14-0300.sql.gz
backup-2026-04-15-0300.sql.gz
backup-2026-04-16-0300.sql.gz
feed-google-shopping-20260514.xml
feed-google-shopping-20260515.xmlThat is a daily 03:00 backup and a daily Google Shopping export. You did not find the cron line. You found the proof the cron line exists, and now you know which docroot script wrote those files.
The database remembers everything
Now to the second half of the picture. Application-level schedulers store their queue in MySQL, and MySQL is what you have. Three tables matter, depending on the stack.
WordPress
WordPress keeps its entire cron schedule serialised in a single row:
SELECT option_value
FROM wp_options
WHERE option_name = 'cron';The value is a PHP-serialised array keyed by unix timestamps. Unserialise it (any PHP one-liner will do) and you get the full list of upcoming events, the hook names, the recurrence interval, and the arguments passed in. The official WP-Cron documentation is worth a re-read here. The trap is that wp-cron only fires when someone visits the site, unless a real system cron pings wp-cron.php on a schedule.
Magento
Magento stores its queue in cron_schedule. One row per scheduled run:
SELECT job_code, status, scheduled_at, executed_at, finished_at
FROM cron_schedule
ORDER BY scheduled_at DESC
LIMIT 50;Group by job_code and you have every job that has ever run, plus its cadence. If status sits at pending for hours, no system cron is calling cron.php and the queue has stalled. The Adobe Commerce cron reference documents the schema in full.
Drupal
Drupal 7 and 8+ both store a system.cron_last entry in the state table. Query it to see when cron last completed:
SELECT name, value
FROM key_value
WHERE collection = 'state' AND name = 'system.cron_last';If that timestamp is hours behind, cron is either failing silently or has not been wired up to anything external.
The HTTP layer is the third witness
Most shared hosts give the FTP user read access to raw access logs, usually under /logs/ or /var/log/apache/ inside the home directory. Open last week's file and grep for the entry points that schedulers hit:
grep -E "wp-cron\.php|/cron|cron\.php|run\.php" access.log.1You are looking for a User-Agent of curl or Wget hitting one of these endpoints on a clean interval from a single IP. That tells you a real system cron exists, it is pointing at the web layer, and you can guess its frequency from the gaps in the timestamps. If the only hits to wp-cron.php come from real visitor IPs with browser user-agents, there is no external cron at all and the schedule is drifting.
Also check .htaccess in the docroot for rewrite rules that obscure cron endpoints behind clean URLs:
RewriteRule ^run-tasks/?$ /cron.php?secret=hunter2 [L]That single line, which we have found on more than one inherited site, means there is a public URL being pinged by something external, and the secret token is sitting in the rewrite. If the previous developer is gone and the cron lives on a third-party uptime monitor you have never logged into, this is your only thread back to it.
Putting the schedule back together
By now you have three lists. The mtime sweep tells you what files cron is producing and when. The database queue tells you what application-level jobs are registered. The access log tells you whether something external is driving the queue at all. Reconcile them.
What you usually find on a site that has been quietly running for three years is roughly this: one external curl hitting wp-cron.php or cron.php every five minutes from a cheap uptime monitor; a handful of application-level jobs registered by plugins long since uninstalled, now throwing errors no one reads; one or two backup scripts writing to a folder no one has emptied since 2022; and one rewrite rule exposing an unauthenticated trigger endpoint.
What we built for this exact moment
The reason we know the shape of this audit is that we did it manually about forty times before getting tired of it. When we built Pier we kept ending up in the same scene: an FTP credential, a MySQL login, and a developer trying to reconstruct a schedule from log files at 23:00 on a Tuesday. The way we ended up handling it was to put the filesystem and the MySQL editor in the same window, so the access log and the cron_schedule table sit side by side, and every change you make to either is captured in version history.
The smallest thing you can do today: open the access log of any site you currently maintain and grep for wp-cron.php or cron.php. If the only hits are from real browsers, you have just found a cron job that is not running, and you have ten minutes to set up an uptime monitor to fix it.
— Questions —
Can I read /etc/crontab over FTP?
On almost every shared host, no. The FTP user is jailed to a home directory. You have to infer system crons from log files, file mtimes, and access log hits to scheduler endpoints.
How do I know if WP-Cron is actually firing on a schedule?
Grep the access log for hits to wp-cron.php. If the only visitors are real browsers and not a curl or Wget user-agent on a clean interval, nothing is driving it and events queue up indefinitely.
Where does Magento store its scheduled jobs?
In the cron_schedule table. One row per scheduled run, with status, scheduled_at, executed_at and finished_at. Group by job_code to see cadence and find stalled jobs.
What if .htaccess hides the cron endpoint behind a clean URL?
Read every RewriteRule in the docroot .htaccess. Inherited sites often expose cron.php behind a path like /run-tasks with a secret token in the query string.