← Blog

Operations

Cron jobs over FTP: mapping a server you just inherited

An FTP login, no SSH, and a client who says 'something runs at night.' Here is how we map every cron job on a legacy server before migration day.

Jacob Molkenboer· Founder · A Brand New Company· 3 Jun 2026· 6 min
Brass pocket watch, folded paper roster with twine, green tag on iron key, red wax seal on ivory blotter.

You take over a five-year-old PHP site for migration. The previous developer is unreachable. The client hands you an FTP login, a MySQL login, and one sentence: 'something runs at night, please do not break it.' There is no SSH. There is no hosting panel password. There is no documentation. That 'something' is a cron job, or several, and you have no idea what they do. Welcome to the most common starting position in legacy work.

This post is about how to find out what is actually running on that server before you touch anything. The risk of guessing is real. Cron tasks send invoices, sync inventory, expire sessions, retry failed payments. Move the site without mapping them and you ship a quiet outage two days after launch.

What you actually have access to

FTP-only does not mean blind. It means you can read every file under the home directory of the FTP user, and usually nothing else. You cannot run crontab -l. You cannot list /etc/cron.d. You cannot tail journalctl. But the server has been writing to its own home directory for years, and that history is a transcript of what runs and when.

Before you start digging, do one cheap thing. Open the hosting account login page (cPanel, Plesk, DirectAdmin, ISPConfig, custom) and ask the client to reset the password. Half the time they have access and forgot they did. With panel access the rest of this post is a half-hour job. Without it, it is two hours.

Where cron lives on shared hosts

Real crontab files live in /var/spool/cron/ and you cannot read them over FTP. What you can read is everything the cron service touches in the home directory: mirrors, logs, output redirects, lock files, mail spools. Each panel has its own quirks.

  • cPanel: mail from cron is filed at ~/mail/cur/ as Maildir entries. Every cron job that produces output emails the user, and cPanel saves those mails. They are timestamped. Each one starts with the command line that produced it. Read the last 30 days and the schedule is in your hand.
  • Plesk: similar mail spool, plus per-domain logs at ~/logs/. Plesk also writes a panel mirror of scheduled tasks at ~/.psa/ on some configurations.
  • DirectAdmin: ~/.shadow/cron/ is sometimes readable and contains the literal crontab.
  • Custom or no panel: look for ~/.bash_history, ~/.lesshst, ~/.mysql_history. They are full of clues about what the previous developer ran by hand and later turned into a job.

cPanel's own documentation is worth a five-minute skim if you have not done this before. The cron jobs interface reference lists the exact paths the panel writes to.

Listen to the filesystem like a clock

The filesystem itself is a clock. Files modified at 03:14 every Monday tell you a Monday 03:14 job exists. You do not need to know what it does yet, only that it has a fingerprint.

Drop a marker file at a known time, wait 24 hours, then list everything modified after it. Most FTP clients expose modification times. If yours does not, mount the FTP root locally with curlftpfs or lftp:

# mount the FTP root locally
mkdir -p /tmp/srv
curlftpfs ftp.example.com /tmp/srv -o user=login:password

# touch a marker at a known time
touch -d "2026-06-03 12:00" /tmp/marker

# 24 hours later, list everything the server wrote since
find /tmp/srv -type f -newer /tmp/marker 2>/dev/null | sort

What comes back is the moving parts of the site. Logs that rotate. Cache files that rebuild. Reports that get generated. Each one is downstream of a scheduled task. Group them by directory and a pattern shows up fast.

The actual common case: HTTP-triggered crons

On shared hosting most 'cron' is not cron. It is a curl or wget request that the host fires at a URL on a schedule, because shell jobs are restricted or charged extra. Or it is the application's own internal scheduler, hit by an external pinger.

So look for endpoints, not scripts. The patterns you will find:

  • /cron.php, /scheduler.php, /run.php, /tasks.php at the web root
  • /wp-cron.php on WordPress (see the official WP-Cron reference for what fires when, and why most production sites disable the loopback version)
  • /cron/cron.php or /?q=cron on Drupal 7
  • /cron.php?key=... on Drupal 9 and 10
  • A wrapped bin/magento cron:run on Magento 2
  • A custom endpoint protected by an .htaccess IP allowlist. Read those .htaccess files. They tell you which IPs the cron service is calling from.

If you have access logs, even gzipped ones in ~/logs/, grep them for those paths. The User-Agent usually gives the trigger away. cPanel jobs hit with Wget/1.x. UptimeRobot-style external pingers hit with their own UA. WordPress loopback cron hits with WordPress/X.Y.

Warning

An inherited cron that pulls a remote script with curl | sh or wget -O - | bash is a live backdoor. Treat any such job as compromised until proven otherwise. Rotate every secret on the box before you flip DNS.

Reconstructing the schedule you were never given

By now you have three datasets: filesystem modification fingerprints, mail-spool transcripts, and access-log hits. Cross-reference them. A file at ~/var/exports/orders-YYYYMMDD.csv modified at 04:05 every day, plus a cron mail at 04:05 with subject 'orders export OK', plus an access-log entry for /cron/export-orders.php?token=... at 04:05, is the same job seen from three sides.

Write the schedule down in a plain crontab format even if you can never deploy it as such. You are building the migration spec.

# reconstructed from filesystem + mail spool + access logs
# 2026-06-03 by the new caretaker

5  4 * * *   /usr/bin/php /home/site/cron/export-orders.php
*/15 * * * * curl -s https://site.example/wp-cron.php?doing_wp_cron
0  3 * * 1   /usr/bin/php /home/site/cron/weekly-invoice-chase.php
30 2 * * *   /usr/bin/php /home/site/cron/clear-sessions.php

Day one of a real migration

When we picked up an FTP-only Drupal 7 site for a Dutch wholesaler this spring, the client's only documentation was a Post-it note that said 'cron runs'. The first three hours were the exact work above: mount the FTP root, drop a marker, read the mail spool, grep the access logs. We found eleven scheduled jobs. Two of them had been failing silently for fourteen months. One was sending an unencrypted CSV of customer addresses to a Gmail account that no longer existed.

That kind of dead weight is normal on inherited infrastructure, and it is why we always do the cron audit before the code audit on a legacy migration. The site you are about to move is rarely the site you think it is.

Smallest thing you can do today: open your FTP client, sort the home directory by modification time descending, and read the top fifty files. Half the server's secrets are in that list.

Key takeaway

Inherited cron jobs leave fingerprints on the filesystem, in the mail spool, and in the access logs. Find them before you migrate, or you will ship a quiet outage.

FAQ

Can you really map cron jobs without any shell access?

Yes. Cron writes mail, logs, output files, and lock files into the FTP user's home directory. Watching what gets modified over 24 hours reveals the schedule even without crontab access.

What if the hosting panel has no cron section at all?

Then crons are almost certainly HTTP-triggered. Look for /cron.php style endpoints, check .htaccess for IP allowlists, and grep the access logs for repeating requests on a fixed cadence.

How long should the marker-and-wait audit run?

At minimum 24 hours for daily jobs, ideally 8 days so you catch weekly ones too. Monthly jobs need a full month or a clue from cron mail in the spool.

Is wp-cron actually a cron job?

No. WP-Cron fires on page visits and pretends to be scheduled. On low-traffic sites a real cron pings wp-cron.php every 15 minutes to make it predictable. That ping is what you find in access logs.

What should I rotate before going live with the migrated site?

Every API key, SMTP password, database password, and webhook secret referenced anywhere in the cron scripts. Treat the old box as untrusted from the moment you take it over.

legacy sitesmigrationoperationsphpwordpresstooling

Building something?

Start a project