Jika endpoint upload bekerja di komputer lokal tetapi gagal di production Vercel dengan error seperti EROFS: read-only file system, penyebab utamanya biasanya bukan parser multipart atau kode upload itu sendiri. Kode tersebut mencoba menulis file ke path di dalam deployment, misalnya ./uploads, public/uploads, atau direktori kerja aplikasi.
Pada Vercel Function, filesystem dari artefak deployment harus diperlakukan sebagai read-only. Instance function juga bersifat sementara, sehingga file yang tampak berhasil ditulis selama satu request tidak boleh diasumsikan tersedia pada request atau invocation berikutnya. Solusi yang benar untuk file upload persisten adalah menyimpan binary ke object storage, lalu menyimpan key, URL yang stabil bila ada, dan metadata file di database.
Gejala Upload Gagal di Vercel Function
Masalah ini biasanya memiliki pola yang cukup jelas:
- Upload sukses saat menjalankan aplikasi secara lokal.
- Upload gagal hanya setelah deployment ke Vercel.
- Log function memuat error seperti
EROFS,read-only file system, atau kegagalan saatwriteFile,mkdir, dan library upload membuat file sementara. - File tampak tersedia sesaat, tetapi hilang ketika request berikutnya mencoba membacanya.
- Endpoint mengembalikan path lokal seperti
/uploads/foto.png, tetapi URL tersebut menghasilkan 404 di production.
Contoh pola kode yang bermasalah:
await mkdir('./public/uploads', { recursive: true });
await writeFile('./public/uploads/avatar.png', buffer);Di mesin lokal, direktori proyek biasanya dapat ditulis dan tetap ada selama aplikasi berjalan. Di deployment serverless, asumsi tersebut tidak berlaku.
Perbedaan Runtime Lokal dan Vercel Function
Filesystem lokal bersifat persisten
Saat development, Node.js berjalan pada mesin atau container yang sama. Menulis ke ./uploads mengubah isi proyek atau volume lokal, dan file tersebut masih tersedia selama proses dan disk lokal tetap ada.
Filesystem deployment bersifat read-only dan instance bersifat ephemeral
Vercel Function menjalankan kode dari artefak deployment. File aplikasi yang dibundel untuk deployment bukan media penyimpanan upload. Selain itu, function dapat dijalankan pada instance yang berbeda antar-request, dihentikan saat tidak diperlukan, atau diinisialisasi ulang.
Akibatnya, dua asumsi berikut tidak aman:
- “Jika file berhasil ditulis sekarang, file pasti ada pada request berikutnya.”
- “Semua invocation menggunakan disk lokal yang sama.”
Jangan gunakan folder proyek,
public, atau path relatif sebagai tujuan upload persisten pada Vercel Function. Folderpublicadalah aset yang dibangun saat deployment, bukan direktori upload dinamis.
Kapan direktori temporer dapat digunakan
Runtime Node.js umumnya menyediakan direktori temporer seperti /tmp untuk kebutuhan yang benar-benar sementara, misalnya library pemrosesan PDF, transcoding, atau antivirus yang hanya menerima path file. Gunakan hanya selama satu eksekusi request, hapus file setelah selesai, dan jangan menjadikannya sumber file permanen.
import { unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
const tempPath = join('/tmp', `${randomUUID()}.pdf`);
try {
await writeFile(tempPath, pdfBuffer);
// Proses file dengan library yang memerlukan path lokal.
// Hasil akhirnya tetap harus dikirim ke object storage.
} finally {
await unlink(tempPath).catch(() => undefined);
}Direktori temporer bukan solusi untuk avatar, lampiran, dokumen pengguna, maupun file yang perlu diakses setelah request selesai.
Mengumpulkan Bukti dari Deployment dan Function Log
Sebelum mengubah arsitektur, pastikan kegagalan memang terjadi pada operasi filesystem. Periksa log runtime function untuk request upload yang gagal, bukan hanya log proses build deployment.
- Reproduksi kegagalan di environment production atau preview dengan file kecil yang aman untuk diuji.
- Cari request berdasarkan endpoint, waktu kejadian, atau request ID dari aplikasi/proxy.
- Periksa stack trace dan nilai
error.code. KodeEROFSsangat kuat mengindikasikan operasi tulis pada filesystem read-only. - Catat path target yang gagal ditulis. Path seperti
/var/task/public/uploads, direktori aplikasi, atau path relatif yang mengarah ke artefak deployment adalah petunjuk utama. - Bandingkan log production dengan lokal: apakah production mencoba membuat direktori, memindahkan file, atau menyimpan output library ke folder proyek?
Tambahkan logging terstruktur yang cukup untuk diagnosis tanpa mencetak isi file, token, atau data pribadi:
try {
// operasi upload
} catch (error) {
const err = error as NodeJS.ErrnoException;
console.error('upload_failed', {
code: err.code,
message: err.message,
targetPath: targetPath,
contentType: request.headers.get('content-type')
});
throw error;
}Hindari mencatat buffer file, URL bertanda tangan, kredensial storage, atau nama file yang mengandung data sensitif. Bila menggunakan platform observability, hubungkan log dengan ID request agar error API, log function, dan aktivitas database dapat ditelusuri dalam satu alur.
Root Cause: Deployment Bukan Penyimpanan Upload
Vercel mengoptimalkan deployment untuk menjalankan fungsi secara terisolasi dan dapat diskalakan. Model ini tidak menyediakan shared disk persisten yang dapat dipakai sebagai tempat upload aplikasi. Menulis ke filesystem deployment akan gagal, sedangkan menyimpan file pada area temporer hanya bertahan selama kondisi runtime mengizinkannya.
Arsitektur yang tepat memisahkan tiga jenis data:
- Binary file: disimpan di object storage, misalnya layanan yang kompatibel dengan Amazon S3, Vercel Blob, atau storage object lain.
- Metadata: disimpan di database, misalnya pemilik file, object key, ukuran, MIME type tervalidasi, checksum opsional, status pemrosesan, dan waktu unggah.
- Akses file: diberikan melalui URL publik yang memang dirancang publik, CDN, atau signed URL berumur pendek untuk file privat.
Untuk file privat, jangan menyimpan signed URL yang cepat kedaluwarsa sebagai nilai permanen di database. Simpan objectKey atau identifier objek yang stabil, lalu buat signed URL saat pengguna yang berhak meminta file.
Perbaikan Praktis: Upload ke Object Storage
Untuk file kecil hingga menengah, function dapat menerima multipart request, memvalidasi file, lalu meneruskannya ke object storage. Untuk file besar atau trafik upload tinggi, lebih baik browser mengunggah langsung ke object storage menggunakan presigned URL atau mekanisme upload aman dari provider. Pendekatan direct upload mengurangi penggunaan memori dan durasi eksekusi function.
Contoh Route Handler Next.js dengan Object Storage
Contoh berikut menunjukkan route handler Node.js yang menerima satu file multipart, membatasi ukuran, memeriksa MIME type yang diizinkan, lalu menyimpan objek ke bucket S3-compatible. Konfigurasi bucket, kredensial, dan endpoint storage disediakan melalui environment variable, bukan ditulis di source code.
import { NextResponse } from 'next/server';
import { randomUUID } from 'node:crypto';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
export const runtime = 'nodejs';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const allowedTypes = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['application/pdf', 'pdf']
]);
const storage = new S3Client({
region: process.env.S3_REGION,
endpoint: process.env.S3_ENDPOINT || undefined,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!
}
});
export async function POST(request: Request) {
const contentType = request.headers.get('content-type') || '';
if (!contentType.startsWith('multipart/form-data')) {
return NextResponse.json({ error: 'Gunakan multipart/form-data.' }, { status: 415 });
}
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File) || file.size === 0) {
return NextResponse.json({ error: 'File wajib diisi.' }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'Ukuran file melebihi batas.' }, { status: 413 });
}
const extension = allowedTypes.get(file.type);
if (!extension) {
return NextResponse.json({ error: 'Tipe file tidak diizinkan.' }, { status: 415 });
}
// Ganti dengan autentikasi aplikasi Anda.
const userId = 'authenticated-user-id';
const objectKey = `uploads/${userId}/${randomUUID()}.${extension}`;
const body = Buffer.from(await file.arrayBuffer());
await storage.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: objectKey,
Body: body,
ContentType: file.type,
ContentLength: file.size
}));
// Setelah upload berhasil, simpan metadata ke database.
// Contoh data: userId, objectKey, originalName, mimeType, size, createdAt.
// Untuk bucket publik, Anda dapat menyimpan canonical public URL.
// Untuk bucket privat, simpan objectKey dan buat signed URL saat diperlukan.
return NextResponse.json({
objectKey,
mimeType: file.type,
size: file.size
}, { status: 201 });
}Contoh tersebut tidak menggunakan nama file dari pengguna sebagai key object. Ini mencegah tabrakan nama, mengurangi risiko karakter path yang tidak diinginkan, dan membuat struktur storage lebih mudah dikontrol. Nama asli file tetap boleh disimpan sebagai metadata database setelah divalidasi dan dibatasi panjangnya.
Validasi MIME type tidak cukup untuk kebutuhan sensitif
file.type berasal dari metadata request dan dapat dipalsukan. Untuk upload umum, allowlist MIME type dan batas ukuran adalah lapisan awal yang wajib ada. Untuk dokumen sensitif, file yang akan diproses otomatis, atau konten yang akan dipublikasikan, tambahkan pemeriksaan signature atau magic bytes, pemindaian malware, dan proses karantina sebelum file tersedia bagi pengguna lain.
Simpan metadata setelah upload berhasil
Jangan membuat record database berstatus selesai sebelum object benar-benar tersimpan. Urutan minimal yang aman adalah: validasi request, upload ke object storage, simpan metadata database, lalu kembalikan respons. Jika penulisan database gagal setelah upload sukses, tandai object sebagai orphan untuk dibersihkan melalui job terjadwal, atau gunakan status upload seperti pending dan ready sesuai kebutuhan konsistensi aplikasi.
Checklist Verifikasi di Production
- Pastikan tidak ada kode upload yang menulis ke
public, root proyek, atau path relatif deployment. - Pastikan environment variable bucket, region/endpoint, dan kredensial storage tersedia pada environment deployment yang benar.
- Gunakan kredensial dengan izin minimum: hanya bucket dan prefix yang diperlukan aplikasi.
- Uji file valid, file terlalu besar, MIME type terlarang, file kosong, dan upload tanpa autentikasi.
- Pastikan record database berisi object key, pemilik file, ukuran, MIME type, dan status yang diperlukan.
- Pastikan file privat tidak dapat diakses hanya dengan menebak URL atau object key.
- Periksa function log untuk error storage, timeout, dan kegagalan parsing multipart.
- Uji akses file dari deployment production, bukan hanya dari storage console atau environment lokal.
- Jika memakai URL publik, pastikan URL tersebut memang berasal dari domain/CDN storage yang dikonfigurasi untuk production.
Kesalahan Umum yang Perlu Dihindari
Mengandalkan path lokal sebagai URL aplikasi
Mengembalikan /uploads/file.pdf dari API tidak membuat file tersebut tersedia di domain aplikasi. Path tersebut hanya bermakna jika ada server yang benar-benar menyajikan file dari disk persisten, yang bukan model Vercel Function.
Menyimpan file antar-invocation di direktori temporer
Walaupun file di /tmp dapat tersedia selama request tertentu, tidak ada jaminan file itu akan ada pada invocation berikutnya atau instance lain. Gunakan hanya sebagai area kerja sementara.
Mengunggah file tanpa batas ukuran
Membaca file dengan arrayBuffer() berarti binary berada di memori function. Batas ukuran harus disesuaikan dengan kemampuan runtime dan kebutuhan aplikasi. Untuk file besar, gunakan direct-to-storage upload dengan presigned URL dan lakukan validasi serta finalisasi metadata melalui API terpisah.
Membuat bucket publik untuk semua jenis file
Bucket publik memang memudahkan penyajian gambar, tetapi tidak cocok untuk invoice, identitas, dokumen internal, atau file pengguna yang bersifat privat. Pisahkan kebijakan akses dan gunakan signed URL bila diperlukan.
Intinya, error filesystem read-only pada Vercel bukan masalah yang perlu diakali dengan membuat folder lain di proyek. Ubah desain upload agar function berperan sebagai validator dan orkestrator, object storage menyimpan binary secara persisten, dan database menyimpan referensi serta metadata file.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!