Repository files navigation

ftpsync

NPM DownloadsNPM VersionNPM LicenseGitHub 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

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

Connection

OptionDefaultDescription
--port <PORT>21FTP port
--secure <MODE>explicitnone | explicit | implicit
--insecure-tlsoffSkip TLS certificate validation (self-signed certs)
--passive <BOOL>truePassive mode
--timeout <SEC>30Connection/handshake timeout

Paths

OptionDefaultDescription
-l, --local-dir <DIR>.Local source directory
-r, --server-dir <DIR>/Remote target directory
--state-file <NAME>.ftpsync-state.jsonState file name on the server
--config <PATH>.ftpsync.jsonConfig file to pre-fill options

Filters

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

Behavior

OptionDescription
--no-auto-initTreat the server as empty on first run (upload everything). By default ftpsync hashes every remote file on first run to bootstrap state
--no-deleteDon'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-runPrint actions without executing them
-v, --verbose / -q, --quietMore / 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. A transfer that fails transiently — a 4xx reply or a dropped connection, which shared hosting hands out freely under sustained load — is retried on a fresh connection with backoff.
  7. Commit the refreshed state file back to the server. This happens even when the run fails, so the state always describes what is actually deployed and a re-run only picks up the files that didn't make it.

Use in CI/CD

GitHub Actions

deploy:
runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- uses: actions/checkout@v6
- name: Install ftpsyncrun: | 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: Deployenv:
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.20rules:
- 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/binscript:
- ftpsync --server "$FTP_HOST" --username "$FTP_USER" --server-dir /www --concurrency 8variables:
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.
  • EPSV over IPv6 — PASV can only encode an IPv4 address (RFC 2428), and servers reached over IPv6 answer it with a tuple no client can parse. When the control connection is IPv6, ftpsync uses EPSV instead, which returns just a port. IPv4 keeps using PASV.
  • 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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

ftpsync

NPM DownloadsNPM VersionNPM LicenseGitHub 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

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

Connection

OptionDefaultDescription
--port <PORT>21FTP port
--secure <MODE>explicitnone | explicit | implicit
--insecure-tlsoffSkip TLS certificate validation (self-signed certs)
--passive <BOOL>truePassive mode
--timeout <SEC>30Connection/handshake timeout

Paths

OptionDefaultDescription
-l, --local-dir <DIR>.Local source directory
-r, --server-dir <DIR>/Remote target directory
--state-file <NAME>.ftpsync-state.jsonState file name on the server
--config <PATH>.ftpsync.jsonConfig file to pre-fill options

Filters

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

Behavior

OptionDescription
--no-auto-initTreat the server as empty on first run (upload everything). By default ftpsync hashes every remote file on first run to bootstrap state
--no-deleteDon'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-runPrint actions without executing them
-v, --verbose / -q, --quietMore / 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. A transfer that fails transiently — a 4xx reply or a dropped connection, which shared hosting hands out freely under sustained load — is retried on a fresh connection with backoff.
  7. Commit the refreshed state file back to the server. This happens even when the run fails, so the state always describes what is actually deployed and a re-run only picks up the files that didn't make it.

Use in CI/CD

GitHub Actions

deploy:
runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- uses: actions/checkout@v6
- name: Install ftpsyncrun: | 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: Deployenv:
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.20rules:
- 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/binscript:
- ftpsync --server "$FTP_HOST" --username "$FTP_USER" --server-dir /www --concurrency 8variables:
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.
  • EPSV over IPv6 — PASV can only encode an IPv4 address (RFC 2428), and servers reached over IPv6 answer it with a tuple no client can parse. When the control connection is IPv6, ftpsync uses EPSV instead, which returns just a port. IPv4 keeps using PASV.
  • 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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ftpsync

NPM DownloadsNPM VersionNPM LicenseGitHub 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

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

Connection

OptionDefaultDescription
--port <PORT>21FTP port
--secure <MODE>explicitnone | explicit | implicit
--insecure-tlsoffSkip TLS certificate validation (self-signed certs)
--passive <BOOL>truePassive mode
--timeout <SEC>30Connection/handshake timeout

Paths

OptionDefaultDescription
-l, --local-dir <DIR>.Local source directory
-r, --server-dir <DIR>/Remote target directory
--state-file <NAME>.ftpsync-state.jsonState file name on the server
--config <PATH>.ftpsync.jsonConfig file to pre-fill options

Filters

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

Behavior

OptionDescription
--no-auto-initTreat the server as empty on first run (upload everything). By default ftpsync hashes every remote file on first run to bootstrap state
--no-deleteDon'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-runPrint actions without executing them
-v, --verbose / -q, --quietMore / 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. A transfer that fails transiently — a 4xx reply or a dropped connection, which shared hosting hands out freely under sustained load — is retried on a fresh connection with backoff.
  7. Commit the refreshed state file back to the server. This happens even when the run fails, so the state always describes what is actually deployed and a re-run only picks up the files that didn't make it.

Use in CI/CD

GitHub Actions

deploy:
runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- uses: actions/checkout@v6
- name: Install ftpsyncrun: | 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: Deployenv:
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.20rules:
- 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/binscript:
- ftpsync --server "$FTP_HOST" --username "$FTP_USER" --server-dir /www --concurrency 8variables:
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.
  • EPSV over IPv6 — PASV can only encode an IPv4 address (RFC 2428), and servers reached over IPv6 answer it with a tuple no client can parse. When the control connection is IPv6, ftpsync uses EPSV instead, which returns just a port. IPv4 keeps using PASV.
  • 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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ftpsync

NPM DownloadsNPM VersionNPM LicenseGitHub 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

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

Connection

OptionDefaultDescription
--port <PORT>21FTP port
--secure <MODE>explicitnone | explicit | implicit
--insecure-tlsoffSkip TLS certificate validation (self-signed certs)
--passive <BOOL>truePassive mode
--timeout <SEC>30Connection/handshake timeout

Paths

OptionDefaultDescription
-l, --local-dir <DIR>.Local source directory
-r, --server-dir <DIR>/Remote target directory
--state-file <NAME>.ftpsync-state.jsonState file name on the server
--config <PATH>.ftpsync.jsonConfig file to pre-fill options

Filters

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

Behavior

OptionDescription
--no-auto-initTreat the server as empty on first run (upload everything). By default ftpsync hashes every remote file on first run to bootstrap state
--no-deleteDon'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-runPrint actions without executing them
-v, --verbose / -q, --quietMore / 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. A transfer that fails transiently — a 4xx reply or a dropped connection, which shared hosting hands out freely under sustained load — is retried on a fresh connection with backoff.
  7. Commit the refreshed state file back to the server. This happens even when the run fails, so the state always describes what is actually deployed and a re-run only picks up the files that didn't make it.

Use in CI/CD

GitHub Actions

deploy:
runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- uses: actions/checkout@v6
- name: Install ftpsyncrun: | 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: Deployenv:
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.20rules:
- 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/binscript:
- ftpsync --server "$FTP_HOST" --username "$FTP_USER" --server-dir /www --concurrency 8variables:
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.
  • EPSV over IPv6 — PASV can only encode an IPv4 address (RFC 2428), and servers reached over IPv6 answer it with a tuple no client can parse. When the control connection is IPv6, ftpsync uses EPSV instead, which returns just a port. IPv4 keeps using PASV.
  • 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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

ftpsync

NPM DownloadsNPM VersionNPM LicenseGitHub 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

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

Connection

OptionDefaultDescription
--port <PORT>21FTP port
--secure <MODE>explicitnone | explicit | implicit
--insecure-tlsoffSkip TLS certificate validation (self-signed certs)
--passive <BOOL>truePassive mode
--timeout <SEC>30Connection/handshake timeout

Paths

OptionDefaultDescription
-l, --local-dir <DIR>.Local source directory
-r, --server-dir <DIR>/Remote target directory
--state-file <NAME>.ftpsync-state.jsonState file name on the server
--config <PATH>.ftpsync.jsonConfig file to pre-fill options

Filters

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

Behavior

OptionDescription
--no-auto-initTreat the server as empty on first run (upload everything). By default ftpsync hashes every remote file on first run to bootstrap state
--no-deleteDon'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-runPrint actions without executing them
-v, --verbose / -q, --quietMore / 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. A transfer that fails transiently — a 4xx reply or a dropped connection, which shared hosting hands out freely under sustained load — is retried on a fresh connection with backoff.
  7. Commit the refreshed state file back to the server. This happens even when the run fails, so the state always describes what is actually deployed and a re-run only picks up the files that didn't make it.

Use in CI/CD

GitHub Actions

deploy:
runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- uses: actions/checkout@v6
- name: Install ftpsyncrun: | 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: Deployenv:
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.20rules:
- 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/binscript:
- ftpsync --server "$FTP_HOST" --username "$FTP_USER" --server-dir /www --concurrency 8variables:
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.
  • EPSV over IPv6 — PASV can only encode an IPv4 address (RFC 2428), and servers reached over IPv6 answer it with a tuple no client can parse. When the control connection is IPv6, ftpsync uses EPSV instead, which returns just a port. IPv4 keeps using PASV.
  • 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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ftpsync

NPM DownloadsNPM VersionNPM LicenseGitHub 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

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

Connection

OptionDefaultDescription
--port <PORT>21FTP port
--secure <MODE>explicitnone | explicit | implicit
--insecure-tlsoffSkip TLS certificate validation (self-signed certs)
--passive <BOOL>truePassive mode
--timeout <SEC>30Connection/handshake timeout

Paths

OptionDefaultDescription
-l, --local-dir <DIR>.Local source directory
-r, --server-dir <DIR>/Remote target directory
--state-file <NAME>.ftpsync-state.jsonState file name on the server
--config <PATH>.ftpsync.jsonConfig file to pre-fill options

Filters

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

Behavior

OptionDescription
--no-auto-initTreat the server as empty on first run (upload everything). By default ftpsync hashes every remote file on first run to bootstrap state
--no-deleteDon'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-runPrint actions without executing them
-v, --verbose / -q, --quietMore / 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. A transfer that fails transiently — a 4xx reply or a dropped connection, which shared hosting hands out freely under sustained load — is retried on a fresh connection with backoff.
  7. Commit the refreshed state file back to the server. This happens even when the run fails, so the state always describes what is actually deployed and a re-run only picks up the files that didn't make it.

Use in CI/CD

GitHub Actions

deploy:
runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- uses: actions/checkout@v6
- name: Install ftpsyncrun: | 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: Deployenv:
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.20rules:
- 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/binscript:
- ftpsync --server "$FTP_HOST" --username "$FTP_USER" --server-dir /www --concurrency 8variables:
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.
  • EPSV over IPv6 — PASV can only encode an IPv4 address (RFC 2428), and servers reached over IPv6 answer it with a tuple no client can parse. When the control connection is IPv6, ftpsync uses EPSV instead, which returns just a port. IPv4 keeps using PASV.
  • 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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ftpsync

NPM DownloadsNPM VersionNPM LicenseGitHub 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

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

Connection

OptionDefaultDescription
--port <PORT>21FTP port
--secure <MODE>explicitnone | explicit | implicit
--insecure-tlsoffSkip TLS certificate validation (self-signed certs)
--passive <BOOL>truePassive mode
--timeout <SEC>30Connection/handshake timeout

Paths

OptionDefaultDescription
-l, --local-dir <DIR>.Local source directory
-r, --server-dir <DIR>/Remote target directory
--state-file <NAME>.ftpsync-state.jsonState file name on the server
--config <PATH>.ftpsync.jsonConfig file to pre-fill options

Filters

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

Behavior

OptionDescription
--no-auto-initTreat the server as empty on first run (upload everything). By default ftpsync hashes every remote file on first run to bootstrap state
--no-deleteDon'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-runPrint actions without executing them
-v, --verbose / -q, --quietMore / 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. A transfer that fails transiently — a 4xx reply or a dropped connection, which shared hosting hands out freely under sustained load — is retried on a fresh connection with backoff.
  7. Commit the refreshed state file back to the server. This happens even when the run fails, so the state always describes what is actually deployed and a re-run only picks up the files that didn't make it.

Use in CI/CD

GitHub Actions

deploy:
runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- uses: actions/checkout@v6
- name: Install ftpsyncrun: | 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: Deployenv:
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.20rules:
- 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/binscript:
- ftpsync --server "$FTP_HOST" --username "$FTP_USER" --server-dir /www --concurrency 8variables:
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.
  • EPSV over IPv6 — PASV can only encode an IPv4 address (RFC 2428), and servers reached over IPv6 answer it with a tuple no client can parse. When the control connection is IPv6, ftpsync uses EPSV instead, which returns just a port. IPv4 keeps using PASV.
  • 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

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

ftpsync

NPM DownloadsNPM VersionNPM LicenseGitHub 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

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

Connection

OptionDefaultDescription
--port <PORT>21FTP port
--secure <MODE>explicitnone | explicit | implicit
--insecure-tlsoffSkip TLS certificate validation (self-signed certs)
--passive <BOOL>truePassive mode
--timeout <SEC>30Connection/handshake timeout

Paths

OptionDefaultDescription
-l, --local-dir <DIR>.Local source directory
-r, --server-dir <DIR>/Remote target directory
--state-file <NAME>.ftpsync-state.jsonState file name on the server
--config <PATH>.ftpsync.jsonConfig file to pre-fill options

Filters

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

Behavior

OptionDescription
--no-auto-initTreat the server as empty on first run (upload everything). By default ftpsync hashes every remote file on first run to bootstrap state
--no-deleteDon'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-runPrint actions without executing them
-v, --verbose / -q, --quietMore / 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. A transfer that fails transiently — a 4xx reply or a dropped connection, which shared hosting hands out freely under sustained load — is retried on a fresh connection with backoff.
  7. Commit the refreshed state file back to the server. This happens even when the run fails, so the state always describes what is actually deployed and a re-run only picks up the files that didn't make it.

Use in CI/CD

GitHub Actions

deploy:
runs-on: ubuntu-latestif: github.ref == 'refs/heads/main'steps:
- uses: actions/checkout@v6
- name: Install ftpsyncrun: | 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: Deployenv:
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.20rules:
- 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/binscript:
- ftpsync --server "$FTP_HOST" --username "$FTP_USER" --server-dir /www --concurrency 8variables:
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.
  • EPSV over IPv6 — PASV can only encode an IPv4 address (RFC 2428), and servers reached over IPv6 answer it with a tuple no client can parse. When the control connection is IPv6, ftpsync uses EPSV instead, which returns just a port. IPv4 keeps using PASV.
  • 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