Step 1 â Why Automate FTP?
Manual FTP transfers are perfectly acceptable when you deploy a static page once a quarter. The moment you upload the same build directory twice in one afternoon, friction compounds. Each click is a chance to drop the wrong folder into public_html/, overwrite a configuration file you edited directly on the server, or forget to clear a cached asset. Automation turns an error-prone ritual into a repeatable, auditable process.
The most tangible benefit is time. A typical WordPress or static-site deploy involves 500 to 5,000 small files â themes, JavaScript bundles, CSS, media. Dragging that tree through a GUI client over a transatlantic link takes minutes and demands attention. A script runs it in the background while you move on to the next ticket. Multiply by five deploys a week, fifty-two weeks a year, and the hours saved are measured in days, not minutes.
Consistency is the second win. A hand-driven upload is only as reliable as your memory. A scripted upload performs the same operations in the same order every time: connect, change to the target directory, mirror the local tree with the correct exclusions, verify the transfer, log the result, disconnect. If something fails, the log tells you where. If someone else on the team runs it, they get the same outcome you do.
Automation also unlocks workflows that are impractical by hand: nightly mirroring of a database export to an offsite FTP backup target, uploading a build artifact immediately after a continuous-integration pipeline passes, syncing user-generated content between a staging and production bucket on a schedule, pulling log rotations from edge servers for aggregation. These are not glamorous tasks â they are the plumbing that lets small teams run reliable infrastructure.
Finally, there is auditability. A script committed to Git documents exactly how deploys happen. New contributors read one file and understand the deployment surface. Security reviewers can inspect what credentials are used, where logs are written, and which paths are touched. In regulated environments, this traceability is not optional.
đĄ
Rule of threeIf you have performed the same FTP action three times, write the script. The fourth time pays for the first three.
A common objection is that modern deploys should use SSH, rsync, Git-based deploy hooks, or object storage â and often they should. But millions of production sites still live on shared hosts whose only remote-write channel is FTP or FTPS. Automation closes that gap: it brings 1990s protocols into a 2026 workflow without forcing you to rebuild the hosting stack.
Step 2 â Windows Batch Script FTP
Windows ships a built-in ftp.exe client that reads a script file through the -s: flag. It is plain, unencrypted FTP, and it has lived in C:\Windows\System32\ since the NT era. For simple internal transfers, local-network backups, or legacy integrations, it is still the shortest path from zero to working automation. For anything that leaves your LAN, prefer the WinSCP or SFTP options later in this guide.
The pattern has two parts: a command file that lists FTP operations, and a .bat wrapper that invokes ftp -s: against the command file. The command file is read line by line as if you typed each command at the ftp> prompt.
1
2
3
4
5
6
7
8
9
open ftp.example.com
username
p@ssw0rd
binary
lcd C:\builds\site
cd /public_html
prompt off
mput *.html
bye
The binary directive is critical â the default ASCII mode corrupts images, archives, and binaries by rewriting line endings. prompt off suppresses the per-file mput confirmation. lcd changes the local directory; cd changes the remote. The wrapper batch file is a one-liner:
1
2
3
4
@echo off
ftp -n -i -s:C:\scripts\ftp_commands.txt >> C:\scripts\ftp.log 2>&1
if %ERRORLEVEL% NEQ 0 echo [%DATE% %TIME%] FAILED >> C:\scripts\ftp.log
exit /b %ERRORLEVEL%
-n suppresses auto-login so credentials come from the script. -i turns off interactive prompting. The redirect captures standard output and errors into a log you can parse later. The exit code from ftp.exe is coarse â zero on apparent success, non-zero on connection failure â so for production use pair it with WinSCP's richer return codes (see Step 3).
Schedule the batch file with the Windows Task Scheduler GUI or the schtasks command. A nightly 2 a.m. run looks like this:
1
2
3
4
5
schtasks /create /tn "Nightly FTP Deploy" ^
/tr "C:\scripts\deploy.bat" ^
/sc daily /st 02:00 ^
/ru SYSTEM ^
/rl HIGHEST
â ī¸
Plaintext passwordsThe script file holds credentials in plaintext. ACL it so only the service account can read it: icacls ftp_commands.txt /inheritance:r /grant:r "SYSTEM:(R)". Better still, use WinSCP with a saved session key (Step 3).
Step 3 â WinSCP Scripting & .NET Assembly
WinSCP is the serious Windows automation tool. It speaks FTP, FTPS, SFTP, SCP, S3, and WebDAV through one scripting surface, returns meaningful exit codes, supports host-key verification, and ships a .NET assembly for PowerShell or C# integration. Install it, add C:\Program Files (x86)\WinSCP\ to PATH, and you have a professional-grade transfer engine.
The script syntax is cleaner than ftp.exe and the synchronize command replaces hand-written mput loops with intelligent one-way or two-way mirroring based on timestamp and size:
1
2
3
4
5
6
7
8
9
10
11
option batch abort
option confirm off
open sftp://[email protected]/ -privatekey="C:\keys\deploy.ppk" -hostkey="ssh-ed25519 256 abc...="
cd /var/www/html
lcd C:\builds\site
synchronize remote -mirror -criteria=time,size ^
-filemask="| *.map; node_modules/; .git/"
chmod 644 *.html
chmod 755 /var/www/html
close
exit
option batch abort turns off interactive prompts and aborts on error. The -mirror flag deletes remote files that no longer exist locally â be careful; -criteria=time,size skips unchanged files. The -filemask excludes source maps, dependency trees, and VCS metadata that must never leave the build host. Pinning -hostkey prevents a silent MITM swap.
Run the script from a batch file and capture the native exit code:
1
2
3
4
5
6
@echo off
winscp.com /ini=nul /log="C:\logs\winscp_%DATE:/=-%.log" ^
/loglevel=1 /script="C:\scripts\deploy.winscp"
set RC=%ERRORLEVEL%
echo Exit code: %RC%
exit /b %RC%
Exit code 0 means success, 1 is a warning (some files skipped), 2 is a fatal error. The XML log at /log= captures every file transferred with timestamps and byte counts â ideal input for alerting or dashboards.
For deeper integration, the WinSCP.NET assembly exposes the same engine to PowerShell. You get SessionOptions, event handlers, and typed exceptions instead of parsing text output:
1
2
3
4
5
6
7
8
9
10
11
12
13
Add-Type -Path "C:\Program Files (x86)\WinSCP\WinSCPnet.dll"
$opts = New-Object WinSCP.SessionOptions -Property @{
Protocol = [WinSCP.Protocol]::Sftp
HostName = "sftp.example.com"
UserName = "deploy"
SshPrivateKeyPath = "C:\keys\deploy.ppk"
SshHostKeyFingerprint = "ssh-ed25519 256 abc...="
}
$session = New-Object WinSCP.Session
$session.Open($opts)
$result = $session.SynchronizeDirectories([WinSCP.SynchronizationMode]::Remote, "C:\builds\site", "/var/www/html", $false)
$result.Check()
$session.Dispose()
đĄ
Session INIUse /ini=nul in production to ignore user profile settings. Store sessions in an isolated INI with /ini="C:\scripts\deploy.ini" for repeatable, portable automation.
Step 4 â Linux/macOS Shell Scripts
Unix automation has a richer toolbox. The modern choice for FTP, FTPS, and SFTP from the shell is lftp â it supports mirroring, parallel transfers, segmented downloads, resume on failure, and bookmarks. It is the closest open-source equivalent of WinSCP's engine and is available in every major package manager.
1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env bash
set -euo pipefail
HOST="sftp.example.com"
USER="deploy"
LOCAL="/home/ci/builds/site"
REMOTE="/var/www/html"
lftp -u "$USER," -e "
set sftp:connect-program 'ssh -i /home/ci/.ssh/deploy_ed25519';
mirror -R --delete --parallel=4 \
--exclude-glob .git/ --exclude-glob node_modules/ \
$LOCAL $REMOTE; bye" sftp://$HOST
mirror -R uploads (reverse mirror). --delete removes remote files absent locally. --parallel=4 runs four concurrent transfers â ideal when latency, not bandwidth, is the bottleneck. The empty password after the comma in -u tells lftp to delegate authentication to the configured sftp:connect-program, which here invokes ssh with a specific key.
For one-off uploads of a single file, curl is often enough and preinstalled on every Unix host:
1
2
3
4
5
#!/usr/bin/env bash
curl --ftp-ssl --ssl-reqd \
--user "$FTP_USER:$FTP_PASS" \
--upload-file ./release.tar.gz \
"ftp://ftp.example.com/releases/"
--ftp-ssl --ssl-reqd forces explicit FTPS and fails if the server does not negotiate TLS. Credentials come from environment variables sourced from a locked-down .env file, not hardcoded. For SFTP, swap the scheme to sftp:// and add --key /path/to/id_ed25519.
When a legacy server demands interactive responses (older FTP appliances sometimes do), the ancient but reliable expect language scripts the back-and-forth. More commonly, a heredoc into ftp handles plain scripted sessions:
1
2
3
4
5
6
7
8
9
10
#!/usr/bin/env bash
ftp -n -v ftp.example.com <<END_FTP
user $FTP_USER $FTP_PASS
binary
cd /public_html
lcd ./dist
prompt off
mput *
bye
END_FTP
â ī¸
Secret hygieneNever commit FTP_PASS to Git. Load secrets from ~/.netrc (mode 600), environment files, or a secrets manager like HashiCorp Vault, AWS Secrets Manager, or 1Password CLI.
Step 5 â Scheduled Transfers with Cron
Cron is the Unix scheduler. It reads a per-user table of time expressions and shell commands and fires them at the matching minute. For FTP automation, cron turns a working script into a reliable recurring job without any extra infrastructure.
Each cron line has six fields: minute, hour, day-of-month, month, day-of-week, and the command. An asterisk means every, a number means exactly that, a comma lists values, a slash sets a step. Edit your crontab with crontab -e:
1
2
3
4
5
6
7
8
9
# m h dom mon dow command
*/15 * * * * /opt/scripts/sync-logs.sh >> /var/log/ftp-sync.log 2>&1
0 2 * * * /opt/scripts/deploy.sh >> /var/log/deploy.log 2>&1
0 */6 * * * /opt/scripts/backup-site.sh >> /var/log/backup.log 2>&1
30 3 * * 0 /opt/scripts/weekly-mirror.sh >> /var/log/mirror.log 2>&1
@reboot /opt/scripts/retry-queue.sh
MAILTO="[email protected]"
PATH=/usr/local/bin:/usr/bin:/bin
SHELL=/bin/bash
Read line by line: every 15 minutes rotate logs, every day at 02:00 deploy, every 6 hours take a backup, every Sunday at 03:30 run a full mirror, run a retry queue after reboot. The MAILTO directive sends job output to an operator email when cron is configured with a mail transport â invaluable for silent-failure detection.
Cron pitfalls bite everyone at least once. The environment is minimal: $PATH is short, no shell profile is loaded, $HOME may be unset. Always use absolute paths for scripts and binaries, set PATH explicitly inside the crontab, and redirect both stdout and stderr. If a job appears not to run, check /var/log/syslog or journalctl -u cron for cron's own log entries.
1
2
3
4
5
#!/usr/bin/env bash
# Add at top of any cron-executed script
exec >> /var/log/myjob.log 2>&1
echo "=== $(date -Iseconds) start pid=$$ ==="
set -x
Modern alternatives exist. systemd timers offer dependency ordering, randomized delays, and persistence across reboots that cron lacks. On macOS, launchd plists serve the same role. On Windows, Task Scheduler covers the ground. The scripting pattern is identical â only the trigger changes.
âšī¸
Jitter and lockingAdd a small random sleep (sleep $((RANDOM % 60))) to spread load if many hosts run the same job. Wrap long jobs in flock -n /tmp/mylock.lock ... to prevent overlapping runs when a previous one is still in flight.
Step 6 â CI/CD Pipeline Integration
Continuous integration platforms turn every Git push into a verified build and, optionally, an automatic deploy. For sites hosted on FTP-only providers (most shared WordPress hosts, many small VPS panels, legacy CMS boxes), the pipeline's final stage is an FTP upload. GitHub Actions, GitLab CI, and Bitbucket Pipelines each offer first-class ways to do this.
The canonical GitHub Actions FTP deployer is SamKirkland/FTP-Deploy-Action. It wraps lftp mirroring in a configuration-driven step and reads credentials from encrypted repository secrets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name: Deploy to FTP
on:
push:
branches: [main]
jobs:
web-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- name: FTP Deploy
uses: SamKirkland/[email protected]
with:
server: ${{ secrets.FTP_HOST }}
username: ${{ secrets.FTP_USER }}
password: ${{ secrets.FTP_PASS }}
local-dir: ./dist/
server-dir: /public_html/
exclude: |
**/.git*
**/node_modules/**
Secrets are configured once at Settings â Secrets and variables â Actions. The action uses lftp under the hood and only transfers changed files on subsequent runs, keeping deploys fast.
GitLab CI follows the same shape. A dedicated job runs after build, uses an image with lftp preinstalled, and references masked variables:
1
2
3
4
5
6
7
8
9
10
11
12
deploy:
stage: deploy
image: alpine:latest
only: [main]
before_script:
- apk add --no-cache lftp openssh-client
script:
- lftp -c "
set ftp:ssl-force true;
open -u $FTP_USER,$FTP_PASS $FTP_HOST;
mirror -R --delete --parallel=4
--exclude '\.git/' ./dist/ /public_html/"
Bitbucket Pipelines offers an official atlassian/ftp-deploy pipe. The config is similarly declarative â point at the local directory, name the remote root, and pass credentials through workspace variables.
Every pipeline-based deployment should surface three things in its output: the number of files transferred, the exit code of the transfer step, and a link to the deployed URL. Pair the deploy job with a smoke-test job that curls the live site and asserts a 200 response, so a broken upload fails loudly instead of silently serving stale content.
â ī¸
FTP vs FTPS vs SFTP in CIPrefer SFTP or at minimum FTPS in pipelines. CI runners live on shared infrastructure, and plaintext credentials across the public internet are indefensible in 2026. Every popular deploy action supports encrypted protocols â enable them.
Step 7 â Advanced Automation Patterns
With basics in hand, the next tier is robust automation â scripts that know what changed, retry transient failures, and tell you when something broke before a user does. Four patterns show up repeatedly in production.
Sync versus upload. mput-style uploads push every matching file every time. Sync commands (synchronize, mirror, rsync through SFTP) compare metadata and only transfer differences. For a 2,000-file site where ten files change per deploy, the difference is tens of seconds versus tens of minutes. Always prefer sync unless you specifically need a complete overwrite â for example, when invalidating a CDN origin.
File change detection. Instead of running a full sync on a timer, watch the local directory and trigger uploads on change. On Linux, inotifywait blocks until a filesystem event fires:
1
2
3
4
5
6
7
8
#!/usr/bin/env bash
WATCH=/home/me/site
inotifywait -m -r -e close_write,move,create,delete "$WATCH" |
while read -r path event file; do
echo "[$(date -Iseconds)] $event $path$file"
/opt/scripts/deploy.sh
sleep 2 # debounce burst edits
done
On macOS use fswatch; on Windows, the WinSCP keepuptodate command does the same job natively. For large projects, debounce aggressively â a webpack rebuild can emit hundreds of events in a second.
Error handling and retries. Networks fail. A deploy script that stops on the first dropped connection is a deploy script that pages you at 3 a.m. Wrap transfers in a retry loop with exponential backoff:
1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/env bash
MAX=5; DELAY=5
for attempt in $(seq 1 $MAX); do
if lftp -c "$CMD"; then
echo "OK on attempt $attempt"; exit 0
fi
echo "attempt $attempt failed, sleeping ${DELAY}s"
sleep $DELAY
DELAY=$((DELAY * 2))
done
echo "FAILED after $MAX attempts" >&2
exit 1
Notifications. Silent success is fine; silent failure is dangerous. On completion or failure, post to Slack, Discord, email, or a status page. A single curl call to a webhook does the job:
1
2
3
4
5
#!/usr/bin/env bash
STATUS=$1; MSG=$2
curl -s -X POST -H 'Content-Type: application/json' \
-d "{\"text\":\":${STATUS}: $MSG\"}" \
"$SLACK_WEBHOOK_URL"
Call it from the deploy script's trap handlers so both success and failure paths report. Over months, the notification stream becomes a lightweight audit log of every deploy that ever ran.
A mature automation layout combines these patterns into a small, legible tree:
/opt/ftp-automation/
âââ scripts/
â âââ deploy.sh
â âââ backup-site.sh
â âââ watch-and-upload.sh
â âââ notify.sh
âââ config/
â âââ prod.env (chmod 600)
â âââ staging.env
âââ logs/
â âââ deploy-2026-10-20.log
âââ README.md
đĄ
Treat scripts like codeCommit the automation tree to Git. Review changes in pull requests. Lint with shellcheck. Test on a staging target before pointing at production. The same engineering standards that apply to the app apply to the pipeline that deploys it.
FTP is an old protocol, but FTP automation is a modern discipline. With a few hundred lines of script, a scheduler, and a webhook, you can turn the oldest file-transfer surface on the internet into a quiet, boring, dependable part of your delivery pipeline â which is exactly what good automation should be.