Your app worked perfectly, then every user's data vanished on redeploy
AI coding tools default to SQLite because it needs no setup. On Vercel, Netlify, Replit Autoscale, Render, Railway, Fly.io and every other container platform, the file that database lives in is thrown away when the container is replaced. The app is not broken. The storage was never durable.
Why did my app lose all its data when I deployed?
Because your database is a file inside a container, and deploying replaces the container. Almost every AI-scaffolded app starts on SQLite, which stores the entire database in one file on the local disk. On a modern deployment platform that disk is ephemeral: it exists for the life of one container instance, and a deploy, a scale event, a crash or an idle timeout gives you a brand new one built from the image, with no file in it. This affects any app running SQLite on Vercel, Netlify, Replit Autoscale, Render, Railway, Fly.io, Heroku, Cloud Run, or plain Docker without a mounted volume.
A Replit user described the shape of it exactly on r/replit: "Most AI tools default to SQLite... when it resets, it deletes that notepad file. Poof. All user data is gone." That is not a metaphor. There is nothing to recover from, no corruption to repair, no backup to restore. The file that held every signup, order and message was in a temporary location, and the platform did what it says it does with temporary locations.
The cruel part is how well it hides. SQLite works perfectly in development, where the file sits on your own machine and nothing ever replaces it. It works perfectly in the first deploy, because the container is new and the app writes rows into it happily. It works for hours or days. Then you push a fix, and the app comes back up looking exactly the same, completely empty.
How do I tell if my app is on ephemeral SQLite?
Two checks. A grep of your repo tells you in thirty seconds whether SQLite is in the stack, and a write-then-redeploy probe against production proves in five minutes whether the data actually survives. Do both, in that order.
Grep the repo for the tell-tale signs
30 seconds# Is SQLite in the dependency tree at all?
grep -nE '"(better-sqlite3|sqlite3|@libsql/client|sequelize|typeorm|knex)"' package.json
# Any database files sitting in the repo or working directory?
find . -path ./node_modules -prune -o \
\( -name "*.db" -o -name "*.sqlite" -o -name "*.sqlite3" -o -name "*.db3" \) -print
# The two most common smoking guns, if you are on Prisma
grep -n "provider" prisma/schema.prisma
grep -rn "DATABASE_URL" .env .env.local .env.production 2>/dev/null
You are looking for any of these:
provider = "sqlite"inprisma/schema.prismaDATABASE_URL="file:./dev.db"anywhere. Thefile:prefix is the whole diagnosis.- A
new Database('./data.db')orsqlite3.Database(...)call in your server code - Python:
sqlite:///app.dbin a SQLAlchemy URL, ordjango.db.backends.sqlite3insettings.py - A
.dbfile listed in.gitignore, which means it was never in your repo and never in your deploys
Also worth knowing: if that .db file is committed to git, every deploy resets the database to whatever was in the file when you committed it. That looks like data loss too, but it is a different flavour: the data does not vanish, it reverts.
The probe that settles it: write, redeploy, read
5 minutesThe grep tells you what the code says. This tells you what production does. Run it against your live app, right now, before you write another feature.
# 1. Write a row you will recognise
curl -s -X POST https://your-app.com/api/items \
-H "Content-Type: application/json" \
-d '{"title":"persistence-probe-2026-09-12"}'
# 2. Confirm it is there
curl -s https://your-app.com/api/items | grep persistence-probe
# 3. Force a redeploy with no code change at all
git commit --allow-empty -m "persistence probe redeploy"
git push
# 4. Wait for the deploy to finish, then ask again
curl -s https://your-app.com/api/items | grep persistence-probe
If step 4 returns nothing, your storage is ephemeral and it is confirmed. If you have no API to call, do the same thing through the UI: create an account with a memorable email, redeploy, and try to log in. Failing to log in as an account you created two minutes ago is the same result.
Do not skip step 3 by waiting to see if data disappears on its own. It will, eventually, when the platform recycles the instance, but that can be hours away and you want the answer now.
Add a route that reports the database's own age and size. It costs five lines and turns "I think we lost data" into a number you can watch:
// app/api/health/route.ts (Next.js App Router)
import fs from 'node:fs';
import db from '@/lib/db';
export async function GET() {
const { c } = db.prepare('select count(*) as c from items').get();
let file = null;
try {
const s = fs.statSync('./data.db');
file = { bytes: s.size, createdAt: s.birthtime.toISOString() };
} catch { /* the file does not exist, which is itself the answer */ }
return Response.json({ rows: c, file, bootedAt: new Date().toISOString() });
}
A createdAt that matches your last deploy time means the file was created by this deployment, not carried over from the previous one.
Why does the database file actually disappear?
Because a deployed container's filesystem is assembled from a read-only image plus a thin writable layer, and that writable layer belongs to the container instance, not to your application. Replace the instance and the layer goes with it. Nothing deletes your file. The file simply never existed anywhere that outlives a single container.
It is worth understanding the three separate mechanisms, because they fail at different moments and people misdiagnose them constantly.
Immutable deploys. Modern platforms build each deployment as a new, immutable artifact from your source. Deploy number 12 is not deploy number 11 with your changes applied to it, it is a fresh build. Anything written to disk by deploy 11 at runtime was never part of the source, so it is not part of deploy 12. This is the same property that makes instant rollbacks possible, and it is the reason the data is gone the moment you push.
The container writable layer. Container images are stacks of read-only layers. When a container starts, the runtime adds one writable layer on top, and every file your process creates lives there. Stop the container and that layer is discarded unless a volume was explicitly mounted over the path. This is why the same app in Docker on your laptop keeps its data (you are reusing the container) and loses it in production (each release starts a new one).
Serverless function filesystems. On Vercel and Netlify your code does not run in a long-lived server at all. It runs in a function instance that is created on demand, kept warm for a while, then destroyed. The bundled filesystem is read-only except for /tmp. So a SQLite file in your project directory usually cannot be written to at all, and the error is a confusing one:
The errors this produces, and what each one means
SQLITE_CANTOPEN: unable to open database file
-> the path is read-only, or the directory does not exist in the deployed bundle
Error: attempt to write a readonly database
-> the file opened, but the filesystem will not accept writes
SQLITE_READONLY_DBMOVED
-> the file moved or was replaced underneath an open connection
no errors at all, empty tables after every deploy
-> the app is writing successfully to /tmp, or to a fresh file it creates on boot.
This is the dangerous one, because nothing ever looks wrong.
That last case is the common one in AI-generated code, because when the first two errors appear, the obvious fix a model reaches for is to move the file to /tmp or to call fs.mkdirSync on boot. Both make the error go away. Neither makes the data durable. /tmp on a serverless function is scoped to one instance and destroyed with it, and two concurrent instances have two different databases.
That last point deserves its own sentence, because it bites even on platforms that do offer persistent disks. SQLite on a local disk cannot be shared between instances. The moment your platform runs two containers to handle traffic, each has its own copy of the file, and which data a user sees depends on which instance answered. Users report items disappearing and reappearing at random, which is a genuinely maddening bug to chase if you do not know this is the cause.
Which platforms delete the file, and when?
All of the common ones, unless you explicitly attach durable storage. The differences are in what triggers the reset and whether the platform lets you avoid it at all.
| Platform | What resets the filesystem | Can you keep a file-based database? |
|---|---|---|
| Vercel | Every deploy. Also every new function instance. Only /tmp is writable at all. | No. Use a managed database. |
| Netlify Functions | Same shape as Vercel: read-only bundle, per-instance /tmp. | No. |
| Replit Autoscale | Every deploy, and instances are stateless and can scale to more than one. | No. Replit's own guidance is to use a database, not the deployment filesystem. |
| Replit Workspace | The workspace disk does persist, which is exactly why this is confusing. | The workspace file is not the deployment's file. See below. |
| Render | Every deploy and every restart, unless you attach a Persistent Disk. | Yes with a Persistent Disk, but it pins the service to a single instance. |
| Railway | Every deploy, unless a Volume is mounted at the exact path the file lives in. | Yes with a Volume mounted at the right path. |
| Fly.io | Machine rootfs is replaced on deploy unless a Fly Volume is mounted. | Yes with a Volume, single region per volume. |
| Heroku | Every deploy, every dyno restart, and dynos restart at least daily. | No. |
| Docker / Cloud Run / Kubernetes | Every new container. The writable layer dies with the container. | Only with a mounted volume, which Cloud Run does not give you by default. |
On Replit the workspace filesystem and the deployment filesystem are different places. You can open the file browser, see data.db sitting there with all your rows, and conclude the data is fine, while your deployed app is serving from a completely separate, empty copy built from your source. Checking the workspace is not checking production. Run the write-redeploy-read probe against the deployed URL instead.
Can I recover the data that already disappeared?
Usually not, and you should find that out in the next ten minutes rather than spending a week hoping. Ephemeral storage is not deleted data waiting to be undeleted, it is storage that was never written anywhere durable. There is no file to carve back.
Four places are worth checking before you accept that, in descending order of how often they actually pay off:
- Git. If the database file was ever committed, an old copy exists in history.
git log --all --oneline -- '*.db' '*.sqlite*'will tell you in one command. It is stale data, but stale beats none. - The development machine. The copy on your laptop or in your Replit workspace is a real SQLite file with real rows in it. It is not production data, but if the app is young the overlap can be most of it.
- Platform snapshots. Fly Volumes take snapshots. Render Persistent Disks have snapshots on paid plans. Replit keeps workspace history and checkpoints. If you were on one of those with durable storage and a deploy still cleared it, there may be a restore point. If you were on Vercel or Netlify, there is not.
- Your own side effects. Transactional emails, Stripe records, webhook logs, analytics events and support inbox history often contain enough to reconstruct who signed up and what they bought, even when the rows are gone. This is tedious and it is frequently the only thing that works.
If none of those produce anything, the honest answer is that the data is gone. Write that down, tell affected users plainly, and move to the migration below so it cannot happen a second time. An app that loses data twice loses the users too.
Is SQLite the wrong database to use?
No. SQLite is an excellent, extremely well-tested database and it powers an enormous amount of production software. The failure here is not SQLite, it is putting any single-file database on storage that the platform is contractually going to throw away. Blaming SQLite leads people to the wrong fix.
SQLite is genuinely the right choice when the file lives on durable storage you control, when one process owns the writes, and when you have a real backup story. That describes a Fly.io machine with a volume, a VPS, a desktop app, or a mobile app. There are also hosted products built specifically to give SQLite durability and replication, and they are a legitimate path if you like the model.
It is the wrong choice when your platform is serverless, when your app can run on more than one instance at once, or when you cannot answer the question "where exactly does this file live, and what backs it up?" For most people who arrived here after an AI tool picked the default, all three of those are true, and managed Postgres is the shorter road than making file storage durable.
How do I migrate from SQLite to managed Postgres?
Six steps: snapshot what you still have, provision Postgres, move the schema, move the rows, fix the sequences, then cut over and re-run the persistence probe. On a small app this is two to four hours, and most of it is waiting.
Snapshot the data you still have, right now
Before anything else, before another deploy, take a consistent copy. Copying the file with cp while the app is running can give you a torn copy, because SQLite may have uncommitted state in a write-ahead log file alongside it. Use SQLite's own backup command, which handles that.
sqlite3 ./data.db ".backup './data-backup.db'"
# a plain-text dump as a second copy, in case the binary one has a problem
sqlite3 ./data.db .dump > ./data-backup.sql
# sanity check: what is actually in there?
sqlite3 ./data-backup.db ".tables"
sqlite3 ./data-backup.db "select count(*) from users;"
Write those counts down. They are how you will verify the migration worked, and guessing at them afterwards is how people quietly lose a table.
Provision a managed Postgres and grab both connection strings
Supabase, Neon, Railway Postgres and RDS are all fine choices. What matters more than which one is that you take both connection strings the provider gives you, because they are not interchangeable.
# DIRECT connection: use for migrations, schema changes, pgloader, psql
postgresql://postgres:pass@db.your-project-ref.supabase.co:5432/postgres
# POOLED connection: use for your serverless app at runtime
# note the username: it carries the project ref, plain "postgres" will not authenticate here
postgresql://postgres.your-project-ref:pass@aws-0-region.pooler.supabase.com:6543/postgres
The pooled endpoint runs in transaction mode (port 6543 at time of writing), which multiplexes many short-lived function invocations onto a small number of real Postgres connections. That is what stops a serverless app from exhausting max_connections the first time it gets busy. It also does not support session-level features some migration tools rely on, which is why schema work goes through the direct URL. Neon exposes the same split as a separate pooled hostname. Get this wrong in either direction and you will see either connection exhaustion under load, an authentication failure from the wrong username shape, or migrations that fail with confusing prepared-statement errors.
Move the schema
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // pooled, for the app
directUrl = env("DIRECT_DATABASE_URL") // direct, for migrations
}
rm -rf prisma/migrations # the old ones are SQLite-flavoured and will not apply
npx prisma migrate dev --name init
npx prisma generate
Read the generated migration before you run it against anything you care about. Prisma will translate your models, but any column you typed loosely in SQLite is a decision Postgres now forces you to make explicitly.
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL! },
});
npx drizzle-kit generate
npx drizzle-kit migrate
Let pgloader create the schema for you in step 04, then read what it produced and adjust. It is usually closer than hand-translating a .dump file, which is full of SQLite-specific syntax Postgres will reject line by line.
Move the rows with pgloader
pgloader is a free, open-source tool that reads a SQLite file and writes it into Postgres, doing the type mapping for you. It is the shortest path that does not involve writing a script.
# macOS: brew install pgloader
# Debian/Ubuntu: apt install pgloader
# or run it without installing anything:
docker run --rm -v "$PWD":/data dimitri/pgloader \
pgloader /data/data-backup.db \
"postgresql://user:pass@db.your-project.supabase.co:5432/postgres"
pgloader prints a summary table of rows read and rows written per table when it finishes. Compare it line by line against the counts you wrote down in step 01. Any table where those two numbers differ is a table you need to look at before you go further, not after.
If you already created the schema with Prisma or Drizzle in step 03, load into a scratch database first and copy rows across with insert into ... select, rather than letting pgloader redefine tables your ORM now owns.
Reset the sequences, or your next insert collides
Easy to missSQLite's autoincrement and Postgres's sequences are different machinery. Importing rows with existing IDs does not advance the Postgres sequence, so the first new insert tries to use ID 1 and hits a duplicate key error on a table that already has 4,000 rows. This is the single most common post-migration bug, and it appears the moment a real user tries to sign up.
select setval(
pg_get_serial_sequence('public.users', 'id'),
coalesce((select max(id) from public.users), 1),
true
);
Or generate the statements for every table at once and run the output:
select format(
'select setval(%L, coalesce((select max(%I) from %I.%I), 1), true);',
pg_get_serial_sequence(quote_ident(table_schema) || '.' || quote_ident(table_name), column_name),
column_name, table_schema, table_name
)
from information_schema.columns
where table_schema = 'public'
and column_default like 'nextval%';
Cut over, then prove it with the same probe that failed
For most apps at this stage the cheapest correct answer is a short maintenance window, not a dual-write scheme. Put up a maintenance page, take the final snapshot, load it, switch the environment variable, deploy, test. Twenty minutes of downtime beats a week of synchronisation complexity and the class of bugs it introduces.
# 1. maintenance mode on
# 2. final snapshot from the live file, repeat steps 01 and 04
# 3. set the env vars in your platform dashboard
# DATABASE_URL = the POOLED url
# DIRECT_DATABASE_URL = the DIRECT url
# 4. remove the old sqlite dependency so nothing can fall back to it
npm uninstall better-sqlite3 sqlite3
# 5. deploy, maintenance mode off
Run check 02 from the top of this article again, unchanged. Write a row, push an empty commit to force a redeploy, read it back. The test that failed before must now pass. That is the whole point, and it is the only evidence worth accepting.
curl -s -X POST https://your-app.com/api/items -H "Content-Type: application/json" \
-d '{"title":"persistence-probe-after-migration"}'
git commit --allow-empty -m "post-migration probe" && git push
# wait for deploy
curl -s https://your-app.com/api/items | grep persistence-probe-after-migration
Managed Postgres gives you automated backups and, on most paid tiers, point-in-time recovery. Turn it on, note the retention window, and restore one backup into a scratch database once so you know the restore actually works. A backup nobody has restored is a hope, not a plan.
What silently breaks after moving to Postgres?
SQLite is permissive in ways Postgres is not, so code that worked for months can start behaving differently without raising an error. Four differences account for most of it, and all four are easier to check deliberately than to debug later.
- Case-sensitive LIKE. SQLite's
LIKEis case-insensitive for ASCII by default. Postgres's is case-sensitive. Every search box in your app quietly stops matching things it used to match, with no error anywhere. Switch those queries toILIKE, or normalise the case on both sides. - Real types. SQLite stores whatever you give it regardless of the declared column type. Postgres does not. Booleans that were stored as 0 and 1, timestamps stored as text or as Unix integers, and numbers stored as strings all need a deliberate decision during the migration rather than a cast bolted on afterwards.
- Date and time functions.
datetime('now'),strftime(...)and SQLite's date arithmetic do not exist in Postgres. They becomenow(),to_char(...)and interval arithmetic. Grep your codebase for them before you deploy, not after a cron job fails at midnight. - Concurrency you were getting for free. SQLite serialises writers, so a race condition in your code may never have been able to fire. Postgres runs writes concurrently. Anything shaped like read-then-write, a counter, a balance, a quota, an "is this slot taken" check, can now interleave. Those need a transaction with the right isolation, or a single atomic statement.
There is also a cost dimension worth knowing before the first invoice rather than after. A managed Postgres is a real monthly line item where a file was free, and connection-heavy serverless traffic pushes you up tiers faster than row count does. If the bill is the thing making you hesitate, our cost-to-serve engineering page covers how that maths actually works on AI-built apps.
What does migrating to Postgres not fix?
It makes your data durable. It does nothing about who can read it, how fast it is, or whether an agent can drop it. Four things stay broken, and two of them get worse the moment you move.
- It does not bring back what you already lost. Migrating is a fix for the next incident, not this one.
- If you migrated to Supabase, you have just created a second problem. Supabase publishes every table in the
publicschema as a REST endpoint, and a table created by a migration has row level security off. Your durable data is now also readable by anyone with your anon key until you write policies. That is the exact failure covered in the Supabase RLS article, and it needs doing in the same sitting, not next month. - It does not fix slow queries, and it can expose them. SQLite reads were in-process and effectively free. Every Postgres query is now a network round trip, so the N+1 pattern AI codegen produces by default, fetch a list then loop and fetch a related row for each, goes from invisible to the reason your page takes four seconds. Scaling a vibe-coded app covers what to do about that.
- It does not separate development from production. This matters more than it sounds. In July 2025 Replit's agent deleted a production database during what its user had declared a code and action freeze, and told him so afterwards: "I deleted the entire database without permission during an active code and action freeze" (The Register, 21 July 2025). The protection against that is not a promise from an agent, it is two separate database projects with two separate connection strings, where the one the agent can see is not the one holding your customers. Set that up while you are already in the database settings.
When should I stop and hire an engineer?
If your app has no paying users and the data currently in SQLite is disposable, do it yourself today. The six steps above are the whole job, they are boring, and doing them badly costs you nothing because there is nothing to lose yet. That is genuinely the right call and you do not need us for it.
Get help when one of these is true:
- You have paying customers and the data has to survive the cutover intact. The migration is easy. Doing it without losing the rows written during the window, and knowing how to roll back if the load half-fails, is the part that benefits from having done it before.
- The types in your SQLite file are ambiguous. If timestamps are stored inconsistently, or a column holds both numbers and strings, somebody has to decide what each row means. Get that wrong and you will not notice for weeks, and by then new data is mixed in with the bad conversion.
- You cannot take a maintenance window. Zero-downtime migration means dual writes, a backfill, a verification pass and a reversible cutover. It is a real project, not an afternoon, and it is the correct answer only when downtime genuinely costs more than the complexity does.
- You are not sure the app can be pointed at a new database at all. If database calls are scattered through your components rather than sitting behind a data layer, changing the database means touching the whole app. That is a refactor wearing a migration's clothes.
Stop deploying. Every deploy destroys another window of data. Take a snapshot of whatever file still exists, from wherever it still exists, before you touch anything else. Then work through the checklist above in order. If you want a second pair of eyes on the cutover plan before you run it, tell us what you are on and we will tell you straight whether it needs us or not.
What to fix in the same sitting.
Also relevant: why a Replit app stops working and MVP to production.
The rescue playbook.
Real prompts, security rules, and teardowns of broken AI-built apps, sent when we publish something worth your inbox. No fluff, no drip sequence.
One or two emails a month. Unsubscribe anytime.
Not sure your data survives the next deploy?
Send us what you are running on and what your database actually is. If it is an afternoon of work you can do yourself, that is what we will say.
Common questions about vanishing data.
Why did my app lose all its data after I redeployed?
Because your database is a file inside a container, and deploying replaces the container. AI coding tools default to SQLite, which keeps the entire database in one file on the local disk, and on Vercel, Netlify, Replit Autoscale, Render, Railway, Fly.io and Heroku that disk exists only for the life of one container instance. A deploy builds a new immutable artifact from your source, so anything written to disk at runtime by the previous deploy is not part of it. Nothing deleted your data. It was never written anywhere durable.
Is SQLite a bad database?
No. SQLite is an excellent, extremely well-tested database and it powers an enormous amount of production software. The failure is not SQLite, it is putting any single-file database on storage the platform is going to throw away. SQLite is the right choice when the file lives on durable storage you control, one process owns the writes, and you have a real backup story. It is the wrong choice when your platform is serverless or your app can run on more than one instance at once, because each instance then has its own separate copy of the file.
Can I recover the data that disappeared?
Usually not, and you should find that out in ten minutes rather than spending a week hoping. Ephemeral storage is not deleted data waiting to be undeleted, it is storage that was never written anywhere durable. Check four places before accepting that: git history in case the database file was ever committed, the copy on your development machine or Replit workspace, platform snapshots if you were on Fly Volumes or a Render Persistent Disk, and your own side effects such as transactional emails, Stripe records and webhook logs, which often contain enough to reconstruct who signed up and what they bought.
Can I just add a persistent disk instead of migrating to Postgres?
On Render, Railway and Fly.io you can, and it will stop the data vanishing. It comes with a real constraint: a local file cannot be shared between instances, so attaching a disk usually pins your service to a single instance and rules out horizontal scaling. On Vercel, Netlify and Heroku it is not an option at all. If you are already on a platform that offers durable volumes and you are happy running one instance, a volume is a legitimate fix. If you are serverless, managed Postgres is the shorter road.
How long does migrating from SQLite to Postgres actually take?
On a small app with a clean schema, two to four hours, and most of that is waiting for deploys. The six steps are snapshot the existing data, provision a managed Postgres and take both connection strings, move the schema, move the rows with pgloader, reset the sequences so your next insert does not collide, then cut over and re-run the write-redeploy-read probe. It takes considerably longer when you have paying customers whose data must survive the cutover intact, or when the types in your SQLite file are ambiguous enough that somebody has to decide what each row means.