A multi-tenant edtech platform leaves a managed database and a PaaS that no longer served it. Why, how, what broke during the night, and what I kept for what came next.

In October 2025 I moved Civinat’s database to Neon and its deployment to Coolify. Two months later I went the other way for the database, and switched PaaS in a single night. That was not the plan. Here is what happened, based on the notes, the docs and the git log of the monorepo.
Where it started
Civinat is a multi-tenant learning platform: a NestJS API with Prisma, a Next.js front end, a PostgreSQL database and media on Cloudflare R2. On 26 October 2025, the commit “finalize the migration to neon” replaced the containerised Postgres with Neon, a serverless Postgres. The docs of the time give the reasons: no database container to run, a built-in connection pooler, and backups with point-in-time recovery included.

The same month, the app moved to Coolify on a VPS. The docker-compose.coolify*.yml files and several guides (COOLIFY-DEPLOYMENT-GUIDE.md, COOLIFY-PATH-EXPLAINED.md) date from then. The second one explains a monorepo trap in detail: with a “Base Directory” set, Coolify changes the build context, and the Dockerfile’s COPY civinat-front/… lines no longer find anything.
Why leave
The “Migration from Neon” section of the docs lists three reasons: cost, control over the database configuration, and consistency across environments. On cost, the docs estimate savings of 220 to 450 dollars a year. That is an estimate made before the migration, not a measurement.
Consistency mattered just as much. With Neon in production and a containerised Postgres locally, there were two worlds: a URL with -pooler and sslmode=require on one side, a Docker hostname on the other, and entrypoint error messages pointing at the Neon dashboard. The VPS was running anyway: in the cost table of infrastructure/README.md, the self-hosted database adds nothing.

Moving the database
I started by writing the plan, on 2 December (DATABASE_BACKUP_RESTORE.md), before touching production. It comes down to three ideas: a custom-format dump, a restore with options that ignore owners and privileges, and a simple way back.
# Neon tourne en PostgreSQL 17 : on prend un pg_dump de la même version
docker run --rm -v $(pwd):/backups postgres:17-alpine pg_dump \
"postgresql://USER:PASSWORD@ENDPOINT.neon.tech/DATABASE?sslmode=require" \
-Fc -f /backups/backup_$(date +%Y%m%d).dump
# Puis restauration dans le conteneur cible
docker cp backup.dump CONTAINER:/tmp/
docker exec CONTAINER pg_restore -U civinat -d civinat \
--clean --if-exists --no-owner --no-privileges /tmp/backup.dumpThe detail that matters is the postgres:17-alpine image. Neon runs PostgreSQL 17, and a pg_dump older than the server refuses to work. Rather than installing the right version on my machine, I run the client in a throwaway container of the same version. The --no-owner --no-privileges options avoid “permission denied” errors on restore: Neon’s owners and grants are not replayed, the new database’s are used.

The rollback is one variable. As long as Neon is not deleted, you put the old DATABASE_URL back and redeploy the backend. That way back let me work calmly: nothing I did was final.
What broke
The Postgres configuration
First attempt, first failure: I was mounting a full postgresql.conf into the container, and its settings did not work with the Alpine image. Commit 94ae31a5 drops the file and passes each setting as a -c argument in the container command. Less elegant, but everything is readable in one place.
Versions
On 4 December the staging database went from PostgreSQL 17 to 16. In the next commit the backup image moved to 17; in the one after, back to 16. The lesson: the backup container ships its own pg_dump, and its version has to follow the server. Going to 16 also created a gap with Neon, on 17: the docs have a whole section on “version mismatch on restore”.
An empty string
Just after midnight on 5 December, the backend refused to start. The Joi validation schema declared R2_ENDPOINT as an optional URI. But a variable declared in the compose file and left empty on the PaaS arrives as an empty string, not as a missing value. And an empty string is not a URI. Commit bfa37dbb adds .allow(''). One line, after removing the variable and putting it back.
Coolify’s proxy
The real blocker was the proxy. The symptoms, written down in backend/DEPLOYMENT.md: the container is healthy, the health check answers internally, but the public URL times out after 15 seconds, and the backend logs show no request at all. Coolify’s Caddy had set the service up with try_files directives, like a static site, instead of a plain reverse_proxy to an API.
The git log tells the story of that night. Expose the port, remove it, add caddy.reverse_proxy labels, drop them, try to override try_files with empty labels, then try an internal nginx:
labels:
- "coolify.managed=true"
- "coolify.type=application"
- "coolify.environment=production"
+ # Override try_files by setting it to empty
+ - "caddy.try_files="
+ - "caddy_0.try_files="
+ - "caddy_1.try_files="At 3:17 am I stopped fighting the proxy. The next commit is called “passing to dockploy”. Dokploy uses Traefik, configured through labels, and the try_files problem goes away with Caddy. The same day, the database, the backend and the front end were on Dokploy.
The stack on Dokploy
Each box is a Docker Compose service in Dokploy. Traefik receives the traffic behind Cloudflare. The front end answers on civinat.com and the *.civinat.com wildcard, one organisation per subdomain. The backend answers on api.civinat.com. Server-side, the front end calls the backend over the internal Docker network, without going back out to the Internet. Adminer is exposed behind basic auth.
Three choices come straight from what had broken before:
- Named Docker volumes rather than bind mounts, to stay compatible with Swarm, which Dokploy uses.
- An `attachable` overlay network per environment, created by the database and joined by the backend, on top of Traefik’s network.
- *`NEXT_PUBLIC_
variables as build arguments**: Next.js bakes them into the bundle at build time, so setting them only in the runtime environment is not enough
postgres-backup:
image: eeshugerman/postgres-backup-s3:16
environment:
POSTGRES_HOST: postgres-production
S3_ENDPOINT: https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
S3_BUCKET: ${R2_BUCKET}
S3_PREFIX: backups/postgresql-production
# Backup schedule: Daily at 3 AM Paris time
SCHEDULE: "0 3 * * *"
BACKUP_KEEP_DAYS: 30
POSTGRES_EXTRA_OPTS: "--clean --if-exists --no-owner --no-acl"
ENCRYPTION_PASSWORD: ${BACKUP_ENCRYPTION_PASSWORD}The backup is a container next to the database: one dump a day at 3 am, encrypted, sent to R2, kept for 30 days. That is the part Neon handled for free. Now it has to be maintained by hand.

The result
Civinat now runs on Dokploy, with its PostgreSQL database alongside. As I write this, the production health check answers:
I also want to note what I did not do cleanly. The docs did not keep up: they still mention Coolify environments and backups “every 3 days”, and the migration phase list still shows hourglasses although the switch happened. And when prisma migrate deploy fails, the backend entrypoint falls back to prisma db push --accept-data-loss. Handy in development, dangerous in production.
What about platform as code?
Civinat still runs from its compose files, managed through the Dokploy UI: it is not yet described in my hosting-as-code platform. But reading this migration again, several of that platform’s rules answer the problems I hit here:
- a database declared in the manifest is backed up daily to R2 by default, with no sidecar to write;
- a deploy refuses any plan that would destroy a stateful resource;
- the configuration lives in a versioned file, not in a UI or in docs that fall behind.
What I take away
- A managed service does more than store data. Neon handled backups, point-in-time recovery and pooling. Leaving it turned each of those into a compose line to write and check.
- Plan the way back before you leave. A one-variable rollback, and a plan that kept Neon for a week after the switch, made every step reversible.
- Know when to stop fighting the tool. A night of Caddy labels fixed nothing. Moving database, backend and front end to Dokploy fit in one day of commits.
#postgresql#neon#coolify#dokploy#traefik#migration#cloudflare-r2#self-hosting#docker