Deploying the API and the database
The API is stateful, it holds connections, and it owns a database that must survive every deployment. That is where the difficulty is, and none of it is solved by picking a good host.
The database first, because it outlives everything
The application can be redeployed a hundred times. The data cannot be redeployed at all.
Managed PostgreSQL, not one you run. Neon, Supabase, Railway, RDS, DigitalOcean — pick one. Running your own means backups, replication, failover, patching and disk monitoring, and on the day it goes wrong you will be doing all of it at two in the morning for a shop that makes a few thousand rupees a day. The managed version costs a few dollars a month and somebody else is on call.
Three things to check before choosing, all of which are cheap now and expensive later:
Automated backups, with point-in-time recovery. The question is not "do you have backups" but "can you restore to 14:32 yesterday, before the bad migration ran?"
Restore, once, before you need it. An untested backup is a belief, not a backup. Restore it into a scratch database and look at the data.
Where it is. A database in Virginia and an API in Mumbai means every query crosses an ocean. A checkout makes several. Put them in the same region, and put that region near your customers.
Connection pooling is not optional on serverless
DATABASE_URL=postgresql://user:pass@host:5432/kirana?connection_limit=10&pool_timeout=20
PostgreSQL handles a few hundred connections. Every serverless function instance opens its own, so a traffic spike opens hundreds at once and the database starts refusing them — including the ones your healthy instances need.
If the API runs as a long-lived container, Prisma's own pool is fine. If it runs on serverless, put PgBouncer or your provider's pooler in front. Getting this wrong looks like "the database is down" under load, when the database is fine and is simply out of connection slots.
Migrations run before the new code
npx prisma migrate deploy
migrate deploy, never migrate dev. dev is interactive, it can reset the
database, and it generates new migration files — all correct on your machine and
catastrophic in a deployment.
The order matters:
1. run migrations
2. deploy the new code
3. health check passes
4. traffic shifts over
Migrations first, because the new code expects the new schema. Which leads to the rule that makes zero-downtime deployment possible at all:
A migration must be compatible with the code already running.
For a moment — sometimes a long moment — the old code and the new schema are live together. So:
| Safe | Dangerous |
|---|---|
| add a nullable column | drop a column |
| add a table | rename a column |
| add an index concurrently | add a NOT NULL column with no default |
| widen a type | narrow a type |
Renaming deliveryPhone to phone in one migration breaks every running
instance the moment it lands. The safe version is four deployments: add the new
column, write to both, backfill, then stop reading the old one and drop it.
That feels absurdly cautious until the first time you take a shop down in the middle of a Saturday evening.
Configuration that refuses to be wrong
ConfigModule.forRoot({ isGlobal: true, cache: true, validate: validateEnv })
The first lesson of this module. Worth restating here because deployment is where it pays: a container that will not start is a failed deploy, and the platform keeps the old one running. A container that starts with no JWT secret is an incident.
Verified by actually trying it:
Environment is not usable:
JWT_SECRET: String must contain at least 32 character(s)
CORS_ORIGIN: Required
DATABASE_URL: Invalid input: must start with "postgres"
Two health checks, not one
@Get("live")
live(): { status: "ok" } {
return { status: "ok" };
}
@Get("ready")
async ready() {
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
throw new ServiceUnavailableException("Not ready");
}
return { status: "ok", database: "ok" };
}
They answer different questions, and conflating them causes outages.
Liveness: is the process up? Nothing more. A platform restarts a container that fails this. If liveness checked the database, a database blip would restart every API container at once — turning a recoverable problem into an outage, for no gain, because restarting an API does not fix a database.
Readiness: should this instance get traffic? This one does check, because an API that cannot reach PostgreSQL can serve nothing useful. The load balancer takes it out of rotation and puts it back when it recovers. No restart, no dropped connections.
SELECT 1 rather than a real query: it proves the connection works without
depending on any table existing.
And note what the failure does not say:
throw new ServiceUnavailableException("Not ready");
No detail. Health endpoints are unauthenticated, and a connection string in an error body is a connection string on the internet.
Shutting down properly
app.enableShutdownHooks();
When a platform replaces a container it sends SIGTERM and waits — usually
about thirty seconds — before killing it.
Without this, the process dies immediately, mid-request. For a checkout that means a customer seeing an error for an order that was actually placed, and then ordering again.
With it, Nest stops accepting new connections, finishes what is in flight, runs
onModuleDestroy — which is where PrismaService.$disconnect() lives — and
exits. Every deploy becomes invisible instead of a handful of errors.
This is also why the Dockerfile execs node directly rather than going through
npm: npm would swallow the signal and none of this would happen.
The image
FROM node:20-slim AS build
…
RUN npm ci
RUN npm run build --workspace=packages/shared \
&& npx prisma generate --schema apps/api/prisma/schema.prisma \
&& npm run build --workspace=apps/api
RUN npm ci --omit=dev && npm cache clean --force
prisma generate at build time, not startup. The generated client is
specific to the schema and the platform, so generating it at boot adds seconds
to every cold start and can fail in production for the first time.
npm ci --omit=dev after building strips the toolchain the runtime never needs.
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
openssl because Prisma's query engine needs it, and slim images do not have
it. The error when it is missing does not mention OpenSSL, which is how it
becomes an hour.
Where to run it
| Good for | Watch out for | |
|---|---|---|
| Railway / Render | one command, a database alongside | cost at scale |
| Fly.io | close to your users, cheap | you manage more |
| AWS ECS / Cloud Run | you are already there | a day of setup |
| Serverless (Lambda) | spiky traffic | cold starts, connection limits |
For a kirana shop: Railway or Render, with their managed PostgreSQL, in the region nearest the customers. Two hundred rupees a month, and the interesting problems stay in the application.
Serverless is the tempting wrong answer here. A Nest application on Lambda means cold starts on a checkout, and the connection problem above becomes your main operational concern. It is a good fit for spiky, stateless work and a poor one for a stateful API with a database.
CORS, and the thing it is not
app.enableCors({
origin: process.env.CORS_ORIGIN,
credentials: true,
});
No fallback, because the config schema already guarantees it is set and a permissive fallback is how an API becomes readable by any site on the internet.
But be clear about what CORS is: a browser-enforced rule, not a security
boundary. It stops a page on another origin reading your API with the user's
cookies. It stops nothing at all from curl, or a script, or anything that is
not a browser.
The actual security is the session cookie, the guards and the signature checks — everything modules 8, 13 and 14 built. CORS is one layer, and the least load- bearing one.
Check your work
Why managed PostgreSQL: backups, failover and patching are somebody else's job, and the alternative is doing all of it at 2am.
Why restore a backup before you need it: an untested backup is a belief.
Why region matters: a checkout makes several queries, and each crosses the ocean twice.
Why pooling matters on serverless: each instance opens its own connections, and PostgreSQL runs out of slots.
Why migrate deploy, never migrate dev: dev is interactive, can reset
the database and writes new migration files.
Why migrations must suit the old code: both are live at once during a deploy.
Why liveness must not check the database: a blip would restart every container, and restarting an API does not fix a database.
Why the readiness failure has no detail: the endpoint is unauthenticated.
What enableShutdownHooks buys: in-flight requests finish, so a deploy does
not error somebody's checkout.
Why prisma generate at build time: it is specific to schema and platform,
and generating at boot can fail in production first.
What CORS is not: a security boundary. It constrains browsers and nothing else.
Practice
- Deploy the API to Railway or Render with their PostgreSQL. Run
migrate deployand then the seed. - Hit
/api/health/liveand/api/health/ready. Then stop the database and hit both again. - Work out what your platform would do to a container failing each one.
- Write a migration that renames a column. List the four deployments that would make it safe.
- Remove
enableShutdownHooks, start a slow request, and redeploy. Watch what the client gets. - Take
opensslout of the Dockerfile, rebuild, and read the error. Decide how long it would have taken you. - Move
prisma generateto startup and time a cold start against a warm one. - Set
CORS_ORIGINto the wrong domain. Confirm the browser is blocked andcurlis not. - Restore a backup into a scratch database and check the data is really there.
- Price this shop for a year on two of the platforms above, with the database.
Next: knowing when it breaks, before a customer tells you.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship