Skip to content
← BlogLessons learned· 5 min read

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.

InfraOps
“Migration from Neon” section: rationale, steps and rollback plan

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.

Civinat documentation page about Neon, marked DEPRECATED
The Neon page of the docs, kept but marked deprecated: it still shows why Neon was picked in October.

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.

Two months, two migrations

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.

“Self-Hosted PostgreSQL Setup” page of the Civinat documentation
The page that replaces it: one containerised PostgreSQL per environment, backed up to Cloudflare R2.

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.

infrastructure/DATABASE_BACKUP_RESTORE.md (excerpt)
# 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.dump

The 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.

“Migration from Neon” section: rationale, steps and rollback plan
The migration plan written before starting, with a one-variable rollback: `DATABASE_URL`.

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.

Civinat monorepo, night of 5 to 6 December 2025

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:

backend/docker-compose.production.yml (commit b22e3679)
         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

Production 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
infrastructure/database/docker-compose.production.dokploy.yml (excerpt)
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.

Automated and manual backups section of the documentation
The backup docs say “every 3 days” while the Dokploy compose backs up daily: the docs fell behind.

The result

Civinat now runs on Dokploy, with its PostgreSQL database alongside. As I write this, the production health check answers:

production

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:

  1. a database declared in the manifest is backed up daily to R2 by default, with no sidecar to write;
  2. a deploy refuses any plan that would destroy a stateful resource;
  3. 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