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
- The old way vs the new way
- What you need
- The architecture
- Step 1 — The Dockerfile
- Step 2 — The self-migrating compose stack
- Step 3 — The auto-migration script
- Step 4 — Create the service in Dokploy
- Step 5 — First deploy: watch it migrate itself
- Step 6 — Verify and lock in
- Rollback plan
- Cleanup
- Troubleshooting
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 serveruntil 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 Postgres | Click through Dokploy UI | Declared in docker-compose.yml |
| Dump source DB | SSH in, run docker run … pg_dump | One-shot container does it on first boot |
| Restore into target | SSH in, docker cp + docker exec pg_restore | Same one-shot container |
| Apply new schema migrations | Exec into app container | One-shot prisma migrate deploy container, every deploy |
| Redis | Not covered | In the same compose file |
| Rerun safety | "rerun the block if it errors" | Idempotent by design (marker table) |
| Commands typed on the VPS | ~10 | 0 |
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:
- db starts, reports healthy.
- 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. - migrate (one-shot) —
prisma migrate deployapplies any pending migrations. Right after the Neon restore it's a no-op, because_prisma_migrationscame across in the dump. - 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 innext.config.ts— that's what produces.next/standalone/server.js.- The placeholder
DATABASE_URLis enough to satisfy anynew PrismaClient()that runs duringnext build. Only the runtime value needs to be real. - The
HEALTHCHECKhits a tiny/api/healthroute that doesSELECT 1against Postgres and a RedisPING. Compose and Dokploy both use it to know the app is actually up, not just started. - Two
.gitattributeslines save Windows users a world of pain — shell scripts that reach a Linux container with CRLF line endings die with crypticnot founderrors:
*.sh text eol=lf
Dockerfile text eol=lfStep 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: trueThe details that matter:
DATABASE_URLis assembled inside the compose file fromPOSTGRES_*parts, pointing at the internal hostnamedb. You never set it in Dokploy — so there's no way to accidentally point production at the wrong database.- No
sslmode=requireon the internal URL. The Docker network doesn't present TLS certs; requiring SSL fails with a confusingpg_hba.conferror. (Neon's URL keeps itssslmode=require— that one crosses the internet.) depends_on+conditionis what turns compose into an orchestrator:service_healthygates on healthchecks,service_completed_successfullygates on one-shots exiting 0. If the migration fails, the app never starts — you cannot end up serving an empty database.dokploy-networkis Dokploy's external Traefik network. Onlyappjoins it;dbandredisstay unreachable from outside the stack.- Redis is capped at 128MB with LRU eviction — a cache, not a datastore. Point your app at
redis://redis:6379and treat it as optional in code (no-op whenREDIS_URLis 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:
| Decision | Why |
|---|---|
| Marker table, not a flag file | The 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 mark | pg_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 absent | After 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-privileges | Strips Neon's role ownership/grants that don't exist on the target — same as the old guide. |
| Dump file deleted at the end | A 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.
- Dokploy → your project → Create Service → Compose.
- Provider: your Git repo, branch
main, compose path./docker-compose.yml. - 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.
- Domains tab: add your domain → service app, port 3000, HTTPS enabled (Let's Encrypt).
- 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:3000On 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
- Hit the health endpoint:
https://myapp.example.com/api/health→ expect{"status":"ok","database":true,"redis":true}. - Log in and check real data is there.
- Do one write (create + delete a test record) to confirm the write path.
- Remove
NEON_DATABASE_URLfrom the Dokploy environment and redeploy. The migrator now skips instantly and Neon's credentials are gone from the VPS. - 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:
- Decommission the source — pause/delete the Neon project, revoke API keys that referenced it.
- Retire the old deployment (e.g. the Vercel project) or leave it disabled as a cold spare.
- Configure backups now. The database lives in the
pgdataDocker 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:rowith 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-privileges → pg_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.


