← All posts

Deploying a static website over SFTP with folder sync

To deploy a website over SFTP well, build the site into its own output folder, compare that folder with the web root on the server, and upload only what changed, with new assets going up before the pages that reference them. Copying the whole folder every time works for a tiny site, but it is slow, it hides mistakes, and it gives you no idea what actually changed. A folder sync with a review step gives you a deploy you can check before it happens.

Here is a process that works for any static site generator, whether the output is Astro, Hugo, Eleventy, Vite or hand-written HTML.

Step 1: build into a dedicated folder

Your deploy should come from the build output, never from the project root. The project root contains things that must not reach a public web server: .git, node_modules, .env files, drafts.

npm run build
ls dist

Check that the output folder is rebuilt from scratch each time (most generators empty it first). If old files linger in it, they will be deployed too.

Step 2: give the site its own folder on the server

The remote side should be a folder that holds only the built site, for example /var/www/example.com/public. If the same folder also holds things the server creates, such as user uploads, cache files or logs, any comparison between your build and the server will keep tripping over them. Move them elsewhere, or point the deploy at a narrower subfolder.

A dedicated deploy account with key-based login and no shell is a sensible companion. Connecting to SFTP with an SSH key walks through setting up the key.

What the command line offers

If the account has a shell and the server has rsync, this is the classic one-liner. Run it with --dry-run first to see what would change:

rsync -avz --delete --dry-run dist/ deploy@example.com:/var/www/example.com/public/

The trailing slash on dist/ means "the contents of dist", not the folder itself. --delete removes server files that are no longer in the build, which is powerful and exactly as dangerous as it sounds if the paths are wrong. The rsync documentation covers the many other options.

rsync needs to run a program on the server, so it does not work on SFTP-only accounts. There, the built-in sftp client can upload everything in batch mode:

sftp -b - deploy@example.com <<'EOF'
cd /var/www/example.com/public
put -r dist/*
EOF

That copies every file every time, with no comparison and no preview.

Order matters: assets before HTML

Most build tools give CSS and JavaScript content-hashed names, like app.3f9c1a.js, so browsers can cache them forever. Your new index.html refers to the new hashed files. If the HTML arrives first, visitors in that window get a page whose scripts and styles do not exist yet.

The fix is to deploy in two passes: the assets folder first, then everything else. Leave the old hashed files in place for a while afterwards. Someone with yesterday's HTML cached still needs yesterday's assets.

SFTP uploads are not atomic

An SFTP upload writes the file at its final path as the bytes arrive, so for a moment a visitor can be served a partly written file. For most small sites this is rarely noticeable. Where it matters, the usual answer is release folders and a symlink switch, which needs a shell on the server:

# upload the build to /var/www/example.com/releases/2026-09-16/, then:
cd /var/www/example.com
ln -s releases/2026-09-16 current.new && mv -T current.new current

The web server's document root points at current. mv -T (GNU coreutils) replaces the symlink in one rename, so every request sees either the old release or the new one, never a mixture.

Deploying a website over SFTP with Wharf's sync

Wharf's sync compares the two folders currently shown in its panes and shows you the plan before anything moves. For a deploy:

  1. Save the server as a site, with Remote path set to the web root so it opens there.
  2. Connect. Navigate the local pane into your dist folder and the remote pane into the web root.
  3. For the two-pass order above, go into the assets folder on both sides first and click Sync. Approve the plan, let it finish, then go back up a level on both sides and sync again.
  4. Read the Review Sync sheet before clicking Start Sync.

The review sheet lists files to upload, files to download, files to delete (only if you asked for that), conflicts, and folders to create, with a running total of how much data will move. Three things are worth knowing when you use it for deploys.

Sync works in both directions

Wharf's sync is two-way: a file that exists only on the server is listed for download into your local folder, not ignored. On a web root that holds only your site, that list should be empty or contain only stale files. If it shows server-generated files, the folder pair is wrong for a deploy; cancel and pick a narrower remote folder rather than pulling those files into your build.

Conflicts and timestamps

Files that exist on both sides but differ appear under conflicts, each showing both sides' size and date. The newer copy is pre-selected, and files whose dates are too close to call default to Skip. For a deploy, where your build is the source of truth, Resolve All › Use Local Everywhere sets every conflict to upload.

Wharf compares size and modification time (with two seconds' tolerance), not file contents, so a scan costs one listing per folder rather than reading every byte. After each upload it sets the server copy's modification time to match the local file, so files you have not changed since the last deploy show as already matching. A generator that rewrites every output file on each build gives them all new times, so expect more files in the list after a full rebuild.

Deletions are opt-in

By default nothing is deleted: a file missing on one side is copied to it. Ticking Also delete files removed on the other side in the review sheet turns deletion on, and the sheet then lists exactly which files would go. Wharf can only spot a deletion by comparing with the previous sync pass, so the first pass deletes nothing, and that record is not kept between launches. Files deleted on the server are gone for good; local ones go to the Trash. For deploys it is often simpler to leave deletions off and prune old assets occasionally. Why sync should not delete by default explains the reasoning.

A few more details: .DS_Store, .localized and Thumbs.db are ignored on both sides, other dotfiles such as .htaccess are included, and symlinks on the server are skipped rather than followed. Every upload is recorded in the Activity Log (⌘⇧L), which can be exported to CSV when someone asks what went live and when.

More on how the review works is on the sync section of the homepage, and the support page explains why a sync did not delete anything.