Skip to content
HexaTransfer
Back to blog
Technical Deep Dives

Create Your Own File Transfer Service: Dev Tutorial

Build a file transfer service from scratch. Backend API design, storage integration, and user interface development tutorial.

To build your own file transfer service, you need five pieces: a frontend that slices and optionally encrypts files in the browser, a REST API that mints upload sessions and presigns storage URLs, S3-compatible object storage (Cloudflare R2, Backblaze B2, or MinIO), a small metadata database (SQLite or Postgres), and a TLS 1.3 reverse proxy. With Node 22, Fastify, and Vite you can ship a WeTransfer-style service in about 600 lines of code and host the whole thing for $5-15 a month at moderate traffic.

Choosing the Storage Layer First

Storage economics make or break a file transfer service. AWS S3 charges $0.09 per GB egress, meaning a popular 2 GB download shared 1,000 times costs you $180 in bandwidth alone. Cloudflare R2 charges zero for egress and $0.015 per GB stored. Backblaze B2 charges $0.01 per GB egress and $0.006 per GB stored. For a free-tier transfer service, R2 is the obvious pick. MinIO lets you self-host S3 semantics on your own hardware, which works if you already have bandwidth and disks sitting idle.

Use presigned URLs so clients upload and download straight from storage, never through your server. This keeps your API CPU-bound instead of bandwidth-bound.

Sketching the API Surface

Keep the API small. Five endpoints cover 95% of what you need:

  • POST /sessions — create an upload session, return ID and presigned multipart URLs
  • POST /sessions/:id/complete — finalize a multipart upload, return share URL
  • GET /shares/:id — return file metadata (size, expiry, downloads remaining)
  • GET /shares/:id/download — return a presigned download URL
  • DELETE /shares/:id — let the owner revoke early (requires a revocation token)

Rate-limit POST /sessions aggressively — 20 per IP per hour is plenty. All endpoints return JSON, use Cache-Control: no-store, and set Content-Security-Policy on the HTML pages.

Wiring Up the Backend With Fastify

Fastify gives you JSON schema validation, sensible logging, and 30k req/s per core without trying. A minimal POST /sessions:

fastify.post('/sessions', {
  schema: {
    body: {
      type: 'object', required: ['size'],
      properties: {
        size: { type: 'integer', minimum: 1, maximum: 10737418240 },
        contentType: { type: 'string', maxLength: 120 }
      }
    }
  }
}, async (req) => {
  const id = nanoid(16);
  const key = `blobs/${id}`;
  const upload = await s3.createMultipartUpload({
    Bucket: 'transfers', Key: key, ContentType: req.body.contentType
  });
  const partCount = Math.ceil(req.body.size / (8 * 1024 * 1024));
  const urls = await Promise.all(
    Array.from({ length: partCount }, (_, i) =>
      getSignedUrl(s3, new UploadPartCommand({
        Bucket: 'transfers', Key: key, UploadId: upload.UploadId, PartNumber: i + 1
      }), { expiresIn: 3600 }))
  );
  db.prepare('INSERT INTO sessions VALUES (?, ?, ?, ?)')
    .run(id, upload.UploadId, key, Date.now());
  return { id, uploadId: upload.UploadId, partUrls: urls };
});

10 GB ceiling, 8 MB parts, 1-hour URL validity. Adjust for your traffic mix.

Building the Upload UI

Vite plus React plus zero UI library gets you to a working drag-and-drop in about 150 lines. The critical piece is the upload worker: spawn a dedicated Web Worker, hand it the File handle and the part URLs, and have it PUT each chunk with 3-way concurrency. Main thread stays responsive, progress updates come via postMessage.

// worker.js
self.onmessage = async ({ data: { file, partUrls } }) => {
  const partSize = 8 * 1024 * 1024;
  const etags = [];
  for (let i = 0; i < partUrls.length; i++) {
    const blob = file.slice(i * partSize, (i + 1) * partSize);
    const res = await fetch(partUrls[i], { method: 'PUT', body: blob });
    etags[i] = res.headers.get('etag');
    self.postMessage({ type: 'progress', done: i + 1, total: partUrls.length });
  }
  self.postMessage({ type: 'done', etags });
};

Add a password field that derives a key via PBKDF2 (600,000 iterations, SHA-256) and wraps each chunk through AES-256-GCM before the PUT. That turns your service into an E2EE service instead of a server-side-encrypted one.

Designing the Database Schema

SQLite via better-sqlite3 handles tens of millions of rows without flinching. Keep the schema lean:

CREATE TABLE sessions (
  id TEXT PRIMARY KEY, upload_id TEXT, object_key TEXT, created_at INTEGER
);
CREATE TABLE shares (
  id TEXT PRIMARY KEY, object_key TEXT, size_bytes INTEGER,
  content_type TEXT, expires_at INTEGER, max_downloads INTEGER,
  download_count INTEGER DEFAULT 0, revocation_token TEXT
);
CREATE INDEX idx_shares_expires ON shares(expires_at);

No user table, no email, no IP logs. That's a deliberate choice — it makes GDPR compliance almost trivial. A nightly cron deletes expired rows and their S3 objects:

const expired = db.prepare('SELECT object_key FROM shares WHERE expires_at < ?').all(Date.now());
for (const { object_key } of expired) {
  await s3.deleteObject({ Bucket: 'transfers', Key: object_key });
}
db.prepare('DELETE FROM shares WHERE expires_at < ?').run(Date.now());

Implementing Share Links and Downloads

Share URLs look like https://yourapp.com/f/abc123#k=<base64key> when you've added client-side encryption. The fragment keeps the key out of server logs. The download page fetches /shares/abc123, gets a presigned S3 URL, streams bytes directly from R2, decrypts in the browser, and hands the result to StreamSaver.js or the File System Access API.

Set Content-Disposition: attachment; filename*=UTF-8''encoded-name so the browser triggers a save dialog instead of rendering the file inline. This matters for PDFs, HTML files, and SVG, all of which can execute scripts in a browser context otherwise.

Hardening Against Abuse

File transfer services attract abusers. Expect three categories: malware distribution, phishing payload hosting, and copyright-infringing uploads. Mitigations in order of impact: rate limit uploads per IP, require a minimum size (under 10 KB is almost always junk), compute a SHA-256 of ciphertext and check against a denylist of known-bad hashes, and provide a no-friction abuse report form. ClamAV on decrypted content only works when you have the key, which defeats E2EE — the right answer is content-agnostic rate limits plus responsive takedowns.

Sign every presigned URL with a short TTL (1 hour max) and tie sessions to a browser-generated ephemeral token stored in a Secure; HttpOnly; SameSite=Strict cookie.

Deploying and Running It Cheap

A single $10 Hetzner CX22 (2 vCPU, 4 GB RAM) runs Fastify + SQLite + a Caddy reverse proxy and handles thousands of active users. Caddy gives you TLS 1.3 automatically via Let's Encrypt. Put Cloudflare in front for DDoS protection and the free WAF. Monitor with Uptime Kuma (free, self-hosted) and log to stdout captured by journald.

For geographic redundancy, replicate SQLite with Litestream to R2, and point a second region at the same object store. Failover is DNS-level. Your whole DR story fits on a napkin.

The whole approach above mirrors how HexaTransfer is built — Fastify API, R2 storage, Web Crypto in the browser, SQLite metadata, no accounts. Try it at hexatransfer.com — free, no account, 10 GB max.

What to Skip Until You Need It

Don't build accounts, teams, or folders in v1. Don't add email delivery. Don't implement video preview or image thumbnails (both force the server to see plaintext). Don't ship a mobile app before the web version is rock solid on mobile Safari. The feature creep that killed early file transfer clones was always the same pattern: accounts, then storage quotas, then payment, then team plans, and suddenly you're competing with Dropbox instead of shipping a fast, private transfer tool. Keep the surface small and the crypto tight.

Send large files securely with end-to-end encryption

Transfer files up to 10 GB for free with end-to-end encryption. No account required. Your files are encrypted in your browser before upload — no one else can read them.

Send a file