Engineering
Postgres on DigitalOcean App Platform: every wall we hit, and how we got past it
Connecting a Next.js + Payload CMS app to DigitalOcean Managed Postgres should take ten minutes. It took us an afternoon. Here's every error we met, what it actually meant, and the fix — so it takes you ten minutes.
Selom
Founder
7 min read

We recently moved our marketing site, flexbyt.com, onto a CMS. The stack is ordinary: Next.js on DigitalOcean App Platform, Payload CMS, and a DigitalOcean Managed PostgreSQL cluster, with Cloudflare in front.
Everything worked on localhost within an hour. Getting the same thing working in production took most of a day. Not because anything was broken, but because six sensible defaults stacked up against us. Each one gave an error that pointed somewhere slightly wrong.
This is the guide we wish we'd had. Each section is a real error we hit, what it actually meant, and the fix.
The setup, in one paragraph
Create a Managed PostgreSQL cluster. Create a database and a user for your app (don't use doadmin in your app). Put the connection string in your App Platform component as DATABASE_URI, deploy, and let Payload run its migrations on boot.
That's the theory. Here's what actually happened.
Headache 1: the connection just… hangs
Symptom: no error at all. psql sits there, then times out. From the app, requests hang until the platform gives up.
Cause: managed databases on DigitalOcean have a Trusted sources firewall. If your IP (or your app) isn't on the list, the connection isn't refused. It's silently dropped, which is why you get a hang and not an error.
Fix: go to Databases → your cluster → Settings → Trusted sources and add:
- your App Platform app (pick it from the list, don't add IPs by hand for it), and
- your own IP, if you want to connect from your laptop to run checks or migrations.
Gotcha inside the gotcha: if you're on a VPN, your public IP changes every time you reconnect. The rule you added yesterday won't cover you today.
Headache 2: no such database (it definitely exists)
Symptom:
FATAL: no such database: flexbytWe could see the flexbyt database right there in the control panel.
Cause: DigitalOcean exposes two ports on every cluster, and they expect different things after the slash:
Port | Connects to | The name in the URL is a… |
|---|---|---|
25060 | the database directly | database name |
25061 | PgBouncer, the connection pooler | pool name |
Our string had port 25061 with a database name. The pooler looked for a pool called flexbyt, found none, and reported it as "no such database".
Fix: in Connection details, pick the database (not a pool) in the dropdown and copy the string it generates. It'll use 25060. Or create a connection pool named for your app and use 25061 with the pool's name.
Rule of thumb: database name → 25060, pool name → 25061. If a string mixes them, it won't connect.
Direct connection or pool?
App Platform runs Next.js as a normal long-lived Node server, not short-lived serverless functions. Your ORM already keeps a small set of open connections and reuses them, so a direct connection is fine and simpler. Payload's migrations also behave more predictably on a direct connection than through PgBouncer in transaction mode.
Reach for a pool when you scale to several instances and start approaching the cluster's connection limit.
Headache 3: the pool that would have wrecked a different app
This one never errored, which is what makes it dangerous.
Our cluster also hosts another product's database. It already had a pool, reviewsjet_pool, and the connection string builder happily offered it. If we'd picked it, Payload would have run its migrations inside the other app's production database on first boot, creating users, posts and media tables beside tables that already existed.
Fix: one database per app, and a pool (if you use one) that points at that database. When several apps share a cluster, check the database name in every connection string before it goes anywhere near an environment variable.
Headache 4: self-signed certificate in certificate chain
Symptom:
Error: cannot connect to Postgres: self-signed certificate in certificate chainCause: DigitalOcean signs its database certificates with its own CA certificate, which Node doesn't trust by default. And in recent versions of node-postgres (pg 8.x), ?sslmode=require in the URL means "require SSL and verify the certificate". So the connection is encrypted, then rejected because the certificate can't be verified.
The tempting fix is rejectUnauthorized: false (or sslmode=no-verify). Don't. It turns off exactly the check that protects you from someone sitting between your app and your database.
The right fix: give the driver DigitalOcean's CA certificate.
- Download it: Databases → your cluster → Connection details → Download CA certificate.
- Add it to App Platform as an environment variable, e.g.
DATABASE_CA_CERT, scoped to run time. - Pass it to the driver, and remove
sslmodefrom the URL:
function databasePool() {
const uri = process.env.DATABASE_URI || "";
const ca = normalizePem(process.env.DATABASE_CA_CERT);
if (!ca) return { connectionString: uri }; // local Postgres
const url = new URL(uri);
url.searchParams.delete("sslmode"); // URI settings override the ssl object
return {
connectionString: url.toString(),
ssl: { ca, rejectUnauthorized: true },
};
}That searchParams.delete line matters. node-postgres lets settings in the connection string override the ssl object you pass in code. Leave sslmode=require in the URL and your carefully supplied CA certificate can be silently ignored.
And then the certificate itself got mangled
Some dashboards turn a pasted multi-line value into a single line with spaces where the line breaks were. A certificate in that shape is invalid, and Node reports it with… the same "self-signed certificate" error. So it looks as though your fix did nothing.
We rebuild the certificate whatever shape it arrives in:
function normalizePem(value?: string) {
if (!value?.trim()) return undefined;
const pem = value.replace(/\\n/g, "\n").trim();
const match = pem.match(/-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/);
if (!match) return pem;
const lines = match[1].replace(/\s+/g, "").match(/.{1,64}/g) ?? [];
return ["-----BEGIN CERTIFICATE-----", ...lines, "-----END CERTIFICATE-----", ""].join("\n");
}It handles real line breaks, \n-escaped ones, and ones flattened into spaces.
Headache 5: "I added the certificate and nothing changed"
This one's embarrassing, and extremely common.
We'd written the certificate-handling code, added DATABASE_CA_CERT in DigitalOcean, redeployed, and still got the certificate error. The environment variable was fine. The code that reads it had never been committed. Production was running the previous commit, which didn't know DATABASE_CA_CERT existed.
Fix: when a fix "doesn't work" in production, first check that production is running it. Compare the commit hash in your App Platform deployment with git log. Thirty seconds, and it would have saved us a deploy cycle.
Headache 6: permission denied for schema public
Symptom: finally connected, SSL verified, migrations start, and then:
Error running migration 20260927_023549_initial
... permission denied for schema publicCause: since PostgreSQL 15, ordinary users no longer get permission to create tables in the public schema by default. Our cluster runs Postgres 18. The app's user could connect and read, but not create a single table.
Fix: connect once as doadmin and make your app's user the owner of its database. In Postgres 15+, public belongs to whoever owns the database, so this grants what's needed and nothing outside it:
ALTER DATABASE flexbyt OWNER TO flexbyt;A failed migration here doesn't leave a half-built schema behind. It stops at its first statement, so just redeploy and it runs from the top.
Headache 7: don't point dev mode at production
This one we avoided, but it deserves a warning.
Payload's Postgres adapter uses push mode in development. It compares your code's schema with the database and changes the database to match. Great locally. Pointed at production, it can alter or drop tables to match whatever's on your laptop.
If you ever run a Payload command against production from your machine (a seed script, say), set NODE_ENV=production:
NODE_ENV=production DATABASE_URI="…" yarn payload run scripts/seed-jobs.tsIn production mode Payload runs your committed migrations instead of pushing, which is what you want.
Headache 8: the content is live, the page says it isn't
Symptom: our three job openings showed as Published in the admin. The careers page said "No open roles listed right now."
Cause: two caches, stacked.
- Next.js ISR. The page was pre-rendered during the build, back when the database was empty. Worse, our fetcher caught database errors and returned an empty list, so "no roles" was a perfectly valid page to cache for the next hour.
- Cloudflare. A "Cache Everything" rule was caching HTML too, for up to an hour (
cf-cache-status: HIT). Even after Next.js regenerated the page, Cloudflare kept serving its copy.
Fixes:
- Don't cache empty on failure. At runtime, let the fetch throw. When a background refresh fails, Next.js keeps serving the last good page. Only fall back to an empty list during the build, so a deploy can't fail because the build machine can't reach the database:
function emptyOnlyDuringBuild(label: string, error: unknown): [] {
console.error(label, error);
if (process.env.NEXT_PHASE === "phase-production-build") return [];
throw error;
}- Keep Cloudflare off your HTML. Next.js already caches pages and refreshes them the moment content is saved. A second copy at the edge just undoes that. One header, read only by Cloudflare:
async headers() {
return [{
source: "/((?!_next/|.*\\.[a-zA-Z0-9]+$).*)",
headers: [{ key: "Cloudflare-CDN-Cache-Control", value: "no-store" }],
}];
}Build assets and static files stay cached at the edge. Check that your Cloudflare cache rule's Edge TTL is set to "Use cache-control header if present", or it will ignore this.
- Purge once after deploying, to clear what's already cached.
Test the connection before you deploy it
Most of our deploy cycles were spent finding out, one redeploy at a time, what a 20-line script could have told us in a second. We now keep a read-only check that connects the same way the app does, with the same driver and the same certificate handling, and reports everything that matters:
→ PROD_DATABASE_URI: db-…ondigitalocean.com:25060 /flexbyt as flexbyt
certificate check: ON (CA provided)
✓ connected — database "flexbyt", user "flexbyt", Postgres 18.6
✓ can create tables
tables in public schema: 0 (empty — first deploy will create them)If that prints all ticks, the deploy will connect. If it doesn't, it tells you which of the headaches above you've got.
The checklist
Before you point an App Platform app at Managed Postgres:
- Trusted sources include the app, and your IP if you'll connect from your machine.
- Right database: one per app, and the string names that database.
- Right port: 25060 with a database name, or 25061 with a pool name.
- CA certificate set as a run-time env var, passed as
ssl.ca, withsslmoderemoved from the URL. - Ownership:
ALTER DATABASE <db> OWNER TO <app_user>;on Postgres 15+. - The fix is deployed: the running commit is the one with your changes.
NODE_ENV=productionfor any command run against production from your machine.- Caching: don't cache empty results, and keep your CDN off HTML that your framework already caches.
- Test with a script before you deploy, not with a deploy.
None of these is hard on its own. Together, with each error pointing slightly the wrong way, they cost us an afternoon. Hopefully this saves you yours.
Building on DigitalOcean, Next.js or Payload and stuck somewhere similar? Tell us about it. This is the kind of thing we fix for clients every week.
Building something?
We help teams design and ship web and mobile software.