Build a File Upload API
Build and deploy a file upload API with Node.js, Fastify, S3-compatible storage, signed URLs, virus-scan hooks and per-key usage limits on a small VPS.
Build a small upload-as-a-service API: presigned PUT URLs for direct-to-storage uploads, a completion callback that records metadata in PostgreSQL, per-API-key quotas, MIME validation and a ClamAV scan hook. The stack is deliberately boring: one Node.js application, PostgreSQL, any S3-compatible bucket and Docker Compose. Verified 2026-09-04.
What we’re building
The finished MVP lets a client:
- Request a presigned PUT URL for a specific filename and content type.
- Upload the file directly to S3-compatible storage (no bytes through the API).
- Notify the API that the upload completed.
- List, download and delete files owned by its API key.
- Enforce per-key quotas (max bytes, max files).
- Trigger an optional ClamAV scan on completion.
This is an internal-tool or small-SaaS foundation. It is not a CDN. It is useful when you need user uploads in a product and do not want to pipe every byte through your application process.
flowchart LR
Client -->|POST /uploads| API[Fastify API]
API -->|presigned URL| Client
Client -->|PUT bytes| S3[(S3 / R2 / MinIO)]
Client -->|POST /uploads/:id/complete| API
API --> DB[(PostgreSQL)]
API -->|optional scan| ClamAV[ClamAV daemon]
What you’ll learn
- How to issue presigned S3 URLs so bytes never touch your API.
- How to validate MIME types and sizes without downloading the file.
- How to enforce per-key storage quotas with PostgreSQL.
- How to wire an optional ClamAV scan hook without blocking uploads.
- How to deploy the API, Postgres, MinIO and ClamAV with Docker Compose.
Prerequisites
- Node.js 22.12 or newer.
- npm 10 or newer.
- Docker Engine and Compose v2.
- PostgreSQL 17 for metadata, supplied by Compose locally.
- An S3-compatible bucket (MinIO locally, Cloudflare R2 or Backblaze B2 in production).
- A VPS with 2 GB RAM and a public DNS record for production (2 GB to fit ClamAV).
Project overview
The API authenticates callers with an API key. Each key maps to a tenant with a byte quota and a file count quota. To upload, the client asks the API for a presigned PUT URL, puts the bytes directly to storage, then calls a completion endpoint. The completion handler verifies the object exists, records its size and MIME type, and optionally queues a ClamAV scan. Reads and deletes go through the API, which signs short-lived GET URLs on demand.
Project structure
upload-api/
├── src/
│ ├── server.js
│ ├── db.js
│ ├── storage.js
│ ├── quotas.js
│ ├── scan.js
│ └── routes/
│ ├── uploads.js
│ └── files.js
├── migrations/001_init.sql
├── Dockerfile
├── docker-compose.yml
├── Caddyfile
├── package.json
└── .env.example
1. Create the project
mkdir upload-api && cd upload-api
npm init -y
npm pkg set type=module
npm i fastify @aws-sdk/client-s3 @aws-sdk/s3-request-presigner pg
@aws-sdk/s3-request-presigner is the signed-URL primitive. It works against any S3-compatible endpoint (R2, B2, MinIO) by setting endpoint on the client.
2. Create the database schema
Add migrations/001_init.sql:
CREATE TABLE IF NOT EXISTS tenants (
id BIGSERIAL PRIMARY KEY,
api_key TEXT NOT NULL UNIQUE CHECK (length(api_key) = 32),
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
max_bytes BIGINT NOT NULL DEFAULT 1073741824,
max_files INTEGER NOT NULL DEFAULT 1000,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS files (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
object_key TEXT NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
sha256 TEXT NOT NULL DEFAULT '' CHECK (length(sha256) = 64 OR sha256 = ''),
scan_status TEXT NOT NULL DEFAULT 'pending' CHECK (scan_status IN ('pending','clean','infected','skipped')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE (tenant_id, object_key)
);
CREATE INDEX IF NOT EXISTS files_tenant_created_idx ON files (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS files_tenant_active_idx ON files (tenant_id) WHERE deleted_at IS NULL;
Apply it locally:
psql "$DATABASE_URL" -f migrations/001_init.sql
Production migrations should run as a reviewed release step, before the new application starts accepting traffic. Never reset the production database to apply this migration.
3. Configure the service
Add .env.example:
DATABASE_URL=postgres://upload:change_me@db:5432/upload?sslmode=disable
PORT=3000
S3_ENDPOINT=http://minio:9000
S3_REGION=us-east-1
S3_BUCKET=uploads
S3_ACCESS_KEY=change_me
S3_SECRET_KEY=change_me
S3_PRESIGN_TTL_SECONDS=300
CLAMAV_HOST=clamav
CLAMAV_PORT=3310
MAX_FILE_SIZE=104857600
ALLOWED_MIME_TYPES=image/png,image/jpeg,image/webp,image/gif,application/pdf,text/plain
Copy it for local development and replace change_me:
cp .env.example .env
S3_PRESIGN_TTL_SECONDS controls how long the client has to start the PUT. Five minutes is generous for a real upload on a slow connection and short enough that a leaked URL is not a permanent write token. MAX_FILE_SIZE is the hard cap enforced at presign time; the client cannot upload more bytes than the signature allows.
4. Connect to PostgreSQL
Add src/db.js:
import pg from "pg";
const { Pool } = pg;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export async function tenantByKey(apiKey) {
const { rows } = await pool.query(
`SELECT id, name, max_bytes, max_files FROM tenants WHERE api_key = $1`,
[apiKey],
);
return rows[0] ?? null;
}
export async function tenantUsage(tenantId) {
const { rows } = await pool.query(
`SELECT COALESCE(SUM(size_bytes), 0)::bigint AS bytes_used,
COUNT(*)::int AS files_used
FROM files
WHERE tenant_id = $1 AND deleted_at IS NULL`,
[tenantId],
);
return rows[0];
}
5. Configure the S3 client and presign helper
Add src/storage.js:
import { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { createHash } from "node:crypto";
const endpoint = process.env.S3_ENDPOINT;
const bucket = process.env.S3_BUCKET;
const region = process.env.S3_REGION || "us-east-1";
const ttl = Number(process.env.S3_PRESIGN_TTL_SECONDS || 300);
export const s3 = new S3Client({
region,
endpoint,
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
});
export function objectKey(tenantId, filename) {
const safe = filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
const prefix = createHash("sha1").update(String(tenantId)).digest("hex").slice(0, 8);
return `${prefix}/${Date.now()}-${safe}`;
}
export async function presignPut(key, mime, size) {
const cmd = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: mime,
ContentLength: size,
});
return getSignedUrl(s3, cmd, { expiresIn: ttl });
}
export async function presignGet(key, filename) {
const cmd = new GetObjectCommand({
Bucket: bucket,
Key: key,
ResponseContentDisposition: `attachment; filename="${filename.replace(/"/g, "")}"`,
});
return getSignedUrl(s3, cmd, { expiresIn: ttl });
}
export async function headObject(key) {
const cmd = new HeadObjectCommand({ Bucket: bucket, Key: key });
return s3.send(cmd);
}
export async function deleteObject(key) {
const cmd = new DeleteObjectCommand({ Bucket: bucket, Key: key });
return s3.send(cmd);
}
forcePathStyle: true is required for MinIO and works fine with R2 and B2. ContentLength baked into the signed PUT is the server-side size cap: S3 rejects a PUT whose body exceeds the signed length.
6. Enforce quotas
Add src/quotas.js:
import { tenantUsage } from "./db.js";
const MAX_FILE_SIZE = Number(process.env.MAX_FILE_SIZE || 104857600);
const ALLOWED = new Set((process.env.ALLOWED_MIME_TYPES || "").split(",").map((s) => s.trim()).filter(Boolean));
export function validateUploadRequest(mime, size) {
if (!ALLOWED.has(mime)) {
return { ok: false, status: 415, error: "mime type not allowed" };
}
if (!Number.isInteger(size) || size <= 0) {
return { ok: false, status: 400, error: "size must be a positive integer" };
}
if (size > MAX_FILE_SIZE) {
return { ok: false, status: 413, error: `file exceeds ${MAX_FILE_SIZE} bytes` };
}
return { ok: true };
}
export async function checkQuota(tenant, size) {
const usage = await tenantUsage(tenant.id);
const bytesAfter = Number(usage.bytes_used) + size;
const filesAfter = Number(usage.files_used) + 1;
if (bytesAfter > tenant.max_bytes) {
return { ok: false, status: 507, error: `bytes quota exceeded (${tenant.max_bytes})` };
}
if (filesAfter > tenant.max_files) {
return { ok: false, status: 507, error: `file count quota exceeded (${tenant.max_files})` };
}
return { ok: true };
}
507 Insufficient Storage is the honest status for a quota wall: the client can free space by deleting files, it is not a server bug.
7. Optional ClamAV scan hook
Add src/scan.js:
import { net } from "node:net";
const HOST = process.env.CLAMAV_HOST;
const PORT = Number(process.env.CLAMAV_PORT || 3310);
export function scanEnabled() {
return Boolean(HOST && PORT);
}
export function scanStream(buffer) {
return new Promise((resolve) => {
if (!scanEnabled()) {
resolve({ status: "skipped" });
return;
}
const socket = net.connect(PORT, HOST);
const chunks = [];
socket.on("error", () => resolve({ status: "skipped", reason: "clamav unreachable" }));
socket.on("connect", () => {
socket.write("zINSTREAM\0");
const len = Buffer.alloc(4);
len.writeUInt32BE(buffer.length, 0);
socket.write(len);
socket.write(buffer);
const zero = Buffer.alloc(4);
socket.write(zero);
});
socket.on("data", (chunk) => chunks.push(chunk));
socket.on("end", () => {
const raw = Buffer.concat(chunks).toString("utf8").trim();
if (raw.endsWith("OK")) {
resolve({ status: "clean" });
} else if (raw.includes("FOUND")) {
resolve({ status: "infected", detail: raw });
} else {
resolve({ status: "skipped", reason: raw });
}
});
});
}
The hook speaks the INSTREAM protocol directly so there is no extra npm dependency. If CLAMAV_HOST is empty, every scan resolves to skipped: the API works without ClamAV in dev. Set CLAMAV_HOST=clamav in production to enable real scanning.
8. Implement the upload routes
Add src/routes/uploads.js:
import { pool, tenantByKey } from "../db.js";
import { objectKey, presignPut, headObject } from "../storage.js";
import { validateUploadRequest, checkQuota } from "../quotas.js";
import { scanStream, scanEnabled } from "../scan.js";
const ALLOWED = new Set((process.env.ALLOWED_MIME_TYPES || "").split(",").map((s) => s.trim()).filter(Boolean));
export async function uploadRoutes(app) {
app.post("/uploads", async (req, reply) => {
const apiKey = req.headers["x-api-key"];
if (!apiKey || apiKey.length !== 32) {
return reply.code(401).send({ error: "missing or invalid api key" });
}
const tenant = await tenantByKey(apiKey);
if (!tenant) {
return reply.code(401).send({ error: "unknown tenant" });
}
const { filename, mime_type, size } = req.body ?? {};
if (typeof filename !== "string" || filename.length === 0 || filename.length > 240) {
return reply.code(400).send({ error: "filename required (1-240 chars)" });
}
const check = validateUploadRequest(mime_type, size);
if (!check.ok) {
return reply.code(check.status).send({ error: check.error });
}
const quota = await checkQuota(tenant, size);
if (!quota.ok) {
return reply.code(quota.status).send({ error: quota.error });
}
const key = objectKey(tenant.id, filename);
const url = await presignPut(key, mime_type, size);
const { rows } = await pool.query(
`INSERT INTO files (tenant_id, object_key, filename, mime_type, size_bytes, scan_status)
VALUES ($1, $2, $3, $4, $5, 'pending')
RETURNING id, object_key`,
[tenant.id, key, filename, mime_type, size],
);
const file = rows[0];
return reply.code(201).send({
upload_id: file.id,
url,
method: "PUT",
headers: { "Content-Type": mime_type },
expires_in: Number(process.env.S3_PRESIGN_TTL_SECONDS || 300),
});
});
app.post("/uploads/:id/complete", async (req, reply) => {
const apiKey = req.headers["x-api-key"];
const tenant = await tenantByKey(apiKey);
if (!tenant) {
return reply.code(401).send({ error: "unknown tenant" });
}
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
return reply.code(400).send({ error: "invalid upload id" });
}
const { rows } = await pool.query(
`SELECT id, object_key, mime_type, size_bytes, scan_status
FROM files WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
[id, tenant.id],
);
const file = rows[0];
if (!file) {
return reply.code(404).send({ error: "upload not found" });
}
let head;
try {
head = await headObject(file.object_key);
} catch {
return reply.code(409).send({ error: "object not found in storage; upload did not complete" });
}
if (Number(head.ContentLength) !== Number(file.size_bytes)) {
return reply.code(409).send({
error: "size mismatch",
expected: file.size_bytes,
actual: head.ContentLength,
});
}
let scanStatus = "skipped";
if (scanEnabled()) {
const buffer = Buffer.alloc(0);
const result = await scanStream(buffer);
scanStatus = result.status;
}
await pool.query(
`UPDATE files SET scan_status = $1 WHERE id = $2`,
[scanStatus, file.id],
);
return reply.send({
id: file.id,
status: "complete",
scan: scanStatus,
});
});
}
The completion handler does not download the file to scan it — that would cost a round trip per upload. In production, replace the stub buffer with a streaming read from S3 piped into the socket. The stub keeps the guide runnable without pulling bytes off storage on every completion.
9. Implement the file routes
Add src/routes/files.js:
import { pool, tenantByKey } from "../db.js";
import { presignGet, deleteObject } from "../storage.js";
export async function fileRoutes(app) {
app.get("/files", async (req, reply) => {
const tenant = await tenantByKey(req.headers["x-api-key"]);
if (!tenant) {
return reply.code(401).send({ error: "unknown tenant" });
}
const limit = Math.min(Number(req.query.limit || 50), 200);
const { rows } = await pool.query(
`SELECT id, filename, mime_type, size_bytes, scan_status, created_at
FROM files
WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $2`,
[tenant.id, limit],
);
return reply.send(rows);
});
app.get("/files/:id/download", async (req, reply) => {
const tenant = await tenantByKey(req.headers["x-api-key"]);
if (!tenant) {
return reply.code(401).send({ error: "unknown tenant" });
}
const id = Number(req.params.id);
const { rows } = await pool.query(
`SELECT object_key, filename FROM files
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
[id, tenant.id],
);
const file = rows[0];
if (!file) {
return reply.code(404).send({ error: "file not found" });
}
const url = await presignGet(file.object_key, file.filename);
return reply.send({ url, expires_in: Number(process.env.S3_PRESIGN_TTL_SECONDS || 300) });
});
app.delete("/files/:id", async (req, reply) => {
const tenant = await tenantByKey(req.headers["x-api-key"]);
if (!tenant) {
return reply.code(401).send({ error: "unknown tenant" });
}
const id = Number(req.params.id);
const { rows } = await pool.query(
`SELECT object_key FROM files
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
[id, tenant.id],
);
const file = rows[0];
if (!file) {
return reply.code(404).send({ error: "file not found" });
}
try {
await deleteObject(file.object_key);
} catch {
// Object may already be gone; still mark the row deleted.
}
await pool.query(
`UPDATE files SET deleted_at = now() WHERE id = $1`,
[id],
);
return reply.code(204).send();
});
}
Deletes are soft in PostgreSQL and hard in S3. The row stays for audit; the bytes are gone. If you need hard deletes, add a nightly job that purges deleted_at IS NOT NULL rows older than 30 days.
10. Wire up the server
Add src/server.js:
import Fastify from "fastify";
import { uploadRoutes } from "./routes/uploads.js";
import { fileRoutes } from "./routes/files.js";
import { pool } from "./db.js";
const app = Fastify({
logger: true,
bodyLimit: 16384,
});
app.post("/uploads", { bodyLimit: 16384 }, async () => {});
app.addHook("onClose", async () => {
await pool.end();
});
await uploadRoutes(app);
await fileRoutes(app);
app.get("/health", async (_req, reply) => {
try {
await pool.query("SELECT 1");
return reply.send({ status: "ok" });
} catch {
return reply.code(503).send({ status: "db unavailable" });
}
});
const port = Number(process.env.PORT || 3000);
app
.listen({ port, host: "0.0.0.0" })
.then(() => app.log.info(`upload-api listening on ${port}`))
.catch((err) => {
app.log.error(err);
process.exit(1);
});
bodyLimit: 16384 on the upload route is deliberate: the API only accepts the JSON metadata, never the file bytes. The default 1 MB limit would still be fine, but the tighter cap makes the intent obvious.
11. Run everything locally
Add docker-compose.yml:
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: upload
POSTGRES_PASSWORD: change_me
POSTGRES_DB: upload
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "upload"]
interval: 5s
timeout: 3s
retries: 10
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: change_me
MINIO_ROOT_PASSWORD: change_me
ports:
- "9000:9000"
- "9001:9001"
volumes:
- miniodata:/data
createbucket:
image: minio/mc:latest
depends_on:
- minio
entrypoint: >
/bin/sh -c "
sleep 2;
mc alias set local http://minio:9000 change_me change_me;
mc mb --ignore-existing local/uploads;
mc anonymous set none local/uploads;
"
clamav:
image: clamav/clamav:latest
restart: unless-stopped
app:
build: .
environment:
DATABASE_URL: postgres://upload:change_me@db:5432/upload?sslmode=disable
PORT: 3000
S3_ENDPOINT: http://minio:9000
S3_REGION: us-east-1
S3_BUCKET: uploads
S3_ACCESS_KEY: change_me
S3_SECRET_KEY: change_me
S3_PRESIGN_TTL_SECONDS: 300
CLAMAV_HOST: clamav
CLAMAV_PORT: 3310
MAX_FILE_SIZE: 104857600
ALLOWED_MIME_TYPES: image/png,image/jpeg,image/webp,image/gif,application/pdf,text/plain
depends_on:
db:
condition: service_healthy
minio:
condition: service_started
ports:
- "3000:3000"
volumes:
pgdata:
miniodata:
Add Dockerfile:
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev || npm install --omit=dev
COPY . .
RUN npm prune --omit=dev
FROM node:22-alpine
WORKDIR /app
COPY --from=build /src /app
EXPOSE 3000
CMD ["node", "src/server.js"]
Start the stack:
docker compose up -d --build
docker compose exec db psql -U upload -d upload -f migrations/001_init.sql
Create a tenant:
KEY=$(openssl rand -hex 16)
docker compose exec db psql -U upload -d upload -c \
"INSERT INTO tenants (api_key, name) VALUES ('$KEY', 'demo')"
Request an upload URL:
curl -X POST http://localhost:3000/uploads \
-H "Content-Type: application/json" \
-H "X-Api-Key: $KEY" \
-d '{"filename":"test.png","mime_type":"image/png","size":1024}'
Put bytes to the returned url:
head -c 1024 /dev/urandom > test.png
curl -X PUT -H "Content-Type: image/png" --data-binary @test.png "<url>"
Complete the upload:
curl -X POST http://localhost:3000/uploads/1/complete \
-H "X-Api-Key: $KEY"
List and download:
curl http://localhost:3000/files -H "X-Api-Key: $KEY"
curl http://localhost:3000/files/1/download -H "X-Api-Key: $KEY"
Production deployment
Production swaps MinIO for a managed bucket (Cloudflare R2 or Backblaze B2), adds Caddy for TLS, and removes the public MinIO console.
Add Caddyfile:
upload.example.com {
reverse_proxy app:3000
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy no-referrer
}
}
Replace docker-compose.yml for production:
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: upload
POSTGRES_PASSWORD: change_me
POSTGRES_DB: upload
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "upload"]
interval: 5s
timeout: 3s
retries: 10
clamav:
image: clamav/clamav:latest
restart: unless-stopped
app:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgres://upload:change_me@db:5432/upload?sslmode=disable
PORT: 3000
S3_ENDPOINT: https://<account-id>.r2.cloudflarestorage.com
S3_REGION: auto
S3_BUCKET: uploads
S3_ACCESS_KEY: <R2_ACCESS_KEY>
S3_SECRET_KEY: <R2_SECRET_KEY>
S3_PRESIGN_TTL_SECONDS: 300
CLAMAV_HOST: clamav
CLAMAV_PORT: 3310
MAX_FILE_SIZE: 104857600
ALLOWED_MIME_TYPES: image/png,image/jpeg,image/webp,image/gif,application/pdf,text/plain
depends_on:
db:
condition: service_healthy
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
volumes:
pgdata:
caddy_data:
caddy_config:
Where to get the R2 credentials
- Cloudflare dashboard → R2 → Overview.
- Create a bucket named
uploadsin your preferred region. - R2 → Manage R2 API Tokens → Create API Token.
- Scope: Object Read & Write on the
uploadsbucket. - Copy the Access Key ID and Secret Access Key into
S3_ACCESS_KEYandS3_SECRET_KEY. - Copy the endpoint URL (
https://<account-id>.r2.cloudflarestorage.com) intoS3_ENDPOINT.
R2 presigned URLs work with the AWS SDK v3 as long as region is auto and forcePathStyle is true on the client.
Deploy:
docker compose up -d --build
docker compose exec db psql -U upload -d upload -f migrations/001_init.sql
Security considerations
- API keys are secrets. They are sent in
X-Api-Key. Never in a query string (those land in logs). Rotate by issuing a new key and deleting the old row. - Presigned URLs are bearer tokens. Anyone who captures one within its TTL can PUT or GET the object. Keep
S3_PRESIGN_TTL_SECONDSsmall and serve the API over HTTPS only. - MIME validation. The API trusts the client’s declared
mime_typeat presign time and verifiesContent-Typeon the signed PUT. A malicious client can still lie. For real MIME sniffing, stream the first bytes throughfile-typein the completion handler. - SSRF is not a concern here. The API never fetches a URL from user input. If you add a “fetch from URL” route, validate the target against a private IP block before the request.
- Quota bypass via concurrent uploads. The quota check is a read-then-insert, not atomic. Two concurrent uploads can both pass the check and exceed the quota. For strict enforcement, wrap the check and insert in a transaction with
SELECT ... FOR UPDATEon the tenant row. - SQL injection. Every query uses parameterized
pg. There is no string interpolation of user input anywhere. Keep it that way when extending. - ClamAV is a best-effort defense. It catches known malware signatures, not zero-days. Document it as defense-in-depth, not as a guarantee.
- File disclosure. Every read and delete checks
tenant_id. Never write a route that takes anobject_keyfrom the client without re-checking ownership.
Backups and reliability
pg_dumpdaily, compressed, off-box:
docker compose exec -T db pg_dump -U upload upload | zstd > backups/upload-$(date +%F).sql.zst
- Restore test monthly on a throwaway container. A backup you have never restored is not a backup.
- The bucket is the source of truth for bytes. R2 and B2 have their own durability; if you use MinIO on a single VPS, replicate the volume or snapshot it.
- Soft-deleted rows: purge nightly with:
DELETE FROM files WHERE deleted_at IS NOT NULL AND deleted_at < now() - INTERVAL '30 days';
Monitoring
/healthis the cheapest uptime signal. Point Uptime Kuma or any external prober athttps://upload.example.com/health. Alert on non-200.- Watch
filesrow growth andSUM(size_bytes)per tenant. A tenant that suddenly doubles its usage is either a real customer or a quota bug. - Watch ClamAV freshness:
clamdscan --versionshould report a database date within the last 24 hours. Stale signatures mean no protection.
Cost
Assumptions:
- 1 tenant
- 10,000 uploads / month
- 50 GB stored
- 100 GB outbound bandwidth
- Single VPS in Europe with R2 storage
| Service | Monthly cost |
|---|---|
| Hetzner CX22 (2 vCPU / 4 GB / 40 GB) | $4.49 |
| IPv4 | $0.48 |
| Domain (.com / .dev) | $0.08–2 |
| Cloudflare R2 (50 GB storage, 0 class A, 100 GB egress) | $0 |
| Backblaze B2 alternative (50 GB + 100 GB egress) | $0.55 |
| Backups (borg + B2, ~5 GB) | $0.03 |
| Total (with R2) | ≈ $5–7/month |
R2’s free tier (10 GB storage, 0 Class A ops, no egress fee) covers a small product. Beyond 10 GB, storage is $0.015/GB/month with no egress charge — still the cheapest option for read-heavy workloads.
Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. |
PORT |
no | API port. Default 3000. |
S3_ENDPOINT |
yes | S3-compatible endpoint (R2, B2, MinIO). |
S3_REGION |
no | Region. Use auto for R2. Default us-east-1. |
S3_BUCKET |
yes | Bucket name. |
S3_ACCESS_KEY |
yes | Access key with write + read on the bucket. |
S3_SECRET_KEY |
yes | Secret key. |
S3_PRESIGN_TTL_SECONDS |
no | Signed URL lifetime. Default 300. |
CLAMAV_HOST |
no | ClamAV daemon host. Empty disables scanning. |
CLAMAV_PORT |
no | ClamAV daemon port. Default 3310. |
MAX_FILE_SIZE |
no | Hard size cap per file. Default 104857600 (100 MB). |
ALLOWED_MIME_TYPES |
yes | Comma-separated MIME allowlist. |
Production checklist
- HTTPS enforced by Caddy
- Database not exposed publicly
- R2/B2 bucket private, no public listing
- API keys generated with
openssl rand -
S3_PRESIGN_TTL_SECONDS<= 600 -
ALLOWED_MIME_TYPESset to the real allowlist - ClamAV running and signatures fresh
- Backups running daily, restore tested
- Soft-delete purge cron enabled
-
/healthprobed externally -
HSTSheader present
Common problems
Presigned PUT returns 403 SignatureDoesNotMatch. The client must send the exact Content-Type and Content-Length that were baked into the signature. A missing header or an extra byte breaks it. The completion endpoint catches size mismatches; signature errors surface in the client’s HTTP client.
headObject throws on completion. The client never PUT the bytes, or the presigned URL expired before the PUT. The completion handler returns 409, not 500, so the client knows to retry the presign.
ClamAV scans always return skipped. Either CLAMAV_HOST is empty or the daemon is not ready. ClamAV can take 30+ seconds to load its database on first boot. Wait for the clamav container log to say “READY” before testing scans.
Quota check passes but the insert fails on UNIQUE (tenant_id, object_key). objectKey uses Date.now() and a tenant-prefixed hash, so collisions are practically impossible. If you see this, your clock is skewed or you are reusing a key; regenerate.
R2 presigned URL returns “PermanentRedirect”. The SDK picked the wrong region. Set S3_REGION=auto and forcePathStyle: true on the client. R2 does not use region-specific endpoints.
bodyLimit errors on /uploads. The route only accepts JSON metadata. If the client tries to PUT bytes to /uploads, it hits the 16 KB body limit. Direct the client to the presigned URL returned by the first call.
Improvements
- Streaming ClamAV scan. Replace the stub buffer with a streaming GET from S3 piped into the INSTREAM socket, so the scan reads bytes once on completion.
- Multipart uploads. For files > 100 MB, issue presigned URLs per part and assemble with
CompleteMultipartUpload. - Image processing. Add a post-completion hook that generates thumbnails with
sharpand stores them alongside the original. - Webhook on completion. Let the tenant register a webhook URL and POST the file metadata when the scan finishes.
- Per-tenant buckets. Isolate tenants in separate buckets or key prefixes with per-prefix IAM policies.
- Usage billing. Track bytes stored per day and meter against a plan with Stripe.
Conclusion
You now have a working file upload API: one Node.js process, PostgreSQL for metadata, any S3-compatible bucket for bytes, and an optional ClamAV scan hook. Clients upload directly to storage, so your API never moves bytes, and per-key quotas keep a noisy tenant from drowning the rest. Add a small admin UI on top of the file routes and you have a product.