Skip to content

Schedule SPSync with cron, Task Scheduler, launchd or CI

spsync run replication.yaml does one sync round: full load the first time, delta after. Schedule it and it keeps the destination current on its own. This page covers scheduling on every platform, plus the one rule that matters for all of them: secrets never go in the YAML.

Under each list’s read / inserted / updated / soft-deleted line, spsync run names any column it could not replicate (skipped 1 column: Areas (unsupported column type 'unknown'), at most 10 names then “and N more”) and shows the system and hidden columns every SharePoint list leaves out by design as a quiet count (9 system/hidden columns not replicated by design). --json carries the full list under list_metrics[].skipped_columns (name, reason, by_design).

A run that completes but could not replicate everything is a warning, not a plain success: spsync run prints Synced <name> with N warnings: followed by the list (for example an unsupported column that was skipped), and --json carries status (success or warning) and a warnings list. The exit codes are:

CodeMeaning
0Success
1 or 3The run failed
2The run completed with warnings
4The run was refused: another run already holds this destination

Exit code 2 is configurable: --warning-exit-code N changes it, and --warning-exit-code 0 makes a warning run exit 0 like a success (the warnings are still printed and recorded). In a scheduler such as GitHub Actions, Azure DevOps or cron, a non-zero exit marks the job failed, so the first run after upgrading can turn a job red if a list has an unsupported column (managed-metadata columns, for example); fix the column or set --warning-exit-code 0. A list with a date-only column and no site_time_zone also exits 2 on every scheduled run until a zone is set, because SPSync cannot place a date-only value on the right day without it. Fix it with spsync export-config --site-time-zone Europe/London (any IANA name), or add site_time_zone: Europe/London to the config YAML. Typer also uses exit code 2 for a bad flag or usage error, so use the printed output to tell the two apart.

Only one round runs per replication and destination at a time on a machine. If an invocation overlaps one that is still running (a cron interval shorter than the run, or the SPSync background service syncing the same replication), it is refused straight away with Error: another run holds this destination and exit code 4, and nothing is written. The run in progress is not affected. Treat 4 as “skipped, already running” rather than a failure, or lengthen the interval. This lock covers one machine; two machines pointed at the same destination are not coordinated yet.

spsync run never loops or sleeps. Your own scheduler decides the cadence, one invocation per round. spsync doctor replication.yaml’s adaptive-interval check reports the interval currently in effect and why:

  • Automatic (default): with no sync_interval_seconds in the config, SPSync estimates an interval from your Graph permission mode and the tenant’s request budget (5 minutes delegated, 1 minute app-only, 30 second absolute floor), so a small tenant with many lists is not scheduled tighter than its own Graph throughput allows. See Setup, step 4 for the fixed defaults.
  • Explicit: set sync_interval_seconds: in the YAML (or pass --sync-interval-seconds <n> to export-config) to pin a fixed cadence instead, never below the 30 second floor. doctor reports this value once it is set, in place of the computed estimate.

Either way, point your cron/Task Scheduler/GitHub Actions/launchd entry at the same number so the schedule you configured is the schedule that actually runs.

A separate, longer-running reconcile pass (reconcile_interval_days, default 7, ADR-005) runs automatically as part of the normal schedule above, plus on demand via spsync reconcile. Run it as a QA check that the two sides match, or to catch back up quickly after your own server or a Microsoft 365 outage, never as a substitute for the sync interval itself.

destination.connection_string_env and the licence key are always environment-variable names, resolved at run time, so the YAML itself never holds a connection string or a licence key, so it is safe to commit and review in a pull request (see Scriptable migrations). Set the variable through whatever secret store the scheduler below already gives you (GitHub Actions secrets, Azure DevOps variable groups, a systemd EnvironmentFile, a Windows machine-level environment variable), never a literal value in a script or the config file.

Every recipe below starts from an installed spsync on the machine’s PATH: a server, a workstation, or a self-hosted CI runner or agent. The CLI is installed with you on a call at onboarding, and hosted CI runners do not have it, so point CI jobs at a runner where it is installed.

Terminal window
# every night at 02:00
0 2 * * * cd /opt/spsync && SPSYNC_DB_CONNECTION="$(cat /etc/spsync/db.env)" spsync run replication.yaml >> /var/log/spsync/run.log 2>&1
.github/workflows/spsync-run.yml
name: SPSync
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch: {}
jobs:
run:
runs-on: self-hosted # a runner with spsync installed
steps:
- uses: actions/checkout@v4
- run: spsync run replication.yaml
env:
SPSYNC_DB_CONNECTION: ${{ secrets.SPSYNC_DB_CONNECTION }}
SPSYNC_LICENCE_KEY: ${{ secrets.SPSYNC_LICENCE_KEY }}
azure-pipelines.yml
schedules:
- cron: "0 2 * * *"
branches: { include: [main] }
always: true
pool:
name: spsync-agents # a self-hosted agent pool with spsync installed
steps:
- script: spsync run replication.yaml
env:
SPSYNC_DB_CONNECTION: $(SPSYNC_DB_CONNECTION)
SPSYNC_LICENCE_KEY: $(SPSYNC_LICENCE_KEY)

Put SPSYNC_DB_CONNECTION and SPSYNC_LICENCE_KEY in a variable group marked secret, not in the YAML.

Set the connection string as a machine or user environment variable first (setx SPSYNC_DB_CONNECTION "..."), then register the task:

Terminal window
schtasks /Create /SC DAILY /ST 02:00 /TN "SPSync" /TR "powershell -File C:\spsync\run.ps1"

run.ps1:

Terminal window
spsync run C:\spsync\replication.yaml

Create ~/Library/LaunchAgents/io.spsync.run.plist with a StartCalendarInterval and a ProgramArguments array running spsync run replication.yaml, with SPSYNC_DB_CONNECTION under EnvironmentVariables, then:

Terminal window
launchctl load ~/Library/LaunchAgents/io.spsync.run.plist

Every spsync run also writes one JSON line per event to the daily log file the desktop app uses (spsync logs path prints the folder; spsync logs tail -f follows it), so the >> run.log redirection above is only for the human-readable console output. Set SPSYNC_LOG_LEVEL=debug (or pass --log-level debug) when you need one record per Graph request. See Where the logs are.

Run spsync doctor replication.yaml once by hand first. It checks the ODBC driver (SQL Server only), Graph connectivity/permissions and destination connectivity, so a scheduled job fails on a clear message the first time it runs unattended rather than doing nothing without a sign.

See CLI recipes for the export-config/doctor/run sequence itself.