FTP DeployIncremental FTP/FTPS/SFTP deploy for VS Code, built around multiple servers as a first-class concern.
Getting startedNothing needs to be hand-written.
Repeat step 2 for every server. Right-click any profile to deploy to it directly, regardless of which one is active. The config file stays readable and hand-editable — the editor writes through
ProfilesA profile is one server. Names must be unique, because the name is the key for two separate things:
Mark a profile Commands resolve their target in this order:
right-click → active profile → RulesA rule is a named pipeline: pick the files once, then list the steps to run on them. Running the rule executes every step in order with no further prompting — that is the whole point of it. Add one from a profile → New rule. The editor has three parts: where it deploys, which files it acts on, and what it does to them. Choosing the filesOne selection per rule, applied to whichever side each step reads from.
Every list carries the same toolbar: filter by name, Expand all, Collapse, Reload, Select all, Unselect all. Ticking a folder writes Steps
Each step says what it will cost before you run it — an upload with no backup step above it says so, and a delete paired with a backup says that the server only loses files already saved to your disk. Any step can narrow the rule's selection further under Files for this step:
a folder tree of exactly what that step will touch, where unticking affects that
step alone. "Back up everything, then delete all of it except Cleanup steps that always runAny step except Delete local files can be marked Always run this step, even if an earlier one fails. Marked steps move to the end of the rule and run there whatever happened — success, failure, or a run you cancelled. This exists for pipelines that take something offline before they work. On IIS, a running app pool holds a lock on the DLLs you are about to replace, so the usual shape is:
Without step 4 marked, a failed upload in step 3 stops the rule and leaves the site showing the offline page until somebody notices. With it marked, the site comes back whether the deploy worked or not. The same shape covers anything that has to be undone: a maintenance flag, a firewall rule opened by a command step, a service stopped before a copy.
PresetsOne click each: Upload changed · Upload with override · Back up server · Back up then upload · Back up, delete, upload · Build, back up, delete, upload · Back up local then delete local · Upload + mirror local. One rule for many targetsWhen the same rule repeats across servers, sites or apps, write it once and let
it expand. A rule with a
That is three rules, and adding a fourth site is one word. More to the point, changing a step changes it everywhere at once — the reason to template is maintenance, not typing. Lists are named by you and hold either plain strings or objects. Objects carry more than one field per item:
…addressed as Several lists at once produce every combination, so a rule can cover a grid of sites and apps from one definition:
Three sites × two apps is six rules. A Substitution reaches every string in the rule — name, paths, globs, and every field of every step, commands included. A few rules keep it predictable:
Generated rules appear everywhere ordinary ones do: the dashboard, Run Rule, Dry Run, Run All, and the tick boxes. Saving an edit to one is refused, and the refusal names the template it came from: there is one definition behind all of them, so the template is where a change belongs. Edit it and every rule it produces follows. That is the point of writing it once. Building one without touching the config fileThe rule editor opens with Repeat for each. Tick it and the rule becomes a template:
Add another variable for a grid: sites × apps, every combination. Saving expands the template first. An unknown variable, a name that does not vary, or a grid over the cap is refused with the reason — a template that cannot expand would take the whole config down with it, and every rule off the dashboard. Linked, or just a head startSaving a template asks which you want:
You do not have to decide up front, because of the next part. Detaching one ruleDetach this rule, on any generated rule, writes that one out in full as an ordinary editable rule. The template carries on producing the others. Start linked; detach the one that turns out to need something the rest don't. There is no third list of exceptions to maintain: a rule written out in full simply wins over a template that would produce the same name, so the template stops producing it — and deleting the detached rule puts it back. Looking at what a template producedOpening a generated rule shows it resolved and read-only — real paths, real profile, exactly what that site will do — with the parts that came from its row highlighted, and a legend saying which token supplied them. Run and Dry run work as normal; Edit template is in the banner and the footer. Nothing on it can be edited, and not only the fixed half: the config holds one entry, the template, and the generated rule is re-derived on every load. There is nowhere to put a change to this rule, so a partly-editable form would either do nothing or silently change every sibling. Anything that differs per row belongs in a column. For a genuine one-off, delete that row and Duplicate any generated rule — the copy is a real, standalone, fully editable rule. On the rules screen, everything one template produced is gathered under a heading naming it, with an Edit template link — so twenty rules say "there is one template, and it is here" rather than saying "from a template" twenty times. The rule that keeps "back up then delete" safe
At run time it goes further. Each step records which files it verified landed
on the server (size-checked, manifest-recorded).
So a half-failed transfer can never take your local copy with it. Running
The same bar carries a menu of everything else a selection can do:
Each says what it had to leave out rather than quietly doing less — a rule a template generated (edit the template instead), one that had already gone, one already in the state you asked for. A switched-off rule can still be ticked, so you can switch it back on from here; running is the only action that skips it. A rule already on the deployment queue cannot be ticked at all, because it is mid-flight. Everything runs on one queue: one rule at a time, in the order the config lists them — not the order you ticked them, so a rule that builds still runs before the rule that uploads what it built. A failure holds the rest back rather than deploying on top of it. A batch is approved once. The prompt names every rule, the server each one goes to, and marks the ones that delete files or run a shell command; approve it and the whole batch runs unattended. A rule run on its own still gets the full review screen, and a queued rule whose plan grew while it waited still stops and asks — an approval given ten minutes ago cannot cover a deletion that has since got bigger. Disable a rule to keep it but skip it in Run All — a disabled rule cannot be ticked either, and the confirmation names anything it left out rather than dropping it quietly. All the rules on a profileA profile card shows its first two rules. View all opens the rest on a page of their own — three across, with its own search and the same select-and-run — rather than growing the card until it pushes every other profile off screen. Also on the palette as FTP Deploy: All Rules For This Profile. Repeating a ruleDuplicate rule — the icon on every row — writes a copy named For anything repetitive, use a rule template instead. Thirty-two sites with a UI and an API each is 128 copies to maintain, where every change has to be made 128 times and the one you miss is a live site; the same estate is one template that expands into 128 rules, and one edit changes all of them. Database deploymentA developer changes something on their local database and now dev needs the same change. Instead of pasting the script into SSMS by hand, commit it — and let the rule that deploys the site apply it too, in the same approved run. Set up the database on a profileA profile answers "where do the files go". Open it and turn on Database to have it also answer "and which database is this environment":
The password lives in the OS keychain, never in this file. A profile with no
Where the scripts liveOne folder per release, numbered scripts inside it:
FTP Deploy: New Database Script creates the next correctly numbered file in the newest version folder and opens it, so the numbering never has to be worked out by hand. Version folders run in version order and the files inside them in number order.
That is a real rule, not an accident of sorting: as plain text The rule
Order matters, which is why these are steps in the pipeline rather than something beside it. Schema changes usually have to land before the code that depends on them; a dropped column has to land after. Both are one drag in the editor. And if a script fails, the upload never runs — the site stays on the version that matches the schema it actually has. What counts as a new scriptA journal table in the target database, created on first run. Not file dates, and not the local manifest: the question is whether this database has had the script, and that has to stay true when a colleague or a build agent deploys between two of your runs. Test connection never creates it. It reports whether the table is there and whether the account could create one, and if it is missing it explains what the table is for and offers a button. Otherwise the first run creates it, and the confirmation says so before you approve. A dry run never creates it at all.
Most deploys change nothing, and stay quietWith no pending scripts the step reports "nothing to apply", asks for no confirmation, and the file deploy carries on untouched. The database backup skips itself for the same reason — otherwise a rule run five times an hour writes five full backups of a database nothing touched. FTP Deploy: Database Status answers "is there anything to run?" without arming a deploy: journal count, highest applied version, and the pending list in the order it would run. Options on the step
One thing to know about database backupsEvery file backup in this extension is downloaded to your machine, because a
copy living on the machine it protects is not a backup. A database cannot honour
that: SQL Server's There are no down-scripts. If a migration succeeds but is wrong, the backup is the only way back. Deploying without a server: the
|
| This folder | This machine | |
|---|---|---|
| Profiles and rules | .vscode/ftp-deploy.json |
~/.ftp-deploy/ftp-deploy.json |
| Upload state | .vscode/.ftp-deploy-manifest.json |
~/.ftp-deploy/manifest.json |
| Passwords | keychain, per folder | keychain, once per machine |
| Active profile, run history | per folder | shared |
Switching offers to copy the current folder's config across, and leaves the other file untouched — switching back loses nothing. Passwords are namespaced per scope, so set them once after a first switch.
Rules stored for the machine should use absolute local paths: there is no one folder for a relative path to resolve against, and they may run in a window with no folder open at all.
How change detection works
Deploy compares local files against the manifest, not against the server.
FTP exposes no file hash, and its timestamps drift with server timezone and DST,
so remote metadata cannot reliably answer "did this change?". Instead, every
confirmed upload is recorded as sha256 + size + timestamp. The next run hashes
locally and uploads only what differs.
Commit .vscode/.ftp-deploy-manifest.json to git so every machine deploys the
same incremental set. Delete a profile's section — or run Reset Manifest —
to force a full re-upload.
Backups
A Back up step copies files into a timestamped folder on your disk —
2026-08-17_143052/ — before anything overwrites or deletes them. It can copy
either side: the server files about to be replaced, or your local files.
- The folder it writes into comes from the step, or from
localBackupRootwhen the step leaves it blank. snapshotFolderFormatnames the subfolder. Tokens:YYYYMMDDHHmmssRULE.snapshotWriteReceiptadds aMANIFEST.txtlisting every file's hash and size, so a backup can be verified or restored without this extension.
Every step gets a folder of its own. The name is claimed, not assumed: if
it is already taken, the next one gets a -2 suffix. This matters because a
queue runs rules back to back, so several can resolve to the same clock name —
and two backups sharing a folder do not fail, they interleave, leaving
something that looks complete and is a mixture.
When several rules share one backup root, put RULE in the format —
RULE_YYYY-MM-DD_HHmmss — so each folder says which rule wrote it. Twenty-five
folders named only by the clock say nothing about which site each came from.
Backups are always complete, never incremental, and are never written to the server. Nothing prunes them — delete old folders yourself.
Safety
- Dry run first.
confirmBeforeDeploy(default on) shows the plan, then asks for a modal confirmation. - Atomic writes. Every file uploads to
<name>.partand is renamed into place. A dropped connection leaves an orphaned.part, never a truncatedweb.configserving 500s. - A dropped connection is reopened, not retried against. The connection is released before a command or database step — hosts close a session that goes quiet for a couple of minutes, and a build is quiet for longer — and reopened by the next step that needs it. A session lost mid-transfer is reconnected on the retry.
- Size verification after each transfer, before the manifest records it.
- Manifest written per file, not at the end — an interrupted run leaves a manifest describing exactly what actually landed, and the next run resumes.
- Deletes are opt-in.
mirrorDeletesisfalseby default. Files gone locally are reported as orphans and left alone. - An approval covers what it was given for. A queued rule's plan is rebuilt just before it runs, because the rules ahead of it may have changed what it matches. If it now deletes more than it did when you approved it, the queue stops and asks again rather than treating the old yes as covering the new number.
- Excludes always beat includes, plus a built-in default exclude list
(
node_modules,.git,dist,.env,*.log…). - Secrets live in the OS keychain, never in the config file.
Settings
FTP Deploy: Open Settings, or search ftpDeploy in the Settings UI.
Per-server details live in profiles; these are the behaviour knobs.
| Setting | Default | What it does |
|---|---|---|
configScope |
workspace |
workspace = this folder, global = ~/.ftp-deploy/ for the whole machine. |
configPath |
.vscode/ftp-deploy.json |
Where profiles live, in workspace scope. |
manifestPath |
.vscode/.ftp-deploy-manifest.json |
Where upload state lives. |
showStatusBar |
true |
Active profile in the status bar. |
confirmBeforeDeploy |
true |
Modal confirmation before transferring. |
openPlanInEditor |
false |
Also open the plan as a text document. |
verifySizeAfterUpload |
true |
Check remote byte count before recording. |
confirmDeletes |
true |
Separate second prompt for remote deletions. |
retries |
3 |
Per-file attempts; backoff 2s/4s/8s. |
commandTimeoutMs |
600000 |
How long a command step may run. |
listConcurrency |
4 |
Connections the rule editor lists server folders over. 1 if the host refuses extra sessions. |
uploadConcurrency |
4 |
Connections a deploy uploads over. Each file is several round trips whatever its size, so a few sessions taking turns cut the wall clock roughly in proportion. 1 if the host limits logins per account. |
connectTimeoutMs |
20000 |
Connection timeout. |
maxFileSizeMb |
0 |
Skip files over this size. 0 = no limit. |
useDefaultExcludes |
true |
Apply the global exclude list. |
defaultExcludes |
(11 globs) | Excluded from every profile. Fully editable. |
followSymlinks |
false |
Follow links when scanning. |
localBackupRoot |
(empty) | Default folder for back up steps that name none. |
snapshotFolderFormat |
YYYY-MM-DD_HHmmss |
Name of each backup subfolder. Tokens YYYY MM DD HH mm ss RULE. |
snapshotWriteReceipt |
true |
Write MANIFEST.txt into each backup. |
Config reference
For hand-editing .vscode/ftp-deploy.json. It accepts // and /* */ comments.
| Key | Required | Notes |
|---|---|---|
name |
✅ | Unique. Keys the credential and manifest section. |
protocol |
✅ | sftp | ftp | ftps | local |
host / username |
✅ | Not used by local, which addresses a folder. |
remoteRoot |
✅ | Absolute remote path, or a folder path when protocol is local. |
port |
Defaults to 22 (sftp) or 21. Ignored by local. |
|
privateKeyPath |
SFTP only. Passphrase goes in the keychain. | |
localRoot |
Workspace-relative. Defaults to the workspace root. | |
include / exclude |
Globs. Include builds the set, exclude subtracts. | |
environment |
development | staging | production. Colours the badge. |
|
mirrorDeletes |
Default false. |
|
secureRejectUnauthorized |
ftps only. Leave true unless you know why. |
|
database |
The database for this environment. Omit for a files-only profile. |
database
| Key | Required | Notes |
|---|---|---|
engine |
✅ | sqlserver | mysql | postgres |
host |
✅ | May carry a SQL Server instance: DEVSQL01\SQLEXPRESS. |
database |
✅ | The database scripts are applied to. |
username |
Omit for integrated auth. Nothing is prompted or stored. | |
port |
Defaults to 1433 / 3306 / 5432. | |
encrypt |
Default true. |
|
trustServerCertificate |
For a self-signed certificate on an internal server. | |
journalTable |
Default dbo.__FtpDeployJournal. Created on first run. |
|
connectTimeoutSeconds |
Default 15. |
Known limits
- Concurrency is per session, not per file. Both FTP and SFTP clients
drive a single channel, so
uploadConcurrencyopens that many sessions and each carries one file at a time. A host that caps logins per account caps the speed-up too; the log says when an extra session was refused. - Depth is not parallelisable. A folder must be listed before its children are known, so a deep chain of single folders is one round trip at a time however many connections are open. Wide trees — a resources folder per locale — are where a pool pays.
- No scheduling. An extension only runs while VS Code is open; there is no background activation.
- No restore/download yet.
- No down-scripts. A migration that succeeds but is wrong can only be undone
from the backup. That is why
dbBackupis worth keeping in the rule even though the schema marks it optional. - The database has to be reachable from this machine. Normally true of dev and usually false of production. A firewalled production database is a good reason for the rule that targets it to live on a build agent rather than a laptop — and a laptop is a poor place to run a forty-minute migration anyway.
- No schema comparison. The scripts are written by you and reviewed in the
pull request. If you want a generated diff, a
commandstep runningSqlPackage.exe /Action:Scriptcan write one into a version folder for thesqlstep to apply — which keeps it reviewable on disk rather than executing straight into a database.
Building from source
npm install
npm run package # runs the tests, then builds ftp-deploy-<version>.vsix
code --install-extension ftp-deploy-1.0.0.vsix --force
Reload the window afterwards. F5 launches an Extension Development Host
instead, and npm run watch rebuilds on save.
src/
config/ types, settings, JSONC read + write, SecretStorage
core/ scanner (globs), hash, manifest, plan, rule engine
transport/ Transport interface, sftp + ftp impls, atomicPut, retry
commands/ one file per user-facing action
ui/ dashboard, profile and rule editors, deployment panel
core/ and transport/ are free of vscode imports on purpose — that is what
keeps a future CLI possible.
License
MIT
