Build a Temporary File-Sharing Service with Node.js and PostgreSQL

Build and deploy a temporary file-sharing service with Node.js, Fastify, PostgreSQL, expiring download links and automatic cleanup on a small VPS.

Build a small WeTransfer-style service that accepts a file, returns a private expiring link, limits downloads and removes expired data automatically. The stack is one Node.js application, PostgreSQL, local disk and Docker Compose. Verified 2026-09-05.

What we’re building

The finished MVP lets an anonymous sender:

  • Upload one file with a configurable size limit.
  • Choose an expiration period between one hour and seven days.
  • Receive a random download token that is safe to share.
  • Limit the number of successful downloads.
  • Download the file without exposing its filesystem path.
  • Delete the share early with a private management token.
  • Remove expired metadata and files through a cleanup worker.

This is a focused private-sharing service, not a general object-storage platform. It stores files on the VPS so it is cheap to operate. Move the storage layer to S3-compatible storage before accepting large or business-critical files.

flowchart LR
    Browser --> API[Fastify API]
    API --> DB[(PostgreSQL)]
    API --> Disk[(Private upload volume)]
    Cleanup[Cleanup loop] --> DB
    Cleanup --> Disk

What you’ll learn

  • How to validate multipart uploads and enforce a byte limit.
  • How to generate unguessable public and management tokens.
  • How to make download limits atomic in PostgreSQL.
  • How to serve files through an authorization check instead of a public directory.
  • How to deploy a small upload service behind Caddy with HTTPS.

Prerequisites

  • Node.js 22.12 or newer.
  • npm 10 or newer.
  • Docker Engine and Compose v2.
  • PostgreSQL 17, supplied by Compose locally.
  • A VPS with at least 2 GB RAM, 20 GB disk and a DNS record.

Project overview

The API writes the upload to a temporary file, calculates its size, then moves it into a private volume only after validation succeeds. PostgreSQL stores metadata and token hashes, never the raw tokens. A download transaction locks the share row, rejects expired or exhausted shares, increments the counter and returns the file path to the handler.

The public token is returned once to the sender and is stored only as a SHA-256 hash. The management token uses the same approach. A database leak therefore does not immediately reveal active links.

Project structure

temporary-share/
├── src/
│   ├── app.js
│   ├── db.js
│   ├── files.js
│   ├── tokens.js
│   └── cleanup.js
├── migrations/001_init.sql
├── Dockerfile
├── docker-compose.yml
├── Caddyfile
├── package.json
└── .env.example

1. Create the project

mkdir temporary-share && cd temporary-share
npm init -y
npm pkg set type=module
npm pkg set scripts.start="node src/app.js"
npm i dotenv fastify @fastify/multipart pg
mkdir -p src migrations data

@fastify/multipart streams the request to disk. The service does not keep the whole file in memory.

2. Configure the environment

Create .env.example:

DATABASE_URL=postgres://share:change_me@db:5432/share
PORT=3000
UPLOAD_DIR=/data/uploads
MAX_FILE_SIZE_BYTES=104857600
DEFAULT_TTL_HOURS=24
MAX_TTL_HOURS=168
CLEANUP_INTERVAL_SECONDS=300
PUBLIC_BASE_URL=http://localhost:3000

Copy it locally and change change_me:

cp .env.example .env

Do not commit .env. PUBLIC_BASE_URL is used only for the response link; it must be the HTTPS hostname in production.

3. Create the database schema

Create migrations/001_init.sql:

CREATE TABLE IF NOT EXISTS shares (
    id BIGSERIAL PRIMARY KEY,
    public_token_hash TEXT NOT NULL UNIQUE CHECK (length(public_token_hash) = 64),
    manage_token_hash TEXT NOT NULL UNIQUE CHECK (length(manage_token_hash) = 64),
    stored_name TEXT NOT NULL CHECK (length(stored_name) BETWEEN 1 AND 180),
    original_name TEXT NOT NULL CHECK (length(original_name) BETWEEN 1 AND 255),
    content_type TEXT NOT NULL CHECK (length(content_type) BETWEEN 1 AND 160),
    size_bytes BIGINT NOT NULL CHECK (size_bytes > 0),
    max_downloads INTEGER NOT NULL CHECK (max_downloads BETWEEN 1 AND 100),
    downloads INTEGER NOT NULL DEFAULT 0 CHECK (downloads >= 0),
    expires_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS shares_expires_idx ON shares (expires_at);

Apply it locally after PostgreSQL is running:

psql "$DATABASE_URL" -f migrations/001_init.sql

For a development reset only:

psql "$DATABASE_URL" -c 'DROP TABLE IF EXISTS shares;'
psql "$DATABASE_URL" -f migrations/001_init.sql

Never run the reset command in production. Production migrations should be reviewed and applied before the new application container receives traffic.

4. Add token and file helpers

Create src/tokens.js:

import crypto from "node:crypto";

export function createToken() {
  return crypto.randomBytes(32).toString("base64url");
}

export function hashToken(token) {
  return crypto.createHash("sha256").update(token, "utf8").digest("hex");
}

export function createStoredName() {
  return `${crypto.randomUUID()}-${crypto.randomBytes(8).toString("hex")}`;
}

Create src/files.js:

import fs from "node:fs/promises";
import path from "node:path";

export async function ensureUploadDir(directory) {
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
}

export function uploadPath(directory, storedName) {
  return path.join(directory, storedName);
}

export async function removeIfPresent(filePath) {
  await fs.rm(filePath, { force: true });
}

The stored name is generated by the server. Never use the client filename as a filesystem path.

5. Add database access

Create src/db.js:

import pg from "pg";

const { Pool } = pg;
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function createShare(data) {
  const { rows } = await pool.query(
    `INSERT INTO shares
      (public_token_hash, manage_token_hash, stored_name, original_name,
       content_type, size_bytes, max_downloads, expires_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
     RETURNING id, expires_at`,
    [data.publicHash, data.manageHash, data.storedName, data.originalName,
      data.contentType, data.sizeBytes, data.maxDownloads, data.expiresAt],
  );
  return rows[0];
}

export async function claimDownload(publicHash) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const result = await client.query(
      `SELECT * FROM shares
       WHERE public_token_hash = $1 AND expires_at > now() AND downloads < max_downloads
       FOR UPDATE`,
      [publicHash],
    );
    const share = result.rows[0];
    if (!share) {
      await client.query("ROLLBACK");
      return null;
    }
    const updated = await client.query(
      "UPDATE shares SET downloads = downloads + 1 WHERE id = $1 RETURNING *",
      [share.id],
    );
    await client.query("COMMIT");
    return updated.rows[0];
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

export async function deleteShare(manageHash) {
  const { rows } = await pool.query(
    "DELETE FROM shares WHERE manage_token_hash = $1 RETURNING stored_name",
    [manageHash],
  );
  return rows[0] ?? null;
}

export async function deleteExpiredShares() {
  const { rows } = await pool.query(
    "DELETE FROM shares WHERE expires_at <= now() RETURNING stored_name",
  );
  return rows;
}

claimDownload locks the row before incrementing it. Concurrent requests cannot consume the same final download slot.

6. Implement the API

Create src/app.js:

import "dotenv/config";
import Fastify from "fastify";
import multipart from "@fastify/multipart";
import fs from "node:fs";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { ensureUploadDir, removeIfPresent, uploadPath } from "./files.js";
import { claimDownload, createShare, deleteShare, pool } from "./db.js";
import { createStoredName, createToken, hashToken } from "./tokens.js";

const app = Fastify({ logger: true });
const uploadDir = process.env.UPLOAD_DIR ?? "/data/uploads";
const maxBytes = Number(process.env.MAX_FILE_SIZE_BYTES ?? 104857600);
const defaultTtl = Number(process.env.DEFAULT_TTL_HOURS ?? 24);
const maxTtl = Number(process.env.MAX_TTL_HOURS ?? 168);

await ensureUploadDir(uploadDir);
await app.register(multipart, { limits: { files: 1, fileSize: maxBytes } });

app.get("/health", async () => ({ status: "ok" }));

app.post("/shares", async (request, reply) => {
  let temporaryPath;
  try {
    const part = await request.file();
    if (!part) return reply.code(400).send({ error: "file is required" });
    const hours = Number(request.query?.hours ?? defaultTtl);
    const maxDownloads = Number(request.query?.max_downloads ?? 1);
    if (!Number.isInteger(hours) || hours < 1 || hours > maxTtl) {
      return reply.code(400).send({ error: "invalid expiration" });
    }
    if (!Number.isInteger(maxDownloads) || maxDownloads < 1 || maxDownloads > 100) {
      return reply.code(400).send({ error: "invalid download limit" });
    }
    const storedName = createStoredName();
    temporaryPath = uploadPath(uploadDir, `${storedName}.tmp`);
    await pipeline(part.file, createWriteStream(temporaryPath, { flags: "wx", mode: 0o600 }));
    const size = (await fs.promises.stat(temporaryPath)).size;
    if (size === 0 || size > maxBytes) {
      await removeIfPresent(temporaryPath);
      return reply.code(400).send({ error: "invalid file size" });
    }
    const finalPath = uploadPath(uploadDir, storedName);
    await fs.promises.rename(temporaryPath, finalPath);
    const publicToken = createToken();
    const manageToken = createToken();
    const share = await createShare({
      publicHash: hashToken(publicToken),
      manageHash: hashToken(manageToken),
      storedName,
      originalName: part.filename,
      contentType: part.mimetype || "application/octet-stream",
      sizeBytes: size,
      maxDownloads,
      expiresAt: new Date(Date.now() + hours * 3600000),
    });
    return reply.code(201).send({
      download_url: `${process.env.PUBLIC_BASE_URL}/s/${publicToken}`,
      manage_token: manageToken,
      expires_at: share.expires_at,
    });
  } catch (error) {
    if (temporaryPath) await removeIfPresent(temporaryPath);
    if (error?.code === "FST_REQ_FILE_TOO_LARGE") return reply.code(413).send({ error: "file too large" });
    request.log.error(error);
    return reply.code(500).send({ error: "upload failed" });
  }
});

app.get("/s/:token", async (request, reply) => {
  try {
    const share = await claimDownload(request.params.token);
    if (!share) return reply.code(404).send({ error: "share unavailable" });
    const filePath = uploadPath(uploadDir, share.stored_name);
    if (!fs.existsSync(filePath)) return reply.code(404).send({ error: "file unavailable" });
    reply.header("Content-Type", share.content_type);
    reply.header("Content-Disposition", `attachment; filename="${share.original_name.replace(/[\"\r\n]/g, "_")}"`);
    return reply.send(fs.createReadStream(filePath));
  } catch (error) {
    request.log.error(error);
    return reply.code(500).send({ error: "download failed" });
  }
});

app.delete("/shares/:manageToken", async (request, reply) => {
  try {
    const share = await deleteShare(hashToken(request.params.manageToken));
    if (!share) return reply.code(404).send({ error: "share not found" });
    await removeIfPresent(uploadPath(uploadDir, share.stored_name));
    return reply.code(204).send();
  } catch (error) {
    request.log.error(error);
    return reply.code(500).send({ error: "delete failed" });
  }
});

const port = Number(process.env.PORT ?? 3000);
await app.listen({ host: "0.0.0.0", port });
process.on("SIGTERM", async () => { await app.close(); await pool.end(); process.exit(0); });

The upload options use query parameters so the request remains easy to test with curl. In a public product, add authentication or rate limiting before allowing anonymous uploads.

7. Add automatic cleanup

Create src/cleanup.js:

import "dotenv/config";
import fs from "node:fs/promises";
import { deleteExpiredShares, pool } from "./db.js";
import { removeIfPresent, uploadPath } from "./files.js";

const directory = process.env.UPLOAD_DIR ?? "/data/uploads";
const interval = Number(process.env.CLEANUP_INTERVAL_SECONDS ?? 300) * 1000;

async function clean() {
  const rows = await deleteExpiredShares();
  await Promise.all(rows.map((row) => removeIfPresent(uploadPath(directory, row.stored_name))));
  const names = new Set((await fs.readdir(directory)).filter((name) => name.endsWith(".tmp")));
  await Promise.all([...names].map((name) => removeIfPresent(uploadPath(directory, name))));
}

await clean();
setInterval(() => clean().catch((error) => console.error(error)), interval);
process.on("SIGTERM", async () => { await pool.end(); process.exit(0); });

Run the cleanup as a separate container process. This keeps deletion work out of request handlers.

8. Run everything locally

Create Dockerfile:

FROM node:22.12-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY migrations ./migrations
USER node
CMD ["node", "src/app.js"]

Create docker-compose.yml:

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: share
      POSTGRES_USER: share
      POSTGRES_PASSWORD: change_me
    volumes:
      - share-db:/var/lib/postgresql/data
  app:
    build: .
    env_file: .env
    depends_on:
      - db
    ports:
      - "3000:3000"
    volumes:
      - share-files:/data/uploads
  cleanup:
    build: .
    command: ["node", "src/cleanup.js"]
    env_file: .env
    depends_on:
      - db
    volumes:
      - share-files:/data/uploads
volumes:
  share-db:
  share-files:

Start the stack and apply the migration:

curl http://localhost:3000/health

Create a one-download share:

curl -F "file=@./README.md" "http://localhost:3000/shares?hours=2&max_downloads=1"

Use the returned download_url once. A second request should return 404. Delete a share early with its returned management token:

curl -i -X DELETE "http://localhost:3000/shares/MANAGEMENT_TOKEN"

9. Deploy to a VPS

Use a fresh Ubuntu 24.04 or Debian 13 server. Create a non-root user, enable SSH keys and allow only SSH, HTTP and HTTPS:

sudo apt update && sudo apt install -y docker.io docker-compose-plugin ufw
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Copy the project to /opt/temporary-share, create .env with production values, and change the database password in both .env and docker-compose.yml:

DATABASE_URL=postgres://share:replace_this@db:5432/share
PUBLIC_BASE_URL=https://share.example.com
UPLOAD_DIR=/data/uploads
MAX_FILE_SIZE_BYTES=104857600
DEFAULT_TTL_HOURS=24
MAX_TTL_HOURS=168
CLEANUP_INTERVAL_SECONDS=300
PORT=3000

Start the containers and migrate:

cd /opt/temporary-share

Do not expose port 3000 publicly once the reverse proxy is active. Replace the ports mapping with an internal network-only configuration if the host already runs Caddy.

10. Configure HTTPS with Caddy

Install Caddy using its official repository, then create /etc/caddy/Caddyfile:

share.example.com {
    reverse_proxy 127.0.0.1:3000
    request_body {
        max_size 100MB
    }
}

Replace share.example.com with the DNS name from PUBLIC_BASE_URL, then reload:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Caddy obtains and renews the certificate. The application still needs its own upload limit because reverse-proxy limits are not a substitute for application validation.

Security considerations

  • Keep the upload volume private; never mount it into a static-file server.
  • Keep tokens in URLs only when necessary, and never log complete tokens.
  • Store token hashes, not tokens, in PostgreSQL.
  • Keep the management token separate from the download token.
  • Validate size server-side and configure limits in both multipart and Caddy.
  • Sanitize the download filename before putting it in Content-Disposition.
  • Add IP rate limiting and CAPTCHA before offering anonymous public uploads.
  • Consider malware scanning for files shared with other users.
  • Use HTTPS and rotate database credentials if they are exposed.
  • Set a disk alert; expired-file cleanup does not protect against an upload flood.

The example does not authenticate senders. Its authorization boundary is possession of a high-entropy token, which is suitable only for a small private-sharing MVP.

Backups and reliability

The database backup alone cannot restore files. Back up both PostgreSQL and the share-files Docker volume, or move files to versioned object storage:

Test restoring a backup on a separate machine. Monitor disk usage, container health, HTTP 5xx responses and cleanup failures.

Cost

Service Required monthly cost
Hetzner shared VPS, 2 GB $4–6
PostgreSQL on the VPS $0
Caddy and Docker $0
Domain $1–2 averaged monthly
Total about $5–8

Optional costs:

  • S3-compatible storage: usage and egress pricing varies by provider.
  • Managed PostgreSQL: usually unnecessary for an MVP, but reduces operational work.
  • Malware scanning: additional CPU, memory and storage.
  • Email notifications: usually free at low volume.

Environment reference

Variable Required Description
DATABASE_URL Yes PostgreSQL connection string
PORT No HTTP port, default 3000
UPLOAD_DIR No Private upload directory
MAX_FILE_SIZE_BYTES No Server-side upload cap
DEFAULT_TTL_HOURS No Expiration when the client omits hours
MAX_TTL_HOURS No Maximum allowed expiration
CLEANUP_INTERVAL_SECONDS No Cleanup loop interval
PUBLIC_BASE_URL Yes Absolute base URL returned to clients

Production checklist

  • HTTPS works and redirects HTTP.
  • PUBLIC_BASE_URL uses the HTTPS hostname.
  • .env is not committed or world-readable.
  • Upload and reverse-proxy limits match.
  • Upload volume is not publicly served.
  • PostgreSQL and files are backed up.
  • Disk, 5xx and cleanup failures are monitored.
  • Anonymous upload rate limiting is enabled.
  • Expiration and download limits are tested.
  • Malware scanning requirements are documented.

Common problems

FST_REQ_FILE_TOO_LARGE

The multipart limit rejected the stream. Lower the client upload size or raise MAX_FILE_SIZE_BYTES and the Caddy max_size together.

A share returns 404 immediately

Check the server clock, expires_at, and whether another request already consumed the final download. The endpoint intentionally uses the same response for expired, exhausted and unknown tokens.

The file exists but download returns 404

The database and file volume are inconsistent. Check that app and cleanup mount the same Docker volume and inspect UPLOAD_DIR inside both containers.

Cleanup removes metadata but leaves files

Check volume permissions and cleanup logs. The database deletion happens before file deletion, so a failed filesystem operation requires a separate orphan-file reconciliation job in a hardened deployment.

Improvements

  • Move file bytes to S3-compatible storage and keep only object keys locally.
  • Add authenticated accounts, quotas and team sharing.
  • Add resumable uploads for unreliable connections.
  • Add a content hash and deduplication policy.
  • Add ClamAV or a managed malware scanner.
  • Add a scheduled orphan-file reconciliation process.
  • Add signed, short-lived streaming URLs instead of serving through the app.

Conclusion

This service is a compact foundation for expiring file shares: PostgreSQL protects metadata and download counters, the filesystem keeps costs low, and a cleanup process enforces retention. Start with the single-VPS design, measure disk and traffic, then move storage and rate limiting to managed services when the workload justifies it.

Related guides