termique
Blog
Guide10 min read

Automating SFTP file transfers with cron, safely

Automate SFTP file transfers with cron in 2026: SSH key auth, lock files, atomic uploads, and failure alerts, without leaving credentials exposed.

Automating SFTP file transfers with cron, safely

Every team that decides to automate sftp file transfers with cron eventually hits the same 2am page: the job silently stopped uploading three nights ago, nobody noticed until a report came back empty, and the crontab shows nothing but a stale timestamp. Cron doesn’t fail loudly. It fails quietly, and sftp jobs are especially good at hiding a failure behind an empty log and a non-zero exit code nobody checked.

This is the setup for a repeatable, unattended file drop, whether you’re pushing nightly backups to a partner’s server or pulling vendor exports every morning. Done carelessly, it means a passphrase-less private key sitting on a box with no rotation policy, and a job that can fail silently for a week. Done right, it means a locked-down service account, a script that logs and alerts on its own, and files that never land half-written on the other end.

How do you automate sftp file transfers with cron?

It comes down to three pieces: a way to authenticate without a human typing a password, a script that does the actual transfer, and a crontab entry that runs the script on schedule. Get the first piece wrong and you’ve created a standing security liability. Get the second wrong and you’ve created a job that fails without telling anyone.

Generate a dedicated SSH key for the automation account

ssh-keygen -t ed25519 -f ~/.ssh/sftp_backup_job -N "" -C "backup-job@yourhost"

The -N "" flag creates the key with an empty passphrase, which is what makes unattended use possible at all: there’s no one around at 2am to type one in. Use a key dedicated to this one job, not your personal login key, and follow the same one-key-per-purpose discipline covered in managing multiple SSH keys across devices. If this key leaks, the blast radius is one script’s access, not your whole account.

Test the sftp batch command by hand before scheduling it

Put the commands you want to run in a plain text batch file, then run sftp in batch mode against it before you let cron anywhere near it:

cd /incoming
put /local/backups/daily.tar.gz daily.tar.gz.part
rename daily.tar.gz.part daily.tar.gz
bye
sftp -i ~/.ssh/sftp_backup_job -b /opt/backup/sftp-commands.txt backupuser@remote-host

Confirm it connects without a password prompt, uploads the file, and exits 0. Anything you can’t get working interactively will not magically start working from cron, where there’s no terminal to show you the prompt it’s stuck on.

Add the crontab entry

15 2 * * * /opt/backup/run-backup.sh >> /var/log/backup-job.log 2>&1

Note the >> logfile 2>&1 at the end. Skip that and cron discards both stdout and stderr, or silently mails them to a root mailbox nobody reads. That single redirect is the difference between having a log to look at and having nothing.

Writing an sftp cron script that doesn’t fail silently

A raw sftp command in a crontab line has no error handling: no lock, no retry, no way to tell you it failed short of an empty destination file someone eventually notices. Wrap it in a script instead.

Wrap sftp in a script, never a raw crontab one-liner

#!/usr/bin/env bash
set -euo pipefail

LOG=/var/log/backup-job.log
KEY=/home/backupsvc/.ssh/sftp_backup_job
BATCH=/opt/backup/sftp-commands.txt
HOST=backupuser@remote-host

echo "$(date -Iseconds) starting transfer" >> "$LOG"

if sftp -i "$KEY" -b "$BATCH" "$HOST" >> "$LOG" 2>&1; then
  echo "$(date -Iseconds) transfer succeeded" >> "$LOG"
else
  echo "$(date -Iseconds) transfer FAILED" >> "$LOG"
  exit 1
fi

set -euo pipefail stops the script on the first unexpected error instead of plowing ahead and reporting success anyway. That’s the baseline. The rest of this article adds the pieces most tutorials skip: preventing overlap, avoiding partial files, verifying integrity, and actually alerting someone.

Log everything, since cron discards it by default

sftp returns a non-zero exit code on failure, but a non-zero exit code nobody reads is worthless. Every line above appends a timestamp to a log file, which means when someone finally does check, there’s a timeline instead of a single “it’s broken” data point. Rotate that log with logrotate so it doesn’t grow forever, same as any other service log on the box.

Preventing overlapping runs with flock

If a transfer occasionally takes longer than the interval between cron ticks, for example a slow network day pushes a 2am job past 2:15am, the next scheduled run starts on top of the first one. Two sftp sessions writing to the same destination at once is how you get corrupted, half-merged files. flock fixes this by wrapping the cron line itself, not the script:

15 2 * * * /usr/bin/flock -n /var/lock/sftp-backup.lock /opt/backup/run-backup.sh >> /var/log/backup-job.log 2>&1

The -n flag means: if the lock is already held (last run is still going), exit immediately instead of waiting or piling up queued runs. Unlike a hand-rolled PID file, the lock releases automatically when the process exits or dies, even from kill -9 or a power loss, so you don’t end up with a stale lock blocking every future run.

Avoiding partial-file reads with atomic uploads

If the receiving side (a downstream import job, another cron script, anything watching that directory) reads the destination file while it’s still mid-upload, it reads a truncated, invalid file. This is the failure mode behind “the import job crashed on a corrupted CSV” tickets that seem to happen at random.

The fix is the same atomic-rename pattern used everywhere else in software: upload to a temporary name, then rename it to the real name only once the upload is fully complete. That’s exactly what the batch file earlier in this article does: put the file as daily.tar.gz.part, then rename it to daily.tar.gz. A rename on the same filesystem is effectively instantaneous, so there’s no window where a reader can see a half-written file under the real name. Anything watching that directory should only ever act on the final, non-.part filename.

Verifying the transfer with a checksum, not a hope

SFTP rides over an encrypted, TCP-checked connection, but that only guarantees the bytes weren’t mangled in transit, not that the file the sender meant to upload is the file that actually landed. Generate a checksum locally before the transfer and upload it as a sidecar file alongside the payload:

sha256sum daily.tar.gz > daily.tar.gz.sha256

Upload both files in the same batch. If you control the receiving end and it can run a shell, have it verify with sha256sum -c daily.tar.gz.sha256 after the transfer. Many hardened sftp-only jails deliberately block shell access, which is exactly the security posture you want (see the next section), so the sidecar file is the portable option: it travels with the payload and gets checked whenever and wherever the receiving side is able to check it, rather than depending on a live remote command you may not be allowed to run.

How do you keep an automated sftp transfer with cron safe from a leaked key?

A passphrase-less key sitting on disk for an unattended job is a real, standing exposure, not a theoretical one. The fix isn’t to avoid automation, it’s to shrink what that specific key can do if it’s ever copied off the box.

Restrict the key with a forced command and IP allowlist

On the receiving server, constrain the automation key’s entry in authorized_keys so it can only do the one thing it’s meant to do, from the one place it’s meant to run:

command="internal-sftp",no-pty,no-agent-forwarding,no-X11-forwarding,from="203.0.113.10" ssh-ed25519 AAAAC3Nza... backup-job@yourhost

command="internal-sftp" means the key can only start an SFTP subsystem, never an interactive shell. from="203.0.113.10" means even a stolen key is useless from anywhere except the automation host’s own IP. no-pty and the forwarding flags close off the other things a compromised key could otherwise be used for.

Use a dedicated, chrooted sftp-only user

Pair the restricted key with a service account that has nowhere to go even if the restrictions above were somehow bypassed. In sshd_config:

Match User backupsvc
    ChrootDirectory /srv/sftp/backupsvc
    ForceCommand internal-sftp
    AllowTcpForwarding no
    X11Forwarding no

The chroot means the account can’t see or touch anything outside its own directory, so a leaked key that somehow still worked would only expose one folder, not the whole filesystem.

Rotate the automation key on a schedule

Service keys get forgotten precisely because there’s no human logging in with them to notice they’re old. Put a rotation date on the calendar, the same discipline described in how end-to-end encrypted SSH credential storage actually works: generate a new key pair, add the new public key to authorized_keys alongside the old one, confirm the job runs cleanly with the new key, then remove the old public key. Log the rotation date somewhere your team will actually see it again.

Getting alerted when a cron sftp job breaks

A log file only helps if someone reads it. Have the script itself fire a notification on failure, since cron’s default of mailing root is functionally silent on most infrastructure:

if ! sftp -i "$KEY" -b "$BATCH" "$HOST" >> "$LOG" 2>&1; then
  echo "$(date -Iseconds) transfer FAILED" >> "$LOG"
  curl -fsS -X POST "$ALERT_WEBHOOK_URL" -d "sftp backup job failed on $(hostname), check $LOG"
  exit 1
fi

For jobs where silence itself is the risk, for example a job that’s supposed to run nightly but the whole box goes down, pair this with a dead-man’s-switch style heartbeat ping that fires on every successful run and alerts you the moment a scheduled ping doesn’t arrive. A failing job that alerts you is good. A job that stops running entirely and alerts you anyway is better.

A complete example: nightly backup upload script

Putting the lock, the atomic rename, the checksum, and the alert together:

#!/usr/bin/env bash
set -euo pipefail

LOG=/var/log/backup-job.log
KEY=/home/backupsvc/.ssh/sftp_backup_job
HOST=backupuser@remote-host
FILE=/local/backups/daily.tar.gz
SHA="${FILE}.sha256"
BATCH=/tmp/sftp-commands-$$.txt

sha256sum "$FILE" > "$SHA"

cat > "$BATCH" <<EOF
cd /incoming
put $FILE daily.tar.gz.part
put $SHA daily.tar.gz.sha256
rename daily.tar.gz.part daily.tar.gz
bye
EOF

echo "$(date -Iseconds) starting transfer" >> "$LOG"

if sftp -i "$KEY" -b "$BATCH" "$HOST" >> "$LOG" 2>&1; then
  echo "$(date -Iseconds) transfer succeeded" >> "$LOG"
else
  echo "$(date -Iseconds) transfer FAILED" >> "$LOG"
  curl -fsS -X POST "$ALERT_WEBHOOK_URL" -d "sftp backup job failed on $(hostname)" || true
  rm -f "$BATCH"
  exit 1
fi

rm -f "$BATCH"
15 2 * * * /usr/bin/flock -n /var/lock/sftp-backup.lock /opt/backup/run-backup.sh >> /var/log/backup-job.log 2>&1

That’s the whole shape: a locked, logged, alerting script behind a locked-down key on a locked-down account, uploading to a temporary name and verifying its own integrity before anything downstream ever sees the file.

When cron and a shell script stop being enough

This pattern holds up well for a handful of jobs on a handful of hosts. Past that, for example dozens of feeds, multiple destinations, or transfers that need bandwidth throttling and resumable retries, a raw sftp batch script starts to sprawl. That’s the point to compare SFTP against rsync for automated backups, since rsync’s delta-transfer and built-in retry handling cover cases a plain sftp batch file has to reinvent by hand. For the broader picture of where SFTP fits alongside key management, file browsing, and the rest of a team’s workflow, see SFTP for developers: the complete guide.

termique’s own SFTP file browser covers the other half of this story: the ad-hoc, interactive transfers a cron script was never meant to handle. It runs over the same encrypted host credentials described above, drag-and-drop, with per-command audit logging so a shared or on-call account’s activity is traceable after the fact, not just the scheduled jobs.

termique is a free SSH manager with SFTP included on every plan since v0.3.0, alongside an SSH key vault and end-to-end encrypted credential storage, for the sessions that happen outside a crontab.

Try termique free.

SSH manager with end-to-end encrypted credentials, AI assistant, and cross-device sync.

Download free

Keep reading

All articles ⟶