Vũ Văn HảiFull-stack · AI-native
GuidesBlog
Discuss a project

© 2026 Vu Van Hai · Written from real deployment experience.

HomeGuidesBlogRSS
  1. Guides
  2. /Database
  3. /Migrate a PostgreSQL database between two VPS with pg_dump

Migrate a PostgreSQL database between two VPS with pg_dump

Move an entire PostgreSQL database (schema and data) from a source VPS to a target VPS through your local machine: dump with pg_dump, restore in a single transaction, verify row counts, then cut over and roll back safely.

Updated: Sep 21, 202610 min read
PostgreSQLVPSBackupSSH
On this page
  • What you need
  • Quick reference
  • Migration model
  • 1. Prepare the tools
  • 2. Declare the connections, passwords kept separate
  • 3. Test both connections
  • 4. Dump the source database (read-only)
  • 5. Strip SET transaction_timeout (only if pg_dump is newer than the target server)
  • 6. Restore into the target database
  • 7. Verify: exact row counts on both sides
  • 8. Cutover: point the backend at the new database
  • Automation script (one shot)
  • Troubleshooting
  • Rollback
  • Related guides

This guide moves an entire PostgreSQL database (schema and data) from a source VPS to a target VPS, relayed through your local machine (Windows + Git Bash) with pg_dump and psql. Use it when splitting the database onto a dedicated production VPS, switching providers, or merging/splitting infrastructure.

  • The source is only ever READ - nothing is written to it.
  • The restore runs in a single transaction -> any error rolls back cleanly, so the target database is never left half-migrated.
  • Idempotent: run it as many times as you like and you end up with a clean state that matches the dump exactly.

Best for small to medium databases on the same PostgreSQL major version (e.g. 16 -> 16). If pg_dump is newer than the target server (e.g. a 17 client restoring into a 16 server), also do step 5. Media on object storage (S3/B2) lives outside the database and is not affected.

What you need

Fill in your real values for these placeholders:

PlaceholderMeaning
<source-host> <source-port>Host + port of the source Postgres (default 5432)
<source-user> <source-db> <source-password>Source account + database name
<target-host> <target-port>Host + port of the target Postgres (over an SSH tunnel this is localhost:<tunnel-port>)
<target-user> <target-db> <target-password>Target account + database name (create the empty database beforehand)
<pg-version>PostgreSQL client version on Windows (e.g. 17)

Run every command in Git Bash, NOT PowerShell - export is bash syntax.

The target database is usually reachable only from inside its VPS, so open an SSH tunnel to it first (details in Connect to PostgreSQL on a VPS through an SSH tunnel). Each tunnel runs in its own terminal and stays open for the whole migration; replace <username>, <new-server-ip>, <old-server-ip> with the SSH user and IP of each VPS:

# Tunnel to the target VPS: local port 5433 -> Postgres (5432) on the target VPS
ssh -L 5433:localhost:5432 <username>@<new-server-ip> -i ~/.ssh/id_ed25519 -N

# If the source database is also only reachable from inside its VPS: open a second tunnel on another port
ssh -L 5434:localhost:5432 <username>@<old-server-ip> -i ~/.ssh/id_ed25519 -N

With the example above, <target-host>:<target-port> is localhost:5433 (and the source is localhost:5434 if you opened the second tunnel).

Quick reference

# 0. Add the PostgreSQL client to PATH (set <pg-version> correctly, e.g. 17)
export PATH="/c/Program Files/PostgreSQL/<pg-version>/bin:$PATH"

# 1. Declare the connections - keep passwords SEPARATE, never inside the URL (avoids broken URL parsing)
export SOURCE_DATABASE_URL='postgresql://<source-user>@<source-host>:<source-port>/<source-db>?sslmode=require'
export SOURCE_DB_PASSWORD='<source-password>'
export TARGET_DATABASE_URL='postgresql://<target-user>@<target-host>:<target-port>/<target-db>?sslmode=require'
export TARGET_DB_PASSWORD='<target-password>'

# 2. Dump (plain SQL) -> strip transaction_timeout (if pg_dump is newer than the target server) -> restore
STAMP=$(date +%Y%m%d-%H%M%S); F="dump_$STAMP.sql"
PGPASSWORD="$SOURCE_DB_PASSWORD" pg_dump "$SOURCE_DATABASE_URL" \
  --format=plain --no-owner --no-privileges --clean --if-exists --file="$F"
grep -v '^SET transaction_timeout' "$F" > "$F.tmp" && mv "$F.tmp" "$F"
PGPASSWORD="$TARGET_DB_PASSWORD" psql "$TARGET_DATABASE_URL" \
  --set=ON_ERROR_STOP=on --single-transaction --file="$F"

Migration model

+-------------+   pg_dump    +---------------+  psql restore  +-------------+
|  Source VPS | ---(read)--> | Local machine | -------------> |  Target VPS |
|  Postgres   |              |  (.sql file)  |                |  Postgres   |
+-------------+              +---------------+                +-------------+

The dump travels between the two VPS through your local machine: pg_dump pulls the data from the source into a .sql file, then psql pushes that file to the target.

1. Prepare the tools

You need pg_dump + psql. On Windows they usually already exist in C:\Program Files\PostgreSQL\<pg-version>\bin. Add them to PATH for the current Git Bash session:

export PATH="/c/Program Files/PostgreSQL/<pg-version>/bin:$PATH"
pg_dump --version && psql --version

Use a pg_dump version >= the target server. Same major version (16 -> 16) is the safest.

2. Declare the connections, passwords kept separate

If the password contains @ % $ / ? and you put it straight into the URL, parsing breaks (an @ in the password collides with the @ before the host; %xx is read as percent-encoding). The reliable approach: the URL holds no password, and the password goes into a *_DB_PASSWORD variable (libpq reads it raw, no parsing).

export SOURCE_DATABASE_URL='postgresql://<source-user>@<source-host>:<source-port>/<source-db>?sslmode=require'
export SOURCE_DB_PASSWORD='<source-password>'
export TARGET_DATABASE_URL='postgresql://<target-user>@<target-host>:<target-port>/<target-db>?sslmode=require'
export TARGET_DB_PASSWORD='<target-password>'

The URL is just user@host, with NO :password part. Use single quotes '...' so the shell does not interpret special characters. Drop ?sslmode=require if the database does not have SSL enabled.

3. Test both connections

echo "== SOURCE ==" && PGPASSWORD="$SOURCE_DB_PASSWORD" psql "$SOURCE_DATABASE_URL" -tAc "SELECT current_database();"
echo "== TARGET ==" && PGPASSWORD="$TARGET_DB_PASSWORD" psql "$TARGET_DATABASE_URL" -tAc "SELECT current_database();"

If both print their database name, the connections are fine. Connection refused on the target -> check the SSH tunnel.

4. Dump the source database (read-only)

STAMP=$(date +%Y%m%d-%H%M%S)
F="dump_$STAMP.sql"
PGPASSWORD="$SOURCE_DB_PASSWORD" pg_dump "$SOURCE_DATABASE_URL" \
  --format=plain \
  --no-owner \
  --no-privileges \
  --clean \
  --if-exists \
  --file="$F"
ls -lh "$F"
FlagMeaning
--format=plainOutput plain SQL (easy to read/audit; restored with psql)
--no-ownerSkip owner assignments (users usually differ between the two VPS)
--no-privilegesSkip GRANT/REVOKE (avoids missing-role errors)
--clean --if-existsPrepend DROP ... IF EXISTS -> safe to re-run (idempotent)

5. Strip SET transaction_timeout (only if pg_dump is newer than the target server)

pg_dump 17+ writes a SET transaction_timeout = 0; line at the top of the dump - servers older than 17 do not know this parameter and the restore fails. It only means "no timeout limit", so removing it is safe:

grep -v '^SET transaction_timeout' "$F" > "$F.tmp" && mv "$F.tmp" "$F"

Skip this step if pg_dump is the same version as the target server.

6. Restore into the target database

PGPASSWORD="$TARGET_DB_PASSWORD" psql "$TARGET_DATABASE_URL" \
  --set=ON_ERROR_STOP=on \
  --single-transaction \
  --file="$F"
  • --single-transaction: wraps everything in one transaction -> any error rolls it all back, so the database is never left half-restored.
  • --set=ON_ERROR_STOP=on: stop at the first error.

7. Verify: exact row counts on both sides

This block fetches the table list (with schema) on its own, then compares the real row count (count(*)) of every table on both sides:

TABLES=$(PGPASSWORD="$SOURCE_DB_PASSWORD" psql "$SOURCE_DATABASE_URL" -tAc \
  "SELECT schemaname||'.'||relname FROM pg_stat_user_tables ORDER BY 1;")

printf "%-40s %12s %12s\n" "table" "source" "target"
for t in $TABLES; do
  s=$(PGPASSWORD="$SOURCE_DB_PASSWORD" psql "$SOURCE_DATABASE_URL" -tAc "SELECT count(*) FROM $t;")
  d=$(PGPASSWORD="$TARGET_DB_PASSWORD" psql "$TARGET_DATABASE_URL" -tAc "SELECT count(*) FROM $t;")
  flag=""; [ "$s" != "$d" ] && flag="  <-- MISMATCH"
  printf "%-40s %12s %12s%s\n" "$t" "$s" "$d" "$flag"
done

It passes when every row has source = target and no line is flagged <-- MISMATCH.

Building the table list as schema.table also catches tables outside the public schema - for example projects using Drizzle ORM keep __drizzle_migrations in the drizzle schema.

8. Cutover: point the backend at the new database

Edit DATABASE_URL in the backend's env file:

DATABASE_URL=postgresql://<target-user>:<target-password-encoded>@<target-host>:<target-port>/<target-db>?sslmode=require

Here the backend parses the URL string, so the password MUST be percent-encoded if it has special characters - unlike step 2, which passes it raw through PGPASSWORD.

CharacterWrite in the URL
%%25 (encode this one first)
@%40
/%2F
?%3F
#%23
:%3A

The characters $ ! - _ can stay as they are. Example: the password ab%cd@ef is written in the URL as ab%25cd%40ef.

Restart the backend and check its health/ready endpoint to confirm it is connected to the new database.

Automation script (one shot)

Save it as migrate-db.sh next to where you want the dumps stored. It reads the 4 environment variables from step 2 (password-less URLs + *_DB_PASSWORD).

#!/usr/bin/env bash
# Migrate a PostgreSQL DB between two servers via pg_dump (plain SQL) -> psql.
# Source is READ-ONLY. Restore runs in a single transaction.
#
#   export SOURCE_DATABASE_URL='postgresql://<user>@<host>:<port>/<db>?sslmode=require'
#   export SOURCE_DB_PASSWORD='...'
#   export TARGET_DATABASE_URL='postgresql://<user>@<host>:<port>/<db>?sslmode=require'
#   export TARGET_DB_PASSWORD='...'
#   ./migrate-db.sh            # asks before touching the target
#   CONFIRM=1 ./migrate-db.sh  # non-interactive
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DUMP_DIR="${DUMP_DIR:-$SCRIPT_DIR/dumps}"
TS="$(date +%Y%m%d-%H%M%S)"
DUMP_FILE="$DUMP_DIR/dump_$TS.sql"

die() { echo "ERROR: $*" >&2; exit 1; }
redact() { printf '%s' "$1" | sed -E 's#(://[^:/@]+:)[^@]*@#\1***@#'; }

command -v pg_dump >/dev/null 2>&1 || die "pg_dump not found in PATH"
command -v psql    >/dev/null 2>&1 || die "psql not found in PATH"
: "${SOURCE_DATABASE_URL:?Set SOURCE_DATABASE_URL}"
: "${TARGET_DATABASE_URL:?Set TARGET_DATABASE_URL}"
mkdir -p "$DUMP_DIR"

echo "==> Source (read-only): $(redact "$SOURCE_DATABASE_URL")"
echo "==> Target (overwrite): $(redact "$TARGET_DATABASE_URL")"
echo "==> Dump file:          $DUMP_FILE"

if [ "${CONFIRM:-0}" != "1" ]; then
  echo "This DUMPS the source (read-only) and OVERWRITES matching tables on the TARGET."
  printf "Type 'yes' to continue: "; read -r ans
  [ "$ans" = "yes" ] || die "Aborted by user"
fi

echo "==> [1/3] Dumping source ..."
if [ -n "${SOURCE_DB_PASSWORD:-}" ]; then export PGPASSWORD="$SOURCE_DB_PASSWORD"; fi
pg_dump "$SOURCE_DATABASE_URL" --format=plain --no-owner --no-privileges --clean --if-exists --file="$DUMP_FILE"
unset PGPASSWORD
# pg_dump 17+ emits `SET transaction_timeout` that servers < 17 reject; safe to drop.
if grep -q '^SET transaction_timeout' "$DUMP_FILE"; then
  grep -v '^SET transaction_timeout' "$DUMP_FILE" > "$DUMP_FILE.tmp" && mv "$DUMP_FILE.tmp" "$DUMP_FILE"
fi

echo "==> [2/3] Restoring into target ..."
if [ -n "${TARGET_DB_PASSWORD:-}" ]; then export PGPASSWORD="$TARGET_DB_PASSWORD"; fi
psql "$TARGET_DATABASE_URL" --set=ON_ERROR_STOP=on --single-transaction --quiet --file="$DUMP_FILE"

echo "==> [3/3] Row counts on target:"
psql "$TARGET_DATABASE_URL" --quiet --command='DO $$ DECLARE r record; BEGIN FOR r IN SELECT schemaname, relname FROM pg_stat_user_tables LOOP EXECUTE format($q$ANALYZE %I.%I$q$, r.schemaname, r.relname); END LOOP; END $$;'
psql "$TARGET_DATABASE_URL" --quiet --command="SELECT schemaname||'.'||relname AS table, n_live_tup AS rows FROM pg_stat_user_tables ORDER BY 1;"
unset PGPASSWORD

echo "Done. Dump kept at: $DUMP_FILE"

The dump contains real data (possibly password hashes and emails). Never commit dump files to git - add dumps/ and *.sql to .gitignore.

If you keep the .sh script in a repo on Windows, add *.sh text eol=lf to .gitattributes so CRLF line endings do not break it when it runs on Linux.

Troubleshooting

SymptomCauseFix
export: command not foundYou are typing in PowerShellOpen Git Bash (the script is bash). Or in PowerShell use $env:VAR='...'
Connection fails even with the right passwordThe password contains @ % $ / and sits inside the URL -> parsing breaksKeep the URL password-less + use PGPASSWORD (step 2)
unrecognized configuration parameter "transaction_timeout"Dumped with pg_dump 17+, restoring into a server < 17Strip that line (step 5)
permission denied to create extension "..."The target user is not allowed to create extensionsHave a superuser create it on the target database first: CREATE EXTENSION IF NOT EXISTS <extension-name>; (e.g. citext, unaccent, uuid-ossp), then restore again
WARNING: permission denied to analyze "pg_..."A global ANALYZE; touches system catalogs and the user is not a superuserHarmless - the app's tables still get analyzed. Or analyze user tables only (the script already does)
relation "..." does not exist during verifyThe table lives in a schema other than public (e.g. drizzle)Use the full schema.table name (the verify block in step 7 already handles this)
SSL connection requiredThe database enforces SSLAppend ?sslmode=require to the connection string
Connection refused on the targetNo SSH tunnel to the target VPS yetOpen the tunnel first (see What you need)

Rollback

The target database only contains data from this migration, so rolling back is very safe: re-run the script (idempotent), or wipe the public schema on the target and restore again from step 6:

PGPASSWORD="$TARGET_DB_PASSWORD" psql "$TARGET_DATABASE_URL" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"

DROP SCHEMA ... CASCADE deletes everything. Only ever run it against TARGET_DATABASE_URL, and double-check that variable before pressing Enter.

The source is never touched, so if you have already cut over (step 8) and need to go back, just point the backend's DATABASE_URL at the source database and restart. Keep in mind that data written to the new database after the cutover will not exist on the source.

Related guides

  • Clone a PostgreSQL database from a VPS to local Windows - pull a copy onto your dev machine (custom format, changing the local port)
  • Connect to PostgreSQL on a VPS through an SSH tunnel - open a tunnel to the database on a VPS
  • Set up PostgreSQL on a VPS securely - bring up PostgreSQL on the target VPS before migrating
PreviousClone a PostgreSQL database from a VPS to local Windows

Related articles

  • Connect to PostgreSQL on a VPS through an SSH tunnel

    Use an SSH tunnel so your dev machine can reach PostgreSQL on a VPS without exposing port 5432 to the internet, plus the DATABASE_URL setup for local and production.

    Database

    Database
  • Tune VPS performance: swap, PostgreSQL and the DB pool

    Speed up a 16GB Ubuntu VPS that runs many Docker containers next to PostgreSQL: add swap to keep the OOM Killer away, tune PostgreSQL for more RAM and SSD storage, then raise the app's connection pool.

    Database

    Database
  • Fix Docker containers that cannot reach PostgreSQL on a VPS

    Docker picks a new network range every time you run docker-compose down and up, so PostgreSQL on the host rejects the container. Diagnose the subnet, open UFW and pg_hba.conf, then allow the whole 172.16.0.0/12 range to fix it once.

    Database

    Database

Written by Vu Van Hai

I'm Hai, a full-stack developer based in Ho Chi Minh City. These guides come from systems I built and run myself. Need to build or untangle something similar? Get in touch.

Discuss a projectMore guides

Spot a mistake or a command that no longer works? Let me know

On this page

  • What you need
  • Quick reference
  • Migration model
  • 1. Prepare the tools
  • 2. Declare the connections, passwords kept separate
  • 3. Test both connections
  • 4. Dump the source database (read-only)
  • 5. Strip SET transaction_timeout (only if pg_dump is newer than the target server)
  • 6. Restore into the target database
  • 7. Verify: exact row counts on both sides
  • 8. Cutover: point the backend at the new database
  • Automation script (one shot)
  • Troubleshooting
  • Rollback
  • Related guides