How to Keep a Migration From Breaking Scheduled Tasks and Cron Jobs
The only reliable way to stop scheduled work from disappearing after a move is to inventory every timer on the old host before you touch DNS, then recreate each one on the new machine and prove it fires. Migrating cron jobs to a new host fails most often not because the commands are hard to copy, but because nobody wrote down what was scheduled in the first place, and because two different schedulers are involved: the system cron daemon and, on WordPress sites, the pseudo scheduler known as WP-Cron.
Start by treating the old server as the source of truth. There are three places scheduled work hides: the per user crontabs, the system wide files under /etc/cron.d, /etc/cron.hourly and their siblings, and the application layer, which for WordPress means rows in the wp_options table rather than anything the operating system knows about.
Inventory the system crontabs before you migrate
Every user has their own crontab, and the one you care about is often not the one you are logged in as. Dump them all. As root you can read the spool directly, which is faster and catches crontabs belonging to service accounts that never log in.
for u in $(cut -d: -f1 /etc/passwd); do
echo "== $u =="
crontab -l -u "$u" 2>/dev/null
done
The output is a series of user names each followed by their schedule lines, or nothing at all for users with no crontab. Read every line and ask what it does. A line like */5 * * * * /usr/bin/php /var/www/app/artisan schedule:run is a framework dispatcher, not a job in itself, and it will need its own handling. A line invoking a backup script that writes to a path you are not migrating is a job you should consciously drop rather than copy.
Then collect the system wide definitions, which live in separate files with a slightly different format because they carry a user field between the schedule and the command.
grep -rHv '^#' /etc/cron.d /etc/crontab 2>/dev/null
ls -l /etc/cron.{hourly,daily,weekly,monthly}
Note the difference in shape. A crontab entry has five time fields then the command. A file in /etc/cron.d has five time fields, then the user to run as, then the command. Copying a line between the two without adding or removing the user field is a classic way to produce a job that either never runs or runs as the wrong account.
Environment is the part that breaks silently
Cron runs commands with a minimal environment. PATH is usually just /usr/bin:/bin, there is no shell profile, and no HOME pointing where you expect. A job that works when you run it by hand frequently fails under cron on the new host because the interpreter moved or the path changed between distributions.
Do not rely on the inherited PATH. Use absolute paths for the interpreter and the script, and set anything else you need at the top of the crontab itself.
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
[email protected]
*/10 * * * * /usr/bin/php /var/www/app/bin/worker.php >> /var/log/worker.log 2>&1
Redirect both standard output and standard error somewhere you will actually look. Cron's default behavior is to mail output to the local user, and on a fresh host with no mail transfer agent configured those messages vanish. A job that is failing every ten minutes and logging nothing is indistinguishable from a job that is not scheduled at all, which is precisely the silent failure you are trying to avoid.
Also carry over the timezone. The cron daemon uses the system timezone, so a host in a different region will fire jobs at different wall clock times even though the crontab text is identical. Check timedatectl or /etc/timezone on both machines and decide deliberately whether you want the schedule to follow local time or stay pinned to the business's timezone.
Migrating cron jobs for WordPress means looking in the database
WP-Cron is not a real scheduler. It is a check that runs when someone loads a page: WordPress looks at the stored schedule, and if an event is due, it fires it during that request. That design has two consequences for a migration. First, nothing in the crontab tells you what WordPress is scheduling, so a system level inventory misses it entirely. Second, because it depends on page loads, a low traffic site may run scheduled events late or not at all.
Inspect the schedule with WP-CLI on the old host and record the output. The command below lists each hook, when it next runs, and how often it repeats.
wp cron event list --fields=hook,next_run_gmt,recurrence --format=table
wp option get cron --format=json
The first command gives you a readable table of hooks and intervals. The second dumps the raw option value, which is the authoritative record and is worth keeping as a file even if you never read it by hand. Pay attention to hooks registered by plugins. If a plugin is not active on the new host, its scheduled hook will not be rescheduled, and any work it was doing simply stops. Reactivate plugins before you compare the two lists.
On the new host, do not copy the cron option row across by hand. The stored timestamps are absolute, and transplanting them from a database dump can leave events scheduled in the past or in a state the plugin no longer recognises. The cleaner path is to let WordPress rebuild the schedule: restore the database, activate the plugins, then deactivate and reactivate anything whose events are missing, or use wp cron event schedule to recreate the specific hooks you inventoried.
If the old site relied on visitor traffic to trigger WP-Cron, the new one probably should not. Disable the built in trigger with define('DISABLE_WP_CRON', true); in wp-config.php and drive it from system cron instead, which makes the schedule predictable and puts it under the same inventory discipline as everything else.
*/5 * * * * cd /var/www/site && /usr/local/bin/wp cron event run --due-now >/dev/null 2>&1
Note the cd. WP-CLI needs to find the installation, and a cron job does not start in the directory you think it does.
Verify by watching the jobs fire, not by reading the file
A crontab that exists is not a crontab that runs. Verification means observing execution on the new host. Check that the daemon is actually running and enabled, since some minimal images ship without it.
systemctl status cron
journalctl -u cron --since "30 min ago"
The journal shows the daemon starting each job and, depending on configuration, the command it ran and the user it ran as. If your distribution logs to a file instead, look in /var/log/syslog or /var/log/cron. For anything you cannot see in the logs, add a temporary line that appends a timestamp to a file, confirm it appears, then remove the line. That is a crude test but it settles the question.
Test the command itself by running it as the cron user with a stripped environment, for example sudo -u www-data env -i /bin/bash -c '/path/to/script'. If it fails there but succeeds in your interactive shell, you have found an environment dependency before it costs you a night of missing backups.
Finally, leave the old host running and keep its crontabs in place until you have seen the new jobs fire at least twice at their real intervals. Disable rather than delete the old entries, so you can compare behaviour if something looks wrong. Once the new schedule has proven itself, retire the old one and keep the inventory file you built in version control, because the next migration will start from it.
