Migrating From S3 to Branch-Aware Storage

If your files already live in Amazon S3, the pitch for storage that branches with your database is appealing but the word "migration" makes it sound like a project. It mostly is not. Neon's object storage speaks the S3 API, so the code you already wrote, the AWS SDK calls and presigned URLs, keeps working. What changes is how you point the client and where the bucket comes from, and that is a small, mechanical diff. The actual data move is a copy loop you can run once. The one thing to do up front is confirm the object operations your app actually relies on: the demo here exercises PutObject, GetObject, listing, and presigned URLs, and I flag the S3 features you should check for yourself further down.
This post is the practical version: what stays identical, the exact config that changes, a script to copy the objects across, and an honest list of the S3 features that do not have an equivalent so you know what to check before you commit. The repo with the working client is at the end.
TL;DR
- Neon object storage is S3-compatible. Your
@aws-sdk/client-s3code for the common operations,PutObject,GetObject,getSignedUrl, listing, works unchanged (these are what the demo verifies). Confirm anything beyond that, like multipart for large objects, against the current preview. - The diff is the client config: point
endpointat the Neon storage endpoint, pinregion: 'us-east-2', setforcePathStyle: true. The bucket is declared inneon.tsinstead of created in the console, and credentials are injected per branch. - Move the data with a list-and-copy loop between two S3 clients (source AWS, destination Neon).
- What does not carry over: S3 bucket policies, event notifications and Lambda triggers, storage classes and Glacier transitions, and cross-region replication. Object CRUD and presigning do.
- The payoff is everything else in this series: once the files are on Neon, they branch with your database.
Prerequisites
- An existing S3 bucket and credentials that can read it
- A Neon project on the platform preview with a declared bucket (
us-east-2) - The AWS SDK (
@aws-sdk/client-s3,@aws-sdk/s3-request-presigner)
What stays the same
This is the reassuring part. The application code that touches storage does not change, because it is the S3 API on both sides. The same PutObjectCommand, GetObjectCommand, and getSignedUrl calls run against either store. The only thing that differs is which client you hand them to.
The diff that changes
Three config differences and two operational ones:
endpoint. AWS infers it from the region; for Neon you set it explicitly to the injectedAWS_ENDPOINT_URL_S3.region. Pin it tous-east-2. The runtime injects anAWS_REGIONthat is actually the storage-cell host, which the SDK rejects as a region, so do not read it from the environment.forcePathStyle: true. Neon storage is path-style (endpoint/bucket/key), not virtual-hosted (bucket.endpoint/key).- Where the bucket comes from. Instead of creating it in the AWS console or Terraform, you declare it in
neon.tsunderpreview.buckets. It is provisioned with the branch. - Credentials. Instead of long-lived access keys in your environment, the credentials are injected per branch by
neon deploy. That is one fewer secret to rotate and store.
Moving the objects
The data move is a list-and-copy loop: list the source bucket, stream each object from AWS, and put it into Neon. Two S3 clients, one reads, one writes.
import { S3Client, ListObjectsV2Command, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
const source = new S3Client({ region: 'us-east-1' }); // AWS
const dest = new S3Client({
region: 'us-east-2',
endpoint: process.env.AWS_ENDPOINT_URL_S3, // Neon
forcePathStyle: true,
});
const SRC_BUCKET = 'my-prod-bucket';
const DEST_BUCKET = 'files';
let ContinuationToken: string | undefined;
do {
const page = await source.send(
new ListObjectsV2Command({ Bucket: SRC_BUCKET, ContinuationToken }),
);
for (const obj of page.Contents ?? []) {
const got = await source.send(new GetObjectCommand({ Bucket: SRC_BUCKET, Key: obj.Key }));
await dest.send(
new PutObjectCommand({
Bucket: DEST_BUCKET,
Key: obj.Key,
Body: got.Body, // stream straight through
ContentType: got.ContentType,
}),
);
console.log(`copied ${obj.Key}`);
}
ContinuationToken = page.NextContinuationToken;
} while (ContinuationToken);
Run it once to backfill, keep dual-writing for a short window if you cannot take downtime, then cut reads over. The destination side of this loop, the PutObject into Neon, is exactly what the demo does on every upload, so it is the tested path; the source side is standard S3 you already run.
Two things to verify before you cut over. First, if your database stores full S3 URLs rather than bare object keys, those rows point at the old host; migrate to storing keys, or rewrite the URLs. Second, keep the keys identical across the move so nothing else has to change; the loop above preserves them.
What does not carry over
Being honest about the edges saves you a surprise in production. The core object operations port cleanly (these are the ones the demo exercises); the larger-object and S3 platform features around them you should confirm against the preview before you rely on them, since this is an early preview and the surface is still filling in:
| Feature | Carries over? |
|---|---|
PutObject / GetObject / DeleteObject |
Yes (verified in the demo) |
Presigned URLs (getSignedUrl) |
Yes (verified in the demo) |
| List, prefixes, pagination | Yes (verified in the demo) |
| Multipart upload | Part of the S3 API; verify for your large-object uploads |
| Bucket policies / ACLs | Check; model differs |
| Event notifications, Lambda triggers | No direct equivalent |
| Storage classes, Glacier transitions | No |
| Cross-region replication | No (single region preview) |
If your app leans on S3 events to kick off processing, you will replace that with the function doing the work inline or enqueueing it after the write. If you depend on Glacier tiering, this is not that. For the common case, upload, store, serve, and now branch, the port is the config change above plus the copy loop.
The repo
The working Neon storage client (the destination side of the migration, plus direct and presigned uploads) is here:
Wrapping up
S3 compatibility is what makes this a config change instead of a rewrite. Your upload and download code does not know the difference; you repoint the client, declare the bucket on the branch, drop the long-lived keys, and run a copy loop once. Check the short list of S3 platform features that do not come along, and if you are in the common case you are not, then the reward is that your files finally branch with your database like the rest of your state.
We earn commissions when you shop through the links below.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
SMTPfast
Developer-first email API
Send transactional and marketing email through a clean REST API. Detailed logs, webhooks, and embeddable signup forms in one dashboard.
QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?
Related Posts
Also worth your time on this topic
Presigned-URL Uploads From a Serverless Function
Streaming user uploads through your API means the bytes cross your server twice, and serverless functions have request-size limits that make it worse. Presigned URLs let the browser upload straight to object storage while your function just hands out permission. Here it is on a Neon Function, tested end to end.
Complete Web Server Automation with Ansible
Build a comprehensive Ansible playbook to automate web server deployment, configuration, and security hardening across multiple environments.
75 minutes