Accept SFTP uploads in Cloudflare Workers, Vercel or Lambda
Your customers upload over SFTP. Your function gets a signed webhook with a download link.
The problem. Sound familiar?
Cloudflare Workers, Vercel and Lambda can't listen on port 22 or keep files on a disk.
Your customers or partners still insist on sending files over SFTP.
Running a VM just for SFTP means patching, monitoring and storage you didn't want to own.
How it works with CloudFile, in about a minute.
- Step 1
Create a server and users
One user per customer of your app, created in the dashboard.
customer-42 SFTP password + key - Step 2
Add a webhook listener
Point it at your function's URL and choose the events.
upload → https://api.example.com/sftp - Step 3
Handle it in your function
Verify the signature and fetch the file from the presigned URL. No disk and no long-running process.
POST /sftp /reports/q3.csv 200 42 ms
What you get, and nothing you have to run.
Fits any runtime
Workers, Vercel and Netlify functions, Lambda, Cloud Run, Fly. Anything that can receive an HTTPS POST.
No file in the request
The webhook is small JSON with a presigned download URL, so you stay within body-size and memory limits.
Signed with HMAC-SHA256
Verify with the Web Crypto API in a few lines. Stale timestamps are easy to reject.
Retries with fresh links
If your function fails, every retry carries a new download URL that still works.
Filters at the source
Only call your function for the paths, users and events you care about.
EU data residency
Files are stored in the EU, which matters when your app serves European customers.
A Cloudflare Worker that receives uploads. Uses only the Web Crypto API, so it runs unchanged on Workers, Vercel Edge and Deno.
export default {
async fetch(request: Request, env: { CLOUDFILE_SECRET: string; BUCKET: R2Bucket }) {
const body = await request.text();
const header = request.headers.get("X-CloudFile-Signature") ?? "";
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return new Response("stale", { status: 401 });
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(env.CLOUDFILE_SECRET),
{ name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);
const sig = Uint8Array.from(v1.match(/../g)!.map((h) => parseInt(h, 16)));
const ok = await crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(`${t}.${body}`));
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body);
const file = await fetch(event.download_url);
await env.BUCKET.put(event.full_path.slice(1), file.body);
return new Response("ok");
},
};Questions about this setup.
Is the file sent in the webhook body?
No. The webhook carries metadata and a download_url that's valid for an hour. Fetch the file with a plain GET when you're ready.
My function times out on big files. What can I do?
Respond 200 quickly and process the file in a background task or queue, such as waitUntil on Workers or a queue on Lambda. The link stays valid for an hour, and retries get a fresh one.
Can I create SFTP users from my app?
Today users are managed in the dashboard. A public API for users is on our roadmap.
Can webhooks reach a private network or localhost?
No. Webhook URLs must use HTTPS and resolve to a public address.
More in the docs: Webhooks, payload and signatures · Quickstart
Other jobs for the same server.
File exchange with partners
One login and one private folder per partner, with a notification on every upload.
Learn moreEDI and ERP integrations
Accept orders, invoices and stock feeds over SFTP, then process each file the moment it lands.
Learn moreScanners, cameras and devices
Point scanners, cameras and NAS boxes at a cloud FTP server instead of a PC under the desk.
Learn moreRansomware-proof backups
Back up with a login that can add files but not delete or replace them, on top of a versioned bucket you own.
Learn more