Cloudflare R2 is S3-compatible object storage offering zero egress fees, designed for storing and serving large amounts of unstructured data like images, videos, and documents globally.
This guide demonstrates how to integrate Cloudflare R2 with Neon by storing file metadata in your Neon database, while using R2 for file storage.
Setup steps
Create a Neon project
- Navigate to pg.new to create a new Neon project.
- Copy the connection string by clicking the Connect button on your Project Dashboard. For more information, see Connect from any application.
Create a Cloudflare account and R2 bucket
-
Sign up for or log in to your Cloudflare account.
-
Navigate to R2 in the Cloudflare dashboard sidebar.
-
Click Create bucket, provide a unique bucket name (e.g.,
my-neon-app-files
), and click Create bucket. -
Generate R2 API credentials (Access Key ID and Secret Access Key) by following Create an R2 API Token. Select Object Read & Write permissions. Copy these credentials securely.
-
Obtain your Cloudflare Account ID by following Find your Account ID.
-
For this example, enable public access to your bucket URL by following Allow public access to your bucket. Note your bucket's public URL (e.g.,
https://pub-xxxxxxxx.r2.dev
).Public access
Public access makes all objects readable via URL; consider private buckets and signed URLs for sensitive data in production.
-
Configure CORS for client-side uploads
If your application involves uploading files directly from a web browser using the generated presigned URLs, you must configure Cross-Origin Resource Sharing (CORS) on your R2 bucket. CORS rules tell R2 which web domains are allowed to make requests (like
PUT
requests for uploads) to your bucket. Without proper CORS rules, browser security restrictions will block these direct uploads.Follow Cloudflare's guide to Configure CORS for your bucket. You can add rules via R2 Bucket settings in the Cloudflare dashboard.
Here’s an example CORS configuration allowing
PUT
uploads andGET
requests from your deployed frontend application and your local development environment:[ { "AllowedOrigins": [ "https://your-production-app.com", // Replace with your actual frontend domain "http://localhost:3000" // For local development ], "AllowedMethods": ["PUT", "GET"] } ]
Create a table in Neon for file metadata
We need a table in Neon to store metadata about the objects uploaded to R2.
-
Connect to your Neon database using the Neon SQL Editor or a client like psql. Here is an example SQL statement to create a simple table including the object key, URL, user ID, and timestamp:
CREATE TABLE IF NOT EXISTS r2_files ( id SERIAL PRIMARY KEY, object_key TEXT NOT NULL UNIQUE, -- Key (path/filename) in R2 file_url TEXT NOT NULL, -- Publicly accessible URL user_id TEXT NOT NULL, -- User associated with the file upload_timestamp TIMESTAMPTZ DEFAULT NOW() );
-
Run the SQL statement. You can add other relevant columns (file size, content type, etc.) depending on your application needs.
Securing metadata with RLS
If you use Neon's Row Level Security (RLS), remember to apply appropriate access policies to the
r2_files
table. This controls who can view or modify the object references stored in Neon based on your RLS rules.Note that these policies apply only to the metadata in Neon. Access control for the objects within the R2 bucket itself is managed via R2 permissions, API tokens, and presigned URL settings if used.
-
Upload files to R2 and store metadata in Neon
A common pattern with S3-compatible storage like R2 involves presigned upload URLs. Your backend generates a temporary, secure URL that the client uses to upload the file directly to R2. Afterwards, your backend saves the file's metadata to Neon.
This requires two backend endpoints:
/presign-upload
: Generates the temporary presigned URL for the client to upload a file directly to R2./save-metadata
: Records the metadata in Neon after the client confirms a successful upload to R2.
We'll use Hono for the server,
@aws-sdk/client-s3
and@aws-sdk/s3-request-presigner
for R2 interaction, and@neondatabase/serverless
for Neon.First, install the necessary dependencies:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @neondatabase/serverless @hono/node-server hono dotenv
Create a
.env
file:# R2 Credentials & Config R2_ACCOUNT_ID=your_cloudflare_account_id R2_ACCESS_KEY_ID=your_r2_api_token_access_key_id R2_SECRET_ACCESS_KEY=your_r2_api_token_secret_access_key R2_BUCKET_NAME=your_r2_bucket_name # my-neon-app-files if following the example R2_PUBLIC_BASE_URL=https://your-bucket-public-url.r2.dev # Your R2 bucket public URL # Neon Connection String DATABASE_URL=your_neon_database_connection_string
The following code snippet demonstrates this workflow:
import { serve } from '@hono/node-server'; import { Hono } from 'hono'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { neon } from '@neondatabase/serverless'; import 'dotenv/config'; import { randomUUID } from 'crypto'; const R2_ENDPOINT = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; const R2_BUCKET = process.env.R2_BUCKET_NAME; const R2_PUBLIC_BASE_URL = process.env.R2_PUBLIC_BASE_URL; // Ensure no trailing '/' const s3 = new S3Client({ region: 'auto', endpoint: R2_ENDPOINT, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, }, }); const sql = neon(process.env.DATABASE_URL); const app = new Hono(); // Replace this with your actual user authentication logic, by validating JWTs/Headers, etc. const authMiddleware = async (c, next) => { c.set('userId', 'user_123'); // Example: Get user ID after validation await next(); }; // 1. Generate Presigned URL for Upload app.post('/presign-upload', authMiddleware, async (c) => { try { const { fileName, contentType } = await c.req.json(); if (!fileName || !contentType) throw new Error('fileName and contentType required'); const objectKey = `${randomUUID()}-${fileName}`; const publicFileUrl = R2_PUBLIC_BASE_URL ? `${R2_PUBLIC_BASE_URL}/${objectKey}` : null; const command = new PutObjectCommand({ Bucket: R2_BUCKET, Key: objectKey, ContentType: contentType, }); const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 300 }); return c.json({ success: true, presignedUrl, objectKey, publicFileUrl }); } catch (error) { console.error('Presign Error:', error.message); return c.json({ success: false, error: 'Failed to prepare upload' }, 500); } }); // 2. Save Metadata after Client Upload Confirmation app.post('/save-metadata', authMiddleware, async (c) => { try { const { objectKey, publicFileUrl } = await c.req.json(); const userId = c.get('userId'); if (!objectKey) throw new Error('objectKey required'); const finalFileUrl = publicFileUrl || (R2_PUBLIC_BASE_URL ? `${R2_PUBLIC_BASE_URL}/${objectKey}` : 'URL not available'); await sql` INSERT INTO r2_files (object_key, file_url, user_id) VALUES (${objectKey}, ${finalFileUrl}, ${userId}) `; console.log(`Metadata saved for R2 object: ${objectKey}`); return c.json({ success: true }); } catch (error) { console.error('Metadata Save Error:', error.message); return c.json({ success: false, error: 'Failed to save metadata' }, 500); } }); const port = 3000; serve({ fetch: app.fetch, port }, (info) => { console.log(`Server running at http://localhost:${info.port}`); });
Explanation
- Setup: Initializes the Neon database client (
sql
), the Hono web framework (app
), and the AWS S3 client (s3
) configured for R2 using environment variables. - Authentication: A placeholder
authMiddleware
is included. Crucially, this needs to be replaced with real authentication logic. It currently just sets a staticuserId
for demonstration. - Upload endpoints:
/presign-upload
: Generates a temporary secure URL (presignedUrl
) that allows uploading a file with a specificobjectKey
andcontentType
directly to R2 using@aws-sdk/client-s3
. It returns the URL, key, and public URL./save-metadata
: Called by the client after it successfully uploads the file to R2. It saves theobjectKey
, the finalfile_url
, and theuserId
into ther2_files
table in Neon using@neondatabase/serverless
.
Testing the upload workflow
Testing the presigned URL flow involves multiple steps:
-
Get presigned URL: Send a
POST
request to your/presign-upload
endpoint with a JSON body containingfileName
andcontentType
.curl -X POST http://localhost:3000/presign-upload \ -H "Content-Type: application/json" \ -d '{"fileName": "test-image.png", "contentType": "image/png"}'
You should receive a JSON response with a
presignedUrl
,objectKey
, andpublicFileUrl
:{ "success": true, "presignedUrl": "https://<ACCOUNT_ID>.r2.cloudflarestorage.com/<BUCKET_NAME>/<GENERATED_OBJECT_KEY>?X-Amz-Algorithm=...", "objectKey": "<GENERATED_OBJECT_KEY>", "publicFileUrl": "https://pub-<HASH>.r2.dev/<GENERATED_OBJECT_KEY>" }
Note the
presignedUrl
,objectKey
, andpublicFileUrl
from the response. You will use these in the next steps. -
Upload file to R2: Use the received
presignedUrl
to upload the actual file using an HTTPPUT
request.curl -X PUT "<PRESIGNED_URL>" \ --upload-file /path/to/your/test-image.png \ -H "Content-Type: image/png"
A successful upload typically returns HTTP
200 OK
with no body. -
Save metadata: Send a
POST
request to your/save-metadata
endpoint with theobjectKey
andpublicFileUrl
obtained in step 1.curl -X POST http://localhost:3000/save-metadata \ -H "Content-Type: application/json" \ -d '{"objectKey": "<OBJECT_KEY>", "publicFileUrl": "<PUBLIC_URL>"}'
You should receive a JSON response indicating success:
{ "success": true }
Expected outcome:
- The file is uploaded to your R2 bucket. You can verify this in the Cloudflare dashboard or by accessing the
publicFileUrl
if your bucket is public. - A new row appears in your
r2_files
table in Neon containing theobject_key
andfile_url
.
You can now integrate API calls to these endpoints from various parts of your application (e.g., web clients using JavaScript's
fetch
API, mobile apps, backend services) to handle file uploads.-
Accessing file metadata and files
Storing metadata in Neon allows your application to easily retrieve references to the files hosted on R2.
Query the
r2_files
table from your application's backend when needed.Example SQL query:
Retrieve files for user 'user_123':
SELECT id, -- Your database primary key object_key, -- Key (path/filename) in the R2 bucket file_url, -- Publicly accessible URL user_id, -- User associated with the file upload_timestamp FROM r2_files WHERE user_id = 'user_123'; -- Use actual authenticated user ID
Using the data:
-
The query returns rows containing the file metadata stored in Neon.
-
The
file_url
column contains the direct link to access the file. -
Use this
file_url
in your application (e.g.,<img>
tags, API responses, download links) wherever you need to display or provide access to the file.Private buckets
For private R2 buckets, store only the
object_key
and generate presigned read URLs on demand using a similar backend process.
This pattern separates file storage and delivery (handled by R2) from structured metadata management (handled by Neon).
-
Resources
Need help?
Join our Discord Server to ask questions or see what others are doing with Neon. Users on paid plans can open a support ticket from the console. For more details, see Getting Support.