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).
Warnings and exit codes
Section titled “Warnings and exit codes”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:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 or 3 | The run failed |
| 2 | The run completed with warnings |
| 4 | The 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.
How often to run it
Section titled “How often to run it”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_secondsin 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>toexport-config) to pin a fixed cadence instead, never below the 30 second floor.doctorreports 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.
Secrets: env vars only
Section titled “Secrets: env vars only”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.
cron (Linux/macOS)
Section titled “cron (Linux/macOS)”# every night at 02:000 2 * * * cd /opt/spsync && SPSYNC_DB_CONNECTION="$(cat /etc/spsync/db.env)" spsync run replication.yaml >> /var/log/spsync/run.log 2>&1GitHub Actions
Section titled “GitHub Actions”name: SPSyncon: 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 DevOps
Section titled “Azure DevOps”schedules: - cron: "0 2 * * *" branches: { include: [main] } always: truepool: name: spsync-agents # a self-hosted agent pool with spsync installedsteps: - 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.
Windows Task Scheduler
Section titled “Windows Task Scheduler”Set the connection string as a machine or user environment variable first
(setx SPSYNC_DB_CONNECTION "..."), then register the task:
schtasks /Create /SC DAILY /ST 02:00 /TN "SPSync" /TR "powershell -File C:\spsync\run.ps1"run.ps1:
spsync run C:\spsync\replication.yamlmacOS launchd
Section titled “macOS launchd”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:
launchctl load ~/Library/LaunchAgents/io.spsync.run.plistEvery 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.
Verify before you schedule
Section titled “Verify before you schedule”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.