File storage with AWS S3
Amazon Simple Storage Service (AWS S3) is an object storage service widely used for storing and retrieving large amounts of data, such as images, videos, backups, and application assets. Neon Object S...
Amazon Simple Storage Service (AWS S3) is an object storage service widely used for storing and retrieving large amounts of data, such as images, videos, backups, and application assets.
Neon now offers native storage
Neon Object Storage is S3-compatible object storage built into the Neon backend. Object storage branches with your database: each branch gets its own isolated namespace, so you can test file uploads in preview branches without touching production. No separate cloud account needed. Use any S3-compatible SDK with your existing Neon credential.
For more information, see Neon Object Storage.
This guide demonstrates how to integrate AWS S3 with Lakebase Postgres by storing file metadata (like the object key and URL) in your database, while using S3 for file storage.
Setup steps
Section titled “Setup steps”Create a Neon project
- Navigate to the Neon Console to create a new Neon project.
- Copy the connection string by clicking the Connect button in the Console nav. For more information, see Connect from any application.
Create an AWS account and S3 bucket
- Sign up for or log in to your AWS Account.
- Navigate to the S3 service in the AWS Management Console.
- Click Create bucket. Provide a unique bucket name (for example,
my-neon-app-s3-uploads), select an AWS Region (for example,us-east-1), and configure initial settings.
- Public Access (for this example): For simplicity in accessing uploaded files via URL in this guide, we'll configure the bucket to allow public read access for objects uploaded with specific permissions. Under Block Public Access settings for this bucket, uncheck "Block all public access". Acknowledge the warning.
Making buckets or objects publicly readable carries security risks. For production applications, it's strongly recommended to:Keep buckets private (Block all public access enabled).Use presigned URLs not only for uploads but also for downloads (temporary read access). This guide uses public access for simplicity, but you should implement secure access controls in production. - After the bucket is created, navigate to the Permissions tab. Under Bucket Policy, you can set up a policy to allow public read access to objects. For example:
JSON { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-neon-app-s3-uploads/*" } ] }Replace
my-neon-app-s3-uploadswith your actual bucket name. - Create IAM user for programmatic access:
- Navigate to the IAM service in the AWS Console.
- Go to Users and click Add users.
- Enter a username (for example,
neon-app-s3-user). Select Access key - Programmatic access as the credential type. Click Next: Permissions. - Choose Attach policies directly. Search for and select
AmazonS3FullAccess.
- Click Next, then Create user.
- Click on Create access key.

- Click Other > Create access key. Copy the Access key ID and Secret access key. These will be used in your application to authenticate with AWS S3.
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 S3 bucket. CORS rules tell S3 which web domains are allowed to make requests (like
PUTrequests for uploads) to your bucket. Without proper CORS rules, browser security restrictions will block these direct uploads.In your S3 bucket settings, navigate to the Permissions tab and find the CORS configuration section. Add the following CORS rules:
JSON [ { "AllowedHeaders": ["*"], "AllowedMethods": ["GET", "PUT"], "AllowedOrigins": ["*"], "ExposeHeaders": [], "MaxAgeSeconds": 9000 } ]This configuration allows any origin (
*) to performGETandPUTrequests. In a production environment, you should restrictAllowedOriginsto your application's domain(s) for security.Create a table in Neon for file metadata
We need a table in the database to store metadata about the objects uploaded to S3.
- Connect to your database using the Neon SQL Editor or a client like psql. Create a table including the object key, URL, user ID, and timestamp:
SQL CREATE TABLE IF NOT EXISTS s3_files ( id SERIAL PRIMARY KEY, object_key TEXT NOT NULL UNIQUE, -- Key (path/filename) in S3 file_url TEXT NOT NULL, -- Publicly accessible URL (if object is public) user_id TEXT NOT NULL, -- User associated with the file upload_timestamp TIMESTAMPTZ DEFAULT NOW() ); - Run the SQL statement. Add other relevant columns as needed (for example,
content_type,size).
- Connect to your database using the Neon SQL Editor or a client like psql. Create a table including the object key, URL, user ID, and timestamp:
Upload files to S3 and store metadata in Neon
The recommended pattern for client-side uploads to S3 involves presigned upload URLs. Your backend generates a temporary URL that the client uses to upload the file directly to S3. Afterwards, your backend saves the file's metadata to Neon.
This requires two backend endpoints:
/presign-upload: Generates the temporary presigned URL./save-metadata: Records the metadata in Neon after the client confirms successful upload.
We'll use Hono for the server,
@aws-sdk/client-s3and@aws-sdk/s3-request-presignerfor S3 interaction, and@neondatabase/serverlessfor Neon.First, install the necessary dependencies:
Bash npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @neondatabase/serverless @hono/node-server hono dotenvCreate a
.envfile:Bash # AWS S3 Credentials & Config AWS_ACCESS_KEY_ID=your_iam_user_access_key_id AWS_SECRET_ACCESS_KEY=your_iam_user_secret_access_key AWS_REGION=your_s3_bucket_region # for example, us-east-1 S3_BUCKET_NAME=your_s3_bucket_name # for example, my-neon-app-s3-uploads # Database Connection String DATABASE_URL=your_database_connection_stringThe following code snippet demonstrates this workflow:
JavaScript 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 S3_BUCKET = process.env.S3_BUCKET_NAME; const AWS_REGION = process.env.AWS_REGION; const s3 = new S3Client({ region: AWS_REGION, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_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'); 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 = `https://${S3_BUCKET}.s3.${AWS_REGION}.amazonaws.com/${objectKey}`; const command = new PutObjectCommand({ Bucket: S3_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'); await sql` INSERT INTO s3_files (object_key, file_url, user_id) VALUES (${objectKey}, ${publicFileUrl}, ${userId}) `; console.log(`Metadata saved for S3 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), Hono (app), and the AWS S3 client (s3) configured with region and credentials. - Authentication: A placeholder
authMiddlewareis included. Crucially, this needs to be replaced with real authentication logic. It currently just sets a staticuserIdfor demonstration. - Upload endpoints:
/presign-upload: Generates a temporary secure URL (presignedUrl) using@aws-sdk/s3-request-presignerthat allows uploading a file directly to S3. It returns the URL, the generatedobjectKey, and the standard S3 public URL./save-metadata: Called by the client after successful upload. Saves theobjectKey,file_url, anduserIdinto thes3_filestable in Neon using@neondatabase/serverless.
We'll use Flask,
boto3(AWS SDK for Python), andpsycopg2.First, install the necessary dependencies:
Bash pip install Flask boto3 psycopg2-binary python-dotenvCreate a
.envfile:env # AWS S3 Credentials & Config AWS_ACCESS_KEY_ID=your_iam_user_access_key_id AWS_SECRET_ACCESS_KEY=your_iam_user_secret_access_key AWS_REGION=your_s3_bucket_region # for example, us-east-1 S3_BUCKET_NAME=your_s3_bucket_name # for example, my-neon-app-s3-uploads # Database Connection String DATABASE_URL=your_database_connection_stringThe following code snippet demonstrates this workflow:
Python import os import uuid import boto3 import psycopg2 from botocore.exceptions import ClientError from dotenv import load_dotenv from flask import Flask, jsonify, request load_dotenv() S3_BUCKET_NAME = os.getenv("S3_BUCKET_NAME") AWS_REGION = os.getenv("AWS_REGION") s3_client = boto3.client( service_name="s3", region_name=AWS_REGION, aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"), aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"), ) app = Flask(__name__) # Use a global PostgreSQL connection instead of creating a new one for each request in production def get_db_connection(): return psycopg2.connect(os.getenv("DATABASE_URL")) # Replace this with your actual user authentication logic def get_authenticated_user_id(request): # Example: Validate Authorization header, session cookie, etc. return "user_123" # Static ID for demonstration # 1. Generate Presigned URL for Upload @app.route("/presign-upload", methods=["POST"]) def presign_upload_route(): try: user_id = get_authenticated_user_id(request) data = request.get_json() file_name = data.get("fileName") content_type = data.get("contentType") if not file_name or not content_type: raise ValueError("fileName and contentType required") object_key = f"{uuid.uuid4()}-{file_name}" public_file_url = ( f"https://{S3_BUCKET_NAME}.s3.{AWS_REGION}.amazonaws.com/{object_key}" ) presigned_url = s3_client.generate_presigned_url( "put_object", Params={ "Bucket": S3_BUCKET_NAME, "Key": object_key, "ContentType": content_type, }, ExpiresIn=300, ) return jsonify( { "success": True, "presignedUrl": presigned_url, "objectKey": object_key, "publicFileUrl": public_file_url, } ), 200 except (ClientError, ValueError) as e: print(f"Presign Error: {e}") return jsonify( {"success": False, "error": f"Failed to prepare upload: {e}"} ), 500 except Exception as e: print(f"Unexpected Presign Error: {e}") return jsonify({"success": False, "error": "Server error"}), 500 # 2. Save Metadata after Client Upload Confirmation @app.route("/save-metadata", methods=["POST"]) def save_metadata_route(): conn = None cursor = None try: user_id = get_authenticated_user_id(request) data = request.get_json() object_key = data.get("objectKey") public_file_url = data.get("publicFileUrl") if not object_key: raise ValueError("objectKey required") conn = get_db_connection() cursor = conn.cursor() cursor.execute( """ INSERT INTO s3_files (object_key, file_url, user_id) VALUES (%s, %s, %s) """, (object_key, public_file_url, user_id), ) conn.commit() print(f"Metadata saved for S3 object: {object_key}") return jsonify({"success": True}), 201 except (psycopg2.Error, ValueError) as e: print(f"Metadata Save Error: {e}") return jsonify( {"success": False, "error": "Failed to save metadata"} ), 500 except Exception as e: print(f"Unexpected Metadata Save Error: {e}") return jsonify({"success": False, "error": "Server error"}), 500 finally: if cursor: cursor.close() if conn: conn.close() if __name__ == "__main__": port = int(os.environ.get("PORT", 3000)) app.run(host="0.0.0.0", port=port, debug=True)Explanation
- Setup: Initializes Flask, the PostgreSQL client (
psycopg2), and the AWS S3 client (boto3) using environment variables for credentials and configuration. - Authentication: A placeholder
get_authenticated_user_idfunction is included. Replace this with real authentication logic. - Upload endpoints:
/presign-upload: Generates a temporary secure URL (presignedUrl) usingboto3that allows uploading a file directly to S3. It returns the URL,objectKey, and the standard public S3 URL./save-metadata: Called by the client after successful upload. Saves theobjectKey,file_url, anduserIdinto thes3_filestable in Neon usingpsycopg2.
- In production, you should use a global PostgreSQL connection instead of creating a new one for each request. This is important for performance and resource management.
Testing the upload workflow
Testing the presigned URL flow involves multiple steps:
- Get presigned URL: Send a
POSTrequest to your/presign-uploadendpoint with a JSON body containingfileNameandcontentType. Using cURL:Bash curl -X POST http://localhost:3000/presign-upload \ -H "Content-Type: application/json" \ -d '{"fileName": "test-s3.txt", "contentType": "text/plain"}'You should receive a JSON response with a
presignedUrl,objectKey, andpublicFileUrl:JSON { "success": true, "presignedUrl": "https://<BUCKET_NAME>.s3.us-east-1.amazonaws.com/.....&x-id=PutObject", "objectKey": "<OBJECT_KEY>", "publicFileUrl": "https://<BUCKET_NAME>.s3.us-east-1.amazonaws.com/<OBJECT_KEY>" }Note the
presignedUrl,objectKey, andpublicFileUrlfrom the response. You will use these in the next steps. - Upload file to S3: Use the received
presignedUrlto upload the actual file using an HTTPPUTrequest. Using cURL:Bash curl -X PUT "<PRESIGNED_URL>" \ --upload-file /path/to/your/test-s3.txt \ -H "Content-Type: text/plain"A successful upload typically returns HTTP
200 OKwith no body. - Save metadata: Send a
POSTrequest to your/save-metadataendpoint with theobjectKeyandpublicFileUrlobtained in step 1. Using cURL:Bash 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:
JSON { "success": true }
Expected outcome:
- The file appears in your S3 bucket (check the AWS Console).
- A new row appears in your
s3_filestable in Neon containing theobject_keyandfile_url.
You can now integrate API calls to these endpoints from various parts of your application (for example, web clients using JavaScript's
fetchAPI, mobile apps, backend services) to handle file uploads.- Get presigned URL: Send a
Accessing file metadata and files
Storing metadata in the database allows your application to easily retrieve references to the files hosted on S3.
Query the
s3_filestable from your application's backend when needed.Example SQL query:
Retrieve files for user 'user_123':
SQL SELECT id, object_key, -- Key (path/filename) in S3 file_url, -- Publicly accessible S3 URL user_id, -- User associated with the file upload_timestamp FROM s3_files WHERE user_id = 'user_123'; -- Use actual authenticated user IDUsing the data:
- The query returns metadata stored in the database.
- The
file_urlcolumn contains the direct link to access the file via S3. - Use this
file_urlin your application (for example,<img>tags, download links)For private S3 buckets, store only theobject_keyand generate presigned read URLs on demand using a similar backend process.
This pattern effectively separates file storage and delivery concerns (handled by S3) from structured metadata management (handled by Postgres), leveraging the strengths of both services.
Resources
Section titled “Resources”Need help?
Section titled “Need help?”Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.