DEV Community

Leaving Netlify and Render without losing a user, a video or a link

By Simon Frisk, Eyevinn Technology

In the previous post I moved users from Supabase to Open Source Cloud without anyone resetting a password. This time it is the hosting itself: a full-stack app leaving Netlify and Render for Open Source Cloud (OSC), and bringing its database and media along.

The app is Classync, a video review tool where teachers and students leave time-coded comments on videos. Three rules applied. Every user keeps their password, everything comes along (videos, comments, projects, all of it), and every old link keeps working.

Everything on the OSC side is open source, so the same steps work in reverse. Each step ends with what moving again would take.

What the move actually covers

Classync is small: 24 users, 179 comments, 21 projects, about 0.1 MB of data and about 1.7 GB of media. Every problem below came from behavior, not scale.

Classync review page: a video player with three time-coded comments from a demo user in a side panel and matching markers on the video timeline

Getting it running on OSC

OSC runs open-source services as managed instances, and runs your own code next to them as a My App. I did the work with a small team of Claude Code agents (plan, build, deploy, review), and I made the call at each step. The snippets below are illustrative, written for this post rather than copied from the repo.

Step 1: Map what Netlify and Render did

Before moving anything, list everything each platform did for you, not just the obvious parts.

Job Before On OSC
Static frontend, SPA fallback Netlify, with _redirects Served by the backend My App
Node backend Render, with build, start and env vars in its dashboard Same My App
Config and secrets Render's dashboard One parameter store
Database MongoDB Atlas FerretDB, on a PostgreSQL backend
Media Cloudinary MinIO
Backups None of its own Nightly dumps to a separate MinIO

The quiet part on Netlify was this line, sending every unknown path to the app shell:

/*  /index.html  200
Enter fullscreen mode Exit fullscreen mode

Its replacement must do the same, or deep links break.

Moving again: every row on the right is plain Node, environment variables, MongoDB-compatible or S3-compatible, so the map works backwards too.

Step 2: Turn two services into one My App

A My App is built from the repo with build and start scripts at the root, so one root package.json builds both halves. Folder names here are examples:

{
  "scripts": {
    "build": "cd frontend && npm ci && npm run build && cd ../backend && npm ci",
    "start": "node backend/server.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

The OSC My Apps list with the Classync backend highlighted: type nodejs, status Running, deployed from a migration branch of the app's GitHub repository

The frontend first went on My Pages, OSC's static hosting. It served / but returned 404 on reload of client routes like /review/:id or /signUp?token=.... So Express serves the built frontend instead, with health first and the browser fallback last. This is Express 4; the * route is written differently in Express 5:

app.get('/health', (req, res) =>          // 1. health first, never the app shell
  res.sendStatus(mongoose.connection.readyState === 1 ? 200 : 503));
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(spec)); // Swagger moved off "/"
app.use(apiRouter);                       // 2. API routes
app.use(express.static(distDir));         // 3. built assets
app.get('*', (req, res, next) =>          // 4. browser fallback, last
  req.accepts('html') ? res.sendFile(path.join(distDir, 'index.html')) : next());
// At boot: retry the database with backoff, then process.exit(1)
// so the platform restarts the app.
Enter fullscreen mode Exit fullscreen mode

One origin means no CORS between frontend and API. The health check changed too. Before, a database outage at boot left the app up, broken, and reporting OK.

Moving again: plain Node and Express with standard root scripts. Nothing in the code is OSC-specific.

Step 3: Move environment variables to a parameter store

The backend's variables from Render, and the frontend's build-time variables, now live in one OSC parameter store attached to the app.

The OSC parameter store's Manage Parameters dialog listing the app's configuration keys, with secret values shown as three asterisks and a lock icon

Two things to know:

  • Vite inlines VITE_ variables at build time, and the platform runs the build, so they go in the same store.
  • Keep one-off switches out of it. A flag that wipes or reseeds the database, such as RESET_DB, is read again on every restart.

Moving again: the app reads ordinary environment variables; the parameter store just supplies them.

Step 4: Bring the data along without password resets

FerretDB is the MongoDB-compatible database on OSC, so Mongoose only needs a new connection string. Rehearse first: mongodump from Atlas, mongorestore into a throwaway FerretDB, then compare counts, indexes and password hashes.

The passwords are bcrypt hashes, so a straight copy keeps every password working. I checked the hashes, then logged in with my real account. Users still sign in once on the new address, because the login token in browser storage belongs to the old site.

Two things behaved differently. FerretDB 2.7 has no multi-document transactions, and writes inside a transaction apply at once with nothing to roll back. And unsorted queries came back in a different order. MongoDB never promised an order there. Atlas was just consistent.

// Ordered steps instead of a transaction. Reassign first,
// so a failure never leaves orphaned comments.
await Comment.updateMany({ author: user._id }, { author: placeholder._id });
await User.deleteOne({ _id: user._id });

// Every list a user sees gets an explicit sort.
Comment.find({ project: projectId }).sort({ timestamp: 1, _id: 1 });
Enter fullscreen mode Exit fullscreen mode

OSC's built-in backup tool does not cover the FerretDB backend, so the app writes its own nightly mongodump-format backup to MinIO. A restore drill passed.

Moving again: the code runs unchanged against Atlas or self-hosted MongoDB, and the nightly backups restore into any MongoDB with plain mongorestore.

Step 5: Bring the media along

Uploads used to pass through the backend, but OSC My Apps have a request size limit, so large files can no longer go that way. The browser now uploads straight to MinIO as S3 multipart uploads in 8 MiB parts, and the backend only signs:

// Backend: start, sign each part, complete.
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({ Bucket, Key }));
const partUrl = await getSignedUrl(s3, new UploadPartCommand({ Bucket, Key, UploadId, PartNumber }));
await s3.send(new CompleteMultipartUploadCommand({ Bucket, Key, UploadId, MultipartUpload: { Parts } }));

// Browser: PUT each slice, with no extra headers.
const res = await fetch(partUrl, { method: 'PUT', body: file.slice(start, start + PART_SIZE) });
Enter fullscreen mode Exit fullscreen mode

Classync's Create a new project dialog mid upload, with a progress bar at 69 percent and a Cancel upload button

OSC's ingress allows only a few request headers on cross-origin calls, hence no extra headers. Videos are capped at 400 MiB, but that is a setting I chose (MAX_VIDEO_MB), not a technical limit. With 8 MiB parts, S3 multipart allows up to 10,000 parts, about 78 GiB per file, so the real limits are storage space and upload time. On Cloudinary's Free plan the ceiling was 100 MB. The browser now also makes thumbnails and refuses HEVC video and .mov files with uncompressed audio, asking for an MP4 export.

The copy covered only files the database references, out of about 1.7 GB in Cloudinary: 64 objects, about 780 MB, each hash-verified. The rest were files no record pointed to, such as Cloudinary's sample files and leftovers from deleted uploads, so they were left where they were. The stored URLs in 67 documents were rewritten only after a dry run showed every URL mapped, a backup was taken and a reverse run was ready as the undo.

MinIO versioning, with a 90-day rule for old versions, keeps deleted files recoverable, and a nightly job mirrors the media into the backup MinIO. But on the MinIO release OSC runs, plain delete permission also covers old versions, so the app's MinIO credentials needed an explicit Deny on s3:DeleteObjectVersion. Only the MinIO owner account can permanently delete media.

Moving again: MinIO is S3-compatible and the uploads are standard S3 multipart with presigned URLs, so the same code works against AWS S3 or any S3-compatible storage. Leaving MinIO would mean a copy and a settings change, with no code to rewrite.

Step 6: Cut over and keep old links working

After one more rehearsal against the production database, the switch was a fresh read-only dump from Atlas, a restore into FerretDB, a check of counts and hashes, and a new database setting. It took about 3.5 minutes, mostly two app rebuilds.

Then the old Netlify site became a forwarder. One line in its _redirects keeps path and query, so invitation links still work:

/*  https://<new-host>/:splat  301!
Enter fullscreen mode Exit fullscreen mode

Moving again: Render, Atlas and Cloudinary stayed a working fallback throughout, so the way back was always open.

Why open source matters here

Open source means no black box: you can read the code and run the exact same software yourself. It also means you are not locked in:

  • The software moves with you. FerretDB and MinIO run the same on your own server or another cloud, so a price change or a shutdown does not mean rebuilding.
  • Your data stays in open formats. MongoDB dumps and S3 objects work anywhere.
  • You set the limits and choose where data lives. Upload caps are your own settings, not a pricing plan's. For a school app, it also means keeping student data where it has to be.

This move showed the difference. Leaving Cloudinary meant a new storage driver, a copy job and 67 rewritten records. Leaving OSC would mean the same dump, copy and settings change, with no code to rewrite.

Wrapping up

The app will run on about 41 OSC tokens per day once the two rehearsal databases are removed: the app, the parameter store, FerretDB and its PostgreSQL backend, and the two MinIO instances for media and backups. Render, Atlas and Cloudinary stay as a fallback for about four weeks.

If you have an app split across Netlify and Render, try the same move on Open Source Cloud. If something behaves differently for you, let us know in the community Slack or on GitHub.

What is Open Source Cloud?

Managed open-source cloud for developers who want infrastructure, not DevOps. Run unmodified open-source services, deploy your own code alongside them as a My App, and spin up Postgres, Valkey and S3-compatible storage without touching Kubernetes or cloud config.

Top comments (0)