JB logo
CoffeeyOUTUBE
Blog
PreviousNext

Migrating PostgreSQL from Neon to self-hosted (Dokploy) — with zero terminal commands

A field-tested walkthrough for moving a live Postgres database from Neon (or any managed Postgres) into a self-hosted stack on Dokploy — app, Postgres, and Redis in one Docker Compose file, with the data migration running itself on first deploy. No SSH, no docker commands.

Migrating PostgreSQL from Neon to self-hosted (Dokploy) — with zero terminal commands

A field-tested walkthrough for moving a live Postgres database from Neon (or any managed Postgres) into a self-hosted stack running under Dokploy. The first version of this guide had you SSH into the VPS and run pg_dump / pg_restore by hand. This version removes every terminal command: the whole stack — app + Postgres + Redis — lives in one docker-compose.yml, and the data migration is a one-shot container that runs itself on the first deploy and never again. Everything happens through Git and the Dokploy UI.


Contents


Why do this

Common reasons teams move off a managed provider like Neon:

  • Cost: free tier gets tight fast once the compute stops auto-suspending.
  • Cold starts: Neon free compute auto-suspends. First request after inactivity fails with P1001 — Can't reach database server until the compute wakes (can take seconds to minutes).
  • Latency: co-locating the DB with the app on the same VPS cuts network round-trips to ~0ms.
  • Control: install any Postgres extensions you want, run your own backups, set your own retention.
  • Single-provider consolidation: if the rest of the stack is on Dokploy/your VPS, having the DB there too simplifies ops.

And a reason specific to this approach: you may not want to run ad-hoc commands on a production VPS at all. If the box hosts several projects, one mistyped docker command can take down a neighbour. Everything below is declarative — committed to Git, executed by Dokploy, reproducible.

The old way vs the new way

Old way (v1 of this guide)New way (this guide)
Provision PostgresClick through Dokploy UIDeclared in docker-compose.yml
Dump source DBSSH in, run docker run … pg_dumpOne-shot container does it on first boot
Restore into targetSSH in, docker cp + docker exec pg_restoreSame one-shot container
Apply new schema migrationsExec into app containerOne-shot prisma migrate deploy container, every deploy
RedisNot coveredIn the same compose file
Rerun safety"rerun the block if it errors"Idempotent by design (marker table)
Commands typed on the VPS~100

What you need

  • A VPS running Dokploy (no SSH needed for anything in this guide).
  • Your app in a Git repo Dokploy can reach (GitHub/GitLab).
  • The source database's connection string (your Neon DATABASE_URL).
  • Matching Postgres major versions between source and target. Check the source with SELECT version(); in Neon's SQL editor. Dump tools are forward-compatible (dumping a 15 with 17 tools works); restoring a newer dump into an older server does not.
  • ~15 minutes. Actual downtime is roughly the length of one deploy.

The examples use a Next.js + Prisma app called myapp, but only the Dockerfile is framework-specific — the compose file and migration script work for anything that talks to Postgres.

The architecture

One Dokploy Compose service, five containers, strict boot order:

                     ┌────────────────────────── VPS ──────────────────────────┐
 myapp.example.com ─►│ Traefik (Dokploy) ──► app (:3000)                       │
                     │                        │           │                    │
                     │                        ▼           ▼                    │
                     │                  db (Postgres 17)  redis (Redis 7)      │
                     │                        ▲                                │
                     │  neon-migrator ────────┘  one-shot, FIRST deploy only   │
                     │  migrate (prisma migrate deploy)  one-shot, EVERY deploy│
                     └─────────────────────────────────────────────────────────┘

Every deploy runs the same chain:

  1. db starts, reports healthy.
  2. neon-migrator (one-shot) — on the first deploy it pg_dumps Neon and restores into the local Postgres, then writes a marker table. On every later deploy it sees the marker (or the removed env var) and exits in under a second.
  3. migrate (one-shot) — prisma migrate deploy applies any pending migrations. Right after the Neon restore it's a no-op, because _prisma_migrations came across in the dump.
  4. app starts only after both one-shots exit successfully.

Two properties worth calling out:

  • Postgres and Redis publish no host ports. They live on the stack's private network. Nothing can clash with other projects on the VPS, and neither DB is reachable from the internet — ever, not even "temporarily during the restore" like the old guide needed.
  • The whole thing is idempotent. Redeploy ten times; data is copied exactly once.

Step 1 — The Dockerfile

Multi-stage, Next.js standalone output, non-root runtime, built-in healthcheck. The builder stage does double duty: compose reuses it as the image for the migrate one-shot, since it has the full node_modules with the Prisma CLI.

# syntax=docker/dockerfile:1
 
# ---------- Base ----------
FROM node:22-alpine AS base
RUN npm install -g pnpm@11
WORKDIR /app
 
# ---------- Dependencies ----------
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Prisma files must be present: the postinstall hook runs `prisma generate`
COPY prisma ./prisma
COPY prisma.config.ts ./
RUN pnpm install --frozen-lockfile
 
# ---------- Builder ----------
# Also used as the one-shot `migrate` service image in docker-compose.yml.
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Placeholders so `next build` can prerender without real secrets.
# Real values are injected at runtime by docker-compose / Dokploy.
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" \
    NEXT_TELEMETRY_DISABLED=1
RUN pnpm prisma generate && pnpm build
 
# ---------- Runner ----------
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
    PORT=3000 \
    HOSTNAME=0.0.0.0
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
  CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]

Notes:

  • output: "standalone" must be set in next.config.ts — that's what produces .next/standalone/server.js.
  • The placeholder DATABASE_URL is enough to satisfy any new PrismaClient() that runs during next build. Only the runtime value needs to be real.
  • The HEALTHCHECK hits a tiny /api/health route that does SELECT 1 against Postgres and a Redis PING. Compose and Dokploy both use it to know the app is actually up, not just started.
  • Two .gitattributes lines save Windows users a world of pain — shell scripts that reach a Linux container with CRLF line endings die with cryptic not found errors:
*.sh text eol=lf
Dockerfile text eol=lf

Step 2 — The self-migrating compose stack

The heart of the setup. Commit this as docker-compose.yml in the repo root:

services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 20
    networks:
      - internal
 
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command:
      [
        "redis-server",
        "--appendonly",
        "yes",
        "--maxmemory",
        "128mb",
        "--maxmemory-policy",
        "allkeys-lru",
      ]
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 20
    networks:
      - internal
 
  # One-shot: pulls the full database from Neon into the local Postgres.
  # Runs only while the target is still empty (guarded by a marker table),
  # and skips entirely once NEON_DATABASE_URL is removed from the env.
  neon-migrator:
    image: postgres:17-alpine
    restart: "no"
    entrypoint: ["/bin/sh", "/migrate-from-neon.sh"]
    volumes:
      - ./scripts/migrate-from-neon.sh:/migrate-from-neon.sh:ro
    environment:
      NEON_DATABASE_URL: ${NEON_DATABASE_URL:-}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      TARGET_HOST: db
    depends_on:
      db:
        condition: service_healthy
    networks:
      - internal
 
  # One-shot: applies any pending Prisma migrations (no-op right after the
  # Neon restore, since _prisma_migrations comes across in the dump).
  migrate:
    build:
      context: .
      target: builder
    restart: "no"
    command: ["pnpm", "prisma", "migrate", "deploy"]
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      db:
        condition: service_healthy
      neon-migrator:
        condition: service_completed_successfully
    networks:
      - internal
 
  app:
    build:
      context: .
      target: runner
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://redis:6379
      # ... the rest of your app's env vars, passed through from Dokploy:
      # BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
      # GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
      # etc.
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
    networks:
      - internal
      - dokploy-network
 
volumes:
  pgdata:
  redisdata:
 
networks:
  internal:
  dokploy-network:
    external: true

The details that matter:

  • DATABASE_URL is assembled inside the compose file from POSTGRES_* parts, pointing at the internal hostname db. You never set it in Dokploy — so there's no way to accidentally point production at the wrong database.
  • No sslmode=require on the internal URL. The Docker network doesn't present TLS certs; requiring SSL fails with a confusing pg_hba.conf error. (Neon's URL keeps its sslmode=require — that one crosses the internet.)
  • depends_on + condition is what turns compose into an orchestrator: service_healthy gates on healthchecks, service_completed_successfully gates on one-shots exiting 0. If the migration fails, the app never starts — you cannot end up serving an empty database.
  • dokploy-network is Dokploy's external Traefik network. Only app joins it; db and redis stay unreachable from outside the stack.
  • Redis is capped at 128MB with LRU eviction — a cache, not a datastore. Point your app at redis://redis:6379 and treat it as optional in code (no-op when REDIS_URL is unset) so local dev without Docker still works.

Step 3 — The auto-migration script

Commit as scripts/migrate-from-neon.sh. This replaces every terminal command from the old guide — same pg_dump/pg_restore flags, wrapped in wait/skip/verify logic:

#!/bin/sh
# One-shot Neon -> Docker Postgres migration, run automatically by the
# `neon-migrator` service in docker-compose.yml. Never needs to be run by hand.
set -eu
 
TARGET_HOST="${TARGET_HOST:-db}"
TARGET_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${TARGET_HOST}:5432/${POSTGRES_DB}"
 
echo "[neon-migrator] Waiting for target Postgres at ${TARGET_HOST}..."
i=0
until pg_isready -h "$TARGET_HOST" -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; do
  i=$((i + 1))
  if [ "$i" -gt 60 ]; then
    echo "[neon-migrator] ERROR: target Postgres not ready after 120s" >&2
    exit 1
  fi
  sleep 2
done
 
if [ -z "${NEON_DATABASE_URL:-}" ]; then
  echo "[neon-migrator] NEON_DATABASE_URL not set — nothing to migrate. Done."
  exit 0
fi
 
MARKER=$(psql "$TARGET_URL" -tAc \
  "SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='neon_migration_done'" || echo "")
if [ "$MARKER" = "1" ]; then
  echo "[neon-migrator] Migration already completed earlier — skipping. Done."
  exit 0
fi
 
echo "[neon-migrator] Dumping source database from Neon..."
pg_dump "$NEON_DATABASE_URL" \
  --no-owner \
  --no-privileges \
  --format=custom \
  --file=/tmp/neon.dump
 
echo "[neon-migrator] Restoring into local Postgres..."
# pg_restore can exit non-zero on ignorable warnings (e.g. --clean on an
# empty database), so success is judged by the verification query below.
set +e
pg_restore \
  --no-owner \
  --no-privileges \
  --clean --if-exists \
  --dbname="$TARGET_URL" \
  /tmp/neon.dump
RESTORE_RC=$?
set -e
[ "$RESTORE_RC" -ne 0 ] && echo "[neon-migrator] pg_restore exited with ${RESTORE_RC} — verifying anyway..."
 
USER_COUNT=$(psql "$TARGET_URL" -tAc 'SELECT count(*) FROM "user"' 2>/dev/null || echo "FAIL")
MIGRATION_COUNT=$(psql "$TARGET_URL" -tAc 'SELECT count(*) FROM "_prisma_migrations"' 2>/dev/null || echo "FAIL")
if [ "$USER_COUNT" = "FAIL" ] || [ "$MIGRATION_COUNT" = "FAIL" ]; then
  echo "[neon-migrator] ERROR: verification failed — restored schema is incomplete." >&2
  echo "[neon-migrator] Fix the issue and redeploy; this will retry automatically." >&2
  exit 1
fi
 
psql "$TARGET_URL" -q -c \
  "CREATE TABLE IF NOT EXISTS neon_migration_done (completed_at timestamptz NOT NULL DEFAULT now());
   INSERT INTO neon_migration_done DEFAULT VALUES;"
 
rm -f /tmp/neon.dump
echo "[neon-migrator] SUCCESS: migrated ${USER_COUNT} users, ${MIGRATION_COUNT} prisma migrations recorded."
echo "[neon-migrator] You can now remove NEON_DATABASE_URL from the environment."

Design decisions, explained:

DecisionWhy
Marker table, not a flag fileThe marker lives in the same volume as the data. Wipe the volume, migration re-runs; keep the volume, it never re-runs. They can't get out of sync.
Verify, then markpg_restore exit codes are noisy (--clean on an empty DB "fails"). Instead we verify a known table and _prisma_migrations exist. Only then is the marker written — so a genuinely failed restore retries on the next deploy instead of being silently marked done.
Skip when env var is absentAfter the migration you delete NEON_DATABASE_URL from Dokploy. The Neon credentials leave the VPS, and the migrator becomes a sub-second no-op forever.
--no-owner --no-privilegesStrips Neon's role ownership/grants that don't exist on the target — same as the old guide.
Dump file deleted at the endA dump is a full, unencrypted copy of your DB. It lives for seconds inside a throwaway container, not in /tmp on the VPS.

Step 4 — Create the service in Dokploy

All UI from here.

  1. Dokploy → your project → Create Service → Compose.
  2. Provider: your Git repo, branch main, compose path ./docker-compose.yml.
  3. Environment tab — paste your variables:
# Docker Postgres — NEW credentials, password ALPHANUMERIC ONLY
POSTGRES_USER=myapp
POSTGRES_PASSWORD=pickSomethingLongAlphanumeric42
POSTGRES_DB=myapp
 
# One-time migration trigger: your current Neon URL.
# REMOVE this after the first successful deploy.
NEON_DATABASE_URL=postgresql://user:pass@ep-xxx.aws.neon.tech/neondb?sslmode=require
 
# ...plus your app's own vars (auth secrets, OAuth keys, API keys, etc.)

The alphanumeric-password rule survives from the old guide for the same reason: @ : / ? # % are URL delimiters, and this password gets embedded in connection URLs by the compose file. Encoding them everywhere (@%40…) is a tax you can simply not pay.

  1. Domains tab: add your domain → service app, port 3000, HTTPS enabled (Let's Encrypt).
  2. Don't forget DNS (A record → VPS IP) and, for OAuth apps, adding the new callback URL (e.g. https://myapp.example.com/api/auth/callback/google) in the provider's console.

Step 5 — First deploy: watch it migrate itself

Press Deploy and open the logs. A healthy first run reads like this:

db-1             | database system is ready to accept connections
neon-migrator-1  | [neon-migrator] Waiting for target Postgres at db...
neon-migrator-1  | [neon-migrator] Dumping source database from Neon...
neon-migrator-1  | [neon-migrator] Restoring into local Postgres...
neon-migrator-1  | [neon-migrator] SUCCESS: migrated 12 users, 7 prisma migrations recorded.
neon-migrator-1  | [neon-migrator] You can now remove NEON_DATABASE_URL from the environment.
migrate-1        | No pending migrations to apply.
app-1            | ▲ Next.js ready on http://0.0.0.0:3000

On every deploy after that, the interesting lines shrink to:

neon-migrator-1  | [neon-migrator] Migration already completed earlier — skipping. Done.
migrate-1        | No pending migrations to apply.

And when you eventually ship a new Prisma migration, migrate-1 applies it here — you never exec into a container to run migrations again.

Step 6 — Verify and lock in

  1. Hit the health endpoint: https://myapp.example.com/api/health → expect {"status":"ok","database":true,"redis":true}.
  2. Log in and check real data is there.
  3. Do one write (create + delete a test record) to confirm the write path.
  4. Remove NEON_DATABASE_URL from the Dokploy environment and redeploy. The migrator now skips instantly and Neon's credentials are gone from the VPS.
  5. Rotate any secrets that previously lived in the old hosting provider's dashboard — cutover is the cheapest moment to do it, since users are re-authenticating against the new domain anyway.

Rollback plan

Keep the source alive for ~48 hours. Because the app moved hosts too, rollback is simply: re-enable the old deployment (still pointed at Neon) and flip DNS back.

The caveat from the old guide still applies, with sharper teeth: any writes made on the new stack after cutover do not exist in Neon. If there's a realistic chance of rolling back, pause data entry during the switch window.

Cleanup

Once the app has been happy for a few days:

  1. Decommission the source — pause/delete the Neon project, revoke API keys that referenced it.
  2. Retire the old deployment (e.g. the Vercel project) or leave it disabled as a cold spare.
  3. Configure backups now. The database lives in the pgdata Docker volume; set up Dokploy's scheduled backups to S3/R2. You just proved how painful it is to depend on someone else's copy of your data — don't recreate that situation with zero copies.

Troubleshooting

Everything here is diagnosable from Dokploy → service → Logs. No SSH.

neon-migrator fails with a connection error to Neon

Neon's compute is suspended (free tier auto-sleeps). Open the Neon console, resume the compute, and hit Deploy again — the migrator retries automatically because the marker was never written.

pg_restore: error: unsupported version (X.Y) in file header

Source Postgres is newer than the target image. Bump the postgres: image versions in docker-compose.yml (both db and neon-migrator — keep them identical), commit, redeploy. The version pair matters nowhere else.

password authentication failed for user "..."

Almost always a non-alphanumeric POSTGRES_PASSWORD mangled by URL parsing. Change it to alphanumerics in the Dokploy env. If the db volume was already created with the old password, note that POSTGRES_PASSWORD only takes effect on a fresh volume — easiest fix this early is deleting the stack's volumes in Dokploy and redeploying (the migrator will re-run and re-copy from Neon, which is exactly the idempotency working for you).

ERROR: permission denied to create extension

Your schema uses an extension (pgcrypto, citext, uuid-ossp…). Bake it into the stack declaratively instead of exec-ing into the container: add an init script mount to the db service —

volumes:
  - pgdata:/var/lib/postgresql/data
  - ./scripts/init-extensions.sql:/docker-entrypoint-initdb.d/01-extensions.sql:ro

with scripts/init-extensions.sql containing CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; etc. Init scripts run only on a fresh volume — which is precisely when you need them, before the restore.

The app never starts, migrate keeps failing

Read migrate-1's logs — it's Prisma telling you exactly which migration failed. The failure gates the app on purpose: fix the migration, push, redeploy. The old version of your app keeps running until the new chain succeeds.

sslmode errors on the internal connection

You added sslmode=require to the internal DATABASE_URL. Don't — the Docker network has no TLS certs. SSL belongs only on URLs that cross the internet (like the Neon one).

App container is unhealthy but logs look fine

The HEALTHCHECK endpoint is failing. Check /api/health returns 200 without auth, and that its DB check uses the same DATABASE_URL the app got. A health route that requires a session cookie will mark every container unhealthy.

Windows: shell script fails with not found or \r errors

The migration script reached the container with CRLF line endings. Add the .gitattributes from Step 1 (*.sh text eol=lf), then re-checkout the file (git rm --cached + restore, or re-clone) and push.


Appendix — doing this for any two Postgres databases

Nothing above is Neon-specific: NEON_DATABASE_URL is just "a Postgres URL I can read from". Point it at RDS, Supabase, Railway, another Dokploy box — the one-shot pattern is identical. The core workflow is still pg_dump --format=custom --no-owner --no-privilegespg_restore --no-owner --no-privileges --clean --if-exists, as long as you:

  • Match major versions (or dump with the newer tools).
  • Handle required extensions on the target side (init scripts, see Troubleshooting).
  • Let a verification query — not pg_restore's exit code — decide success.

And the pattern generalizes past migrations: any "run exactly once against the database, before the app boots" job (backfills, big data fixes) fits the same one-shot-container-with-a-marker-table shape.