Your AI-Built App Assumes a Database That Isn't There
Ask an AI tool for an app that stores anything and it will make a database assumption without mentioning it. There are three common ones, and each fails differently once deployed: SQLite written next to the code, which works perfectly until the container is recreated and then silently starts empty; a DATABASE_URL the platform is expected to provide, which nothing here injects because there's no managed database; and a private hostname like db:5432 lifted from a generated docker-compose.yml, which doesn't resolve because compose isn't a deploy unit and there's no private networking. Identify which one you have and each takes a few minutes to fix.
Assumption 1: SQLite next to the code
The default for anything small. You'll see it as sqlite:///./app.db, ./data.db, or a bare filename.
It works locally and it appears to work after deploying — right up until the first redeploy, when the container is replaced and the file goes with it. Anything outside a volume is discarded when the container is recreated, and that happens on every deploy and every configuration change.
The failure is nasty because it isn't an error. Your app recreates an empty database and carries on, so you find out when a user mentions their data is gone.
The fix is two changes:
- Attach a volume at a path like
/data. - Make the path configurable rather than hardcoded, so the same code runs locally and deployed:
DB_PATH = os.environ.get("DB_PATH", "./app.db")
Then set DB_PATH=/data/app.db on the app. SQLite on a volume is a genuinely good choice for a single-process app — it's local disk, which is what SQLite's locking wants.
Assumption 2: DATABASE_URL will be provided
Models trained on Heroku-era deployment write code that reads DATABASE_URL and expect the platform to inject it, because Heroku, Railway, and Render all do when you attach their managed database.
There's no managed database here and nothing injects that variable. You run a database as an app, the same way you run anything else — there's a one-click Postgres template, or you can deploy any database image yourself.
The code doesn't need changing. Reading DATABASE_URL from the environment is exactly right; you just set the value yourself once the database app exists and you know its address.
Worth thinking about before you do, though: do you actually need a second app? The rule that decides it is whether more than one process needs the data — volumes belong to a single application and can't be shared, so two apps needing the same data means a real database, and one process rarely does.
Assumption 3: a compose file and a private hostname
The most confusing one, because the generated project looks complete.
Ask for a full-stack app and you'll often get a docker-compose.yml with web, db, and maybe redis, with the app connecting to postgres://user:pass@db:5432/app. That hostname works under compose, which creates a network where service names resolve.
Compose isn't a deploy unit here. A compose file is treated as a hint that a project has deployment configuration, but nothing parses or runs it. Each service in it has to become its own application, deployed separately.
And when you do that, db:5432 still won't resolve — there's no private networking. Every port binding gets a public DNS name, so your app reaches its database over a publicly-reachable TCP endpoint with credentials as the only boundary.
Three things follow:
- Use a long random password. It's the entire protection, not a formality.
- Expect the network to be real. Connections cross the internet, so set connect timeouts and handle reconnects rather than assuming a socket to
localhostthat never fails. - Remove the port binding when you don't need it. If only your app talks to the database and you're not connecting from a local client, the exposure isn't buying you anything.
Migrations that run on every boot
A related pattern worth catching, because it's nearly universal in generated code: schema creation at startup. Base.metadata.create_all(), db.create_all(), or a migrate command in the entrypoint.
Locally this runs once. Here it runs on every container start — every deploy, every configuration change, and every iteration of a crash loop, where it hammers your database several times a minute while you debug something unrelated.
create_all() is idempotent and mostly harmless. Real migrations are not: a half-applied migration during a restart is how you get a schema nobody can reason about, and a crash loop will retry it for you until it is. If two separate apps share one database, they can also attempt the same migration at the same time.
Better patterns:
- Run migrations deliberately — from the app's shell, as a one-off, when you intend to change the schema.
- Or guard them behind a
RUN_MIGRATIONS=truevariable you set for one deploy and unset afterwards.
Either way, a failed migration should stop the app with a clear error rather than leaving it running against a schema it doesn't match.
Sizing the pool
Two adjustments once the database is a separate app across a real network:
Keep the pool small. A default pool of 20 connections from an app on a fraction of a vCPU is optimistic — your CPU is a time budget shared by the whole container, and idle connections cost the database memory. Five is plenty for most small apps.
Both apps draw from the same allocation. A Postgres container takes CPU and RAM from your account's limits, so the database and the app compete for the same pool. That's the real cost of splitting — not the app count, which isn't a billing unit.
FAQ
Where should my SQLite file live?
On a volume — /data/app.db or similar. Anywhere else is discarded when the container is recreated, which happens on every deploy.
Why is my database empty after deploying?
The file was written inside the container rather than on a volume, so it was replaced along with the container. There's no error; the app just creates a new one.
Is DATABASE_URL set automatically?
No. There's no managed database. Deploy Postgres as its own app and set the variable yourself.
Why can't my app reach db:5432?
That hostname only exists under docker-compose, which isn't used here. Each service becomes its own app, and they talk over public addresses.
Is my database exposed to the internet?
If you bind a port, yes — there's no private networking. Use a strong password, and remove the binding if only your own app connects.
Should I run migrations at startup?
Not for real migrations. Startup code runs on every restart, including during crash loops. Run them deliberately or gate them behind a flag.
Wrapping up
None of these assumptions are wrong in general — they're right for a laptop, for Heroku, and for docker-compose respectively, which is exactly why models produce them. What they share is an environment that provides something this one doesn't: a persistent working directory, an injected connection string, or a private network. Work out which of the three you're holding, put your data on a volume or in its own app, and stop the schema from being recreated every time your process restarts.