Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

24 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ftpsync

NPM Downloads NPM Version NPM License GitHub Workflow Status

Hash-based deploy over FTPS — no SSH, no mtime/size guessing.

ftpsync syncs a local directory to an FTP(S) server by comparing SHA-256 content hashes, so only genuinely changed files are uploaded. It keeps a small JSON state file on the server (.ftpsync-state.json) recording the hash of every deployed file. A single static binary, nothing to install on the target — ideal for CI/CD pipelines deploying to cheap shared hosting that only offers FTP.

Features

  • Content-hash diffing — SHA-256 of file contents, never mtime/size, so a git checkout or rebuild won't re-upload unchanged files.
  • Auto-init — on the first run against a populated server, it lists, downloads and hashes the existing files to build the initial state (no full re-upload).
  • Parallel uploads — configurable connection pool (-j).
  • Atomic uploads — files are sent to {path}.ftpsync-tmp then renamed onto the target, so a half-uploaded file never replaces a live one.
  • .ftpignore — gitignore-style filtering, plus --include/--exclude globs.
  • FTPS by default — explicit AUTH TLS via rustls (no system OpenSSL); --insecure-tls for self-signed certs.
  • Safe state handling — size cap (100 MB), schema + version checks, and path-traversal rejection. Paths containing control characters (which could inject commands on the FTP control channel) are refused outright.

Installation

npm

The binary is also published to npm; only the prebuilt binary for your platform is downloaded (via per-platform optionalDependencies, no post-install step):

npm install -g @ozzyczech/ftpsync
# or run on demand:
npx @ozzyczech/ftpsync --help

Pre-built binaries

Download the archive for your platform from the latest release, extract, and put ftpsync on your PATH:

curl -sSL https://github.com/OzzyCzech/ftpsync/releases/latest/download/ftpsync-x86_64-unknown-linux-musl.tar.gz | tar xz
sudo mv ftpsync /usr/local/bin/
ftpsync --version

From source (cargo)

cargo install --git https://github.com/OzzyCzech/ftpsync

Build locally

git clone https://github.com/OzzyCzech/ftpsync
cd ftpsync
cargo build --release        # -> target/release/ftpsync

For a fully static Linux binary (Alpine / scratch images):

rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

Quick start

# Deploy the current directory to /www on the server
ftpsync \
  --server ftp.example.com \
  --username deploy \
  --password 's3cret' \
  --server-dir /www

Prefer the FTPSYNC_PASSWORD environment variable so the password never appears in your shell history or process list:

export FTPSYNC_PASSWORD='s3cret'
ftpsync -s ftp.example.com -u deploy -r /www

Always preview first with --dry-run:

ftpsync -s ftp.example.com -u deploy -r /www --dry-run -v

For repeated deploys, commit the non-secret options to a .ftpsync.json so a run is just FTPSYNC_PASSWORD=… ftpsync.

Usage

ftpsync [OPTIONS] --server <SERVER> --username <USERNAME> --password <PASSWORD>

Required

Option Description
-s, --server <HOST> FTP server hostname
-u, --username <USER> FTP username
-p, --password <PASS> FTP password (or set FTPSYNC_PASSWORD)

Connection

Option Default Description
--port <PORT> 21 FTP port
--secure <MODE> explicit none | explicit | implicit
--insecure-tls off Skip TLS certificate validation (self-signed certs)
--passive <BOOL> true Passive mode
--timeout <SEC> 30 Connection/handshake timeout

Paths

Option Default Description
-l, --local-dir <DIR> . Local source directory
-r, --server-dir <DIR> / Remote target directory
--state-file <NAME> .ftpsync-state.json State file name on the server
--config <PATH> .ftpsync.json Config file to pre-fill options

Filters

Option Description
--include <GLOB> Glob to include (repeatable → whitelist mode)
--exclude <GLOB> Glob to exclude (repeatable)
--ignore-file <FILE> Path to .ftpignore (default .ftpignore)
--no-ignore-file Don't read .ftpignore

Behavior

Option Description
--no-auto-init Treat the server as empty on first run (upload everything). By default ftpsync hashes every remote file on first run to bootstrap state
--no-delete Don't delete remote files that are missing locally
--purge <DIR> Empty a remote directory after deploying, e.g. a cache (repeatable; the directory itself is kept). Local files inside a purge dir are skipped, not uploaded
--file-perms <OCTAL> chmod uploaded files, e.g. 0644 (best-effort via SITE CHMOD)
--dir-perms <OCTAL> chmod created directories, e.g. 0755 (best-effort via SITE CHMOD)
-j, --concurrency <N> Parallel uploads (default 4)
--dry-run Print actions without executing them
-v, --verbose / -q, --quiet More / less output

Examples

# Static site: deploy only the build output
ftpsync -s ftp.example.com -u deploy -r /www --include 'dist/**'

# Deploy a single subdirectory to a matching remote path
ftpsync -s ftp.example.com -u deploy \
        --local-dir build/theme \
        --server-dir /www/theme

# Exclude directories you don't manage
ftpsync -s ftp.example.com -u deploy -r /www \
        --exclude 'vendor/**' --exclude 'uploads/**'

# Empty a cache directory after deploying, and set file/dir permissions
ftpsync -s ftp.example.com -u deploy -r /www \
        --purge cache/views --file-perms 0644 --dir-perms 0755

# Self-signed certificate (e.g. some Czech shared hosts)
ftpsync -s ftp.example.com -u deploy -r /www --insecure-tls

# Faster deploy with more parallel connections
ftpsync -s ftp.example.com -u deploy -r /www -j 8

Configuration file

For repeated deploys you can commit a project's non-secret settings to a .ftpsync.json instead of retyping flags every run. It is optional: if the default .ftpsync.json is absent it is silently ignored, and you can point elsewhere with --config <PATH>. The file is looked up in the current working directory (no upward tree search), and it is never uploaded to the server.

Keys map 1:1 to the CLI flags (kebab-case), all optional:

{
  "server": "ftp.example.com",
  "port": 21,
  "username": "deploy",
  "secure": "explicit",
  "passive": true,
  "timeout": 30,
  "local-dir": ".",
  "server-dir": "/www",
  "state-file": ".ftpsync-state.json",
  "include": ["dist/**"],
  "exclude": ["vendor/**", "uploads/**"],
  "ignore-file": ".ftpignore",
  "no-delete": false,
  "purge": ["cache/views"],
  "file-perms": "0644",
  "dir-perms": "0755",
  "concurrency": 8
}

With that committed, a deploy is just:

FTPSYNC_PASSWORD='s3cret' ftpsync

Rules:

  • No password in the file. There is no password key; it must come from -p / FTPSYNC_PASSWORD, so it never lands in git. (Same for the per-run toggles --dry-run / --verbose / --quiet.)
  • Precedence is default → file → CLI. A CLI flag always overrides the file; the file overrides the built-in default.
  • List flags merge. include / exclude / purge from the file and the CLI are combined (the CLI's entries appended last), not replaced.
  • Unknown keys are errors, so a typo like "serverr" fails loudly instead of being silently ignored.

.ftpignore

Gitignore syntax, read from --local-dir by default:

node_modules/
*.log
!important.log
.git/
.env*
.DS_Store

State file

ftpsync stores .ftpsync-state.json in the remote --server-dir. Paths are POSIX and relative to --server-dir; hashes are SHA-256 of file contents. The format is shared with the Bun implementation so either tool can read the other's state:

{
  "version": 1,
  "tool": "ftpsync 0.1.1",
  "updated": "2026-06-02T15:00:00Z",
  "files": {
    "index.html": {
      "hash": "sha256:abc123…",
      "size": 4096,
      "uploaded": "2026-06-02T15:00:00Z"
    }
  }
}

Auto-init cost: the first run against a server without a state file downloads and hashes every remote file to build the baseline. For large sites (e.g. a full WordPress install) this can take a while — use --no-auto-init to skip it and upload everything instead.

How it works

  1. Discover local files (--include/--exclude + .ftpignore).
  2. Hash every local file with streaming SHA-256.
  3. Connect over FTPS and fetch .ftpsync-state.json.
  4. Auto-init if no state exists: list + download + hash remote files.
  5. Diff local hashes against the state → uploads (changed/new) and deletes (present in state, missing locally).
  6. Execute uploads in parallel (atomic temp + rename) and deletes.
  7. Commit the refreshed state file back to the server.

Use in CI/CD

GitHub Actions

deploy:
  runs-on: ubuntu-latest
  if: github.ref == 'refs/heads/main'
  steps:
    - uses: actions/checkout@v6
    - name: Install ftpsync
      run: |
        curl -sSL https://github.com/OzzyCzech/ftpsync/releases/latest/download/ftpsync-x86_64-unknown-linux-musl.tar.gz | tar xz
        sudo mv ftpsync /usr/local/bin/
    - name: Deploy
      env:
        FTPSYNC_PASSWORD: ${{ secrets.FTP_PASSWORD }}
      run: ftpsync -s "${{ secrets.FTP_HOST }}" -u "${{ secrets.FTP_USER }}" -r /www -j 8

GitLab CI

deploy:production:
  image: alpine:3.20
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  before_script:
    - wget -qO- https://github.com/OzzyCzech/ftpsync/releases/latest/download/ftpsync-x86_64-unknown-linux-musl.tar.gz | tar xz -C /usr/local/bin
  script:
    - ftpsync --server "$FTP_HOST" --username "$FTP_USER" --server-dir /www --concurrency 8
  variables:
    FTPSYNC_PASSWORD: "$FTP_PASSWORD"

Development

cargo fmt           # format
cargo clippy --all-targets -- -D warnings   # lint (CI is strict)
cargo test          # unit tests
cargo build --release

Tests cover hashing, state (de)serialization + path-traversal guards, the walker/ignore filters, config validation, and LIST-line parsing.

Releasing

Pushing a vX.Y.Z tag triggers .github/workflows/release.yml, which:

  1. creates the GitHub release,
  2. builds and attaches binaries for all targets (upload-assets),
  3. assembles and publishes the npm packages (publish-npm): one per-platform package (@ozzyczech/ftpsync-<os>-<cpu>) plus the @ozzyczech/ftpsync launcher (npm/build.mjs).

Publishing uses npm Trusted Publishing (OIDC) — no NPM_TOKEN secret. The job authenticates via its id-token and publishes with provenance. One-time setup on npmjs.com: for each package (@ozzyczech/ftpsync and the five @ozzyczech/ftpsync-<os>-<cpu>), add a Trusted Publisher pointing at the OzzyCzech/ftpsync repo and the release.yml workflow. Keep the version in Cargo.toml in sync with the tag.

build.mjs skips any package whose version is already on the registry, so re-running a release (or recovering from a partial failure) is safe. The very first publish of a brand-new package name can't use OIDC (a Trusted Publisher can only be added to an existing package) — bootstrap it once with a local npm login + node npm/build.mjs <version>, then configure the publishers.

Notes & guarantees

  • TLS via rustls (futures-rustls) — no system OpenSSL dependency.
  • Atomic uploads — temp file + rename, never a half-written live file.
  • Robust downloads — verified against the server-reported SIZE and retried with backoff + reconnect. Some FTP servers race the data-channel close against the 226 completion reply, which can otherwise yield a silently truncated transfer; ftpsync detects this and refuses to commit a corrupt state.
  • Passwords are never logged and read from FTPSYNC_PASSWORD when available.
  • Passive NAT workaround — in passive mode the data channel connects to the control host instead of the IP the server advertises in its PASV reply, so misconfigured/NATed servers (e.g. advertising 0.0.0.0) still work.
  • Deploy marker — a <state-file>.running marker is written while a deploy mutates the server and removed when it finishes, making an interrupted or overlapping run visible. It is advisory only: it surfaces concurrent deploys but does not prevent them (the check and write are not atomic over FTP).

This whole project was inspired by dg/ftp-deployment and git-ftp, thank you for your work!

License

MIT

About

Fast hash-based FTP/FTPS deploy tool in Rust — uploads only changed files, no SSH needed. Single static binary, installable via npm.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages