Свой сервер для базы данных
Как мы перевезли базу с Supabase на свой сервер: приложение подождало 1,52 секунды, сам переезд занял 7. Отдайте этот гайд своему боту, он пройдёт шаги с вами. Команды гайда целиком на чистом сервере не запускались, поэтому сначала прогоните их на копии.
Гайд про переезд с PostgreSQL. Если у вас другая база, бот предложит похожее решение для неё.
Supabase Pro
- 4 ГБ памяти
- возврат базы на любую минуту за 14 дней
- 250 ГБ трафика
Свой сервер, Hetzner CAX11
- 4 ГБ памяти
- возврат базы на любую минуту за 14 дней
- 20 ТБ трафика
Цены с сайтов на 3 ноября 2026 за одинаковый набор. Сами мы ушли с бесплатного тарифа, когда база упёрлась в его лимит. Разницу вы платите своим временем: сервер теперь обслуживаете сами.
Как отдать гайд боту
Скопируйте этот промпт и вставьте в чат с ботом. Бот сначала уточнит у вас, что за база, где она стоит и сколько стоит, сравнит с Hetzner и предложит варианты. Переезд начнётся только после вашего решения.
Помоги решить, стоит ли переносить нашу базу данных на свой сервер, и если стоит, проведи переезд. Сначала уточни у меня то, чего не знаешь:- какая у нас база и версия, какой у неё размер и где она стоит; - как ты получишь к ней доступ. Пароли и ключи пусть лежат в переменных окружения или в файле на сервере, в чат их не печатай; - сколько мы платим за неё сейчас; - какие у нас условия безотказности и бэкапов. Затем: 1. Узнай, сколько сейчас стоит сервер Hetzner нужного размера, и сравни с нашей текущей ценой и условиями. 2. Перечисли варианты переезда и что каждый стоит по простою и по работе. 3. Если у нас PostgreSQL, предложи вариант с логической репликацией и PgBouncer. Шаги и команды: https://prtner.ai/guides/svoy-server/ Если не можешь открыть ссылку, попроси меня вставить шаги. 4. Если база другая, этот гайд к ней напрямую не подходит: предложи похожее решение для неё. 5. Дай рекомендацию и дождись моего решения. Если решим переезжать: иди по одному шагу, показывай команду перед запуском, проверяй пункт «Готово, когда» и только потом иди дальше. Перед шагом 6 остановись и спроси меня. Перед началом убедись, что есть свежий бэкап старой базы.
Возьмите сервер
Мы взяли Hetzner CAX11: 2 ядра ARM, 4 ГБ памяти, 40 ГБ диска, 20 ТБ трафика. На 3 ноября новые CAX11 на сайте отмечены как недоступные, тогда берите ближайший по размеру. Смотрите, чтобы диск был с запасом к размеру базы, а сервер стоял близко к приложению.
Вход только по SSH-ключу, пароли выключены.
Обновите систему и включите автоматические обновления безопасности.
Поставьте PostgreSQL и закройте его
Версия та же, что у старой базы, или новее. Порт 5432 открыт только адресу приложения, соединения только по TLS.
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt install -y postgresql-17 pgbackrest
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw allow from APP_IP to any port 5432 proto tcp
sudo ufw --force enable# postgresql.conf
listen_addresses = '*'
ssl = on
password_encryption = scram-sha-256
wal_level = logical
timezone = 'UTC'
jit = off
max_slot_wal_keep_size = 8GB
# pg_hba.conf: допишите строку, файл не заменяйте
hostssl app app_user APP_IP/32 scram-sha-256
sudo systemctl restart postgresqlЕщё раз сверьте настройки, которые раньше давала платформа: у нас на роли приложения стоит statement_timeout на 2 минуты.
Запустите репликацию
Гайд переносит только таблицы схемы public. Supabase Auth, Storage и Realtime у нас не использовались, и здесь они не переезжают. Если приложение на них держится или таблицы ссылаются на auth.users, остановитесь: сначала замените их. С этого момента и до снятия подписок действует правило миграций из блока «После», а новые таблицы до переезда не создавайте.
Логическая репликация копирует каждую запись на новый сервер, пока приложение работает со старой базой. На старой базе сначала найдите таблицы без первичного ключа: после публикации UPDATE и DELETE в них начнут падать. Каждой такой таблице дайте первичный ключ или REPLICA IDENTITY FULL.
-- на старой: эти таблицы надо исправить до публикации
SELECT c.oid::regclass FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r','p') AND c.relpersistence = 'p'
AND c.relreplident IN ('d','n')
AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary);Заведите приложению отдельную роль на старой базе и переведите приложение на неё. На Supabase приложение часто ходит под postgres: при переключении вход роли приложения закрывается, а NOLOGIN никогда не применяйте к postgres, иначе вы потеряете вход и откат.
-- на старой
CREATE ROLE app_user LOGIN BYPASSRLS PASSWORD '...';
GRANT USAGE ON SCHEMA public TO app_user;
GRANT ALL ON ALL TABLES IN SCHEMA public TO app_user;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO app_user;Затем перенесите роли, расширения и схему на новый сервер.
# на новом сервере
psql "$OLD_URL" -Atc "SELECT format('CREATE ROLE %I NOLOGIN;', rolname) FROM pg_roles WHERE rolname !~ '^pg_' AND rolname <> 'postgres'" | sudo -u postgres psql
sudo -u postgres createdb app
# создайте в app те же расширения в тех же схемах
psql "$OLD_URL" -c "SELECT e.extname, n.nspname FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace"
pg_dump "$OLD_URL" --schema-only --schema=public --no-publications --no-subscriptions \
| sed 's/^CREATE SCHEMA public;$//' | sudo -u postgres psql -d app -v ON_ERROR_STOP=1Дайте app_user вход, пароль и те же атрибуты, что были на старой базе. postgres на Supabase обходит RLS: без BYPASSRLS приложение увидит пустые таблицы без всякой ошибки.
-- на старой
CREATE ROLE repl_user LOGIN REPLICATION BYPASSRLS PASSWORD '...';
GRANT USAGE ON SCHEMA public TO repl_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO repl_user;
DO $$ DECLARE l text; BEGIN
SELECT string_agg(format('%I.%I', n.nspname, c.relname), ', ') INTO l
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','p') AND c.relpersistence = 'p'
AND NOT c.relispartition AND n.nspname = 'public';
EXECUTE 'CREATE PUBLICATION app_move FOR TABLE ' || l;
END $$;
-- на новой, через sudo -u postgres psql -d app
CREATE SUBSCRIPTION app_move_sub
CONNECTION 'host=OLD_HOST dbname=postgres user=repl_user password=... sslmode=require'
PUBLICATION app_move WITH (origin = none);OLD_HOST на Supabase это прямой адрес db.<ref>.supabase.co, не пулер. Ему нужен IPv6 на новом сервере или платный IPv4.
-- на новой: 0 значит, все таблицы скопированы
SELECT count(*) FROM pg_subscription_rel WHERE srsubstate <> 'r';
-- на старой: отставание в байтах
SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)
FROM pg_replication_slots;Поставьте перемычку: PgBouncer
PgBouncer это пулер соединений. Он ставится на сервер приложения, между приложением и базой. Записи он не копирует, это делает репликация. Его работа при переезде: придержать запросы, сменить адрес базы и отпустить их.
sudo apt install -y pgbouncer
# /etc/pgbouncer/userlist.txt, права 640
"app_user" "пароль"
"pgb_admin" "другой пароль"
# /etc/pgbouncer/pgbouncer.ini
[databases]
app = host=OLD_HOST port=5432 dbname=postgres
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
pool_mode = transaction
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = pgb_admin
server_tls_sslmode = require
ignore_startup_parameters = extra_float_digits,options
max_prepared_statements = 200Переведите приложение на 127.0.0.1:6432. В режиме transaction не работают LISTEN и блокировки на сессию: такой код оставьте на прямом соединении.
# репетиция под нагрузкой
psql -h 127.0.0.1 -p 6432 -U pgb_admin pgbouncer -c "PAUSE app;"
psql -h 127.0.0.1 -p 6432 -U pgb_admin pgbouncer -c "RESUME app;"
# проверьте вход на новую базу: временная строка в [databases], затем RELOAD; потом удалите её
app_new = host=NEW_HOST port=5432 dbname=app
psql -h 127.0.0.1 -p 6432 -U app_user app_new -c "SELECT 1"Настройте бэкапы и восстановите из них
Возврат на любую минуту делает pgBackRest. Мы храним копии 14 дней в Cloudflare R2. Настройте это до переезда, тогда новая база с первой минуты под защитой.
# postgresql.conf
archive_mode = on
archive_command = 'pgbackrest --stanza=app archive-push %p'
archive_timeout = 60
# /etc/pgbackrest/pgbackrest.conf
[global]
repo1-type=s3
repo1-s3-endpoint=ACCOUNT.r2.cloudflarestorage.com
repo1-s3-bucket=pg-backups
repo1-s3-region=auto
repo1-s3-uri-style=path
repo1-path=/app
repo1-retention-full-type=time
repo1-retention-full=14
repo1-s3-key=...
repo1-s3-key-secret=...
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=...
[app]
pg1-path=/var/lib/postgresql/17/mainsudo chown postgres:postgres /etc/pgbackrest/pgbackrest.conf
sudo chmod 600 /etc/pgbackrest/pgbackrest.conf
sudo -u postgres pgbackrest --stanza=app stanza-create
sudo systemctl restart postgresql
sudo -u postgres pgbackrest --stanza=app check
sudo -u postgres pgbackrest --stanza=app --type=full backup
# /etc/cron.d/pgbackrest
15 2 * * * postgres pgbackrest --stanza=app --type=full backupПока архивация падает, WAL копится на диске, поэтому check обязателен. Ключи не попадают в чат и в репозиторий. Пароль шифрования храните и вне сервера: без него копии не прочитать.
R=/var/lib/postgresql/restore-test
sudo -u postgres install -d -m 700 $R
sudo -u postgres pgbackrest --stanza=app --pg1-path=$R --type=time \
--target="YYYY-MM-DD HH:MM:SS+00" --target-action=promote --archive-mode=off restore
sudo -u postgres tee $R/postgresql.conf <<EOF
port = 5433
listen_addresses = ''
unix_socket_directories = '/tmp'
archive_mode = off
hot_standby = off
wal_level = logical
max_wal_senders = 0
max_replication_slots = 8
max_logical_replication_workers = 0
EOF
echo "local all all trust" | sudo -u postgres tee $R/pg_hba.conf
sudo -u postgres /usr/lib/postgresql/17/bin/pg_ctl -D $R -w -t 900 -l /tmp/restore-test.log startКопия поднимается только на той же архитектуре и той же основной версии PostgreSQL. postgresql.conf, pg_hba.conf и TLS-ключ в неё не входят, храните их отдельно. Команда может вернуться раньше конца восстановления: ждите, пока ответит sudo -u postgres psql -h /tmp -p 5433 -d app. После сверки сразу остановите копию и удалите её: она пускает без пароля и занимает диск.
sudo -u postgres /usr/lib/postgresql/17/bin/pg_ctl -D $R -m fast stop && sudo rm -rf $RПереключитесь
Боту стоит остановиться здесь и спросить вас. Пишет всегда только одна база. Сделайте порядок одним скриптом и запускайте его через systemd-run или tmux: обрыв SSH не должен оставить запросы на паузе.
Заранее, до паузы, поставьте зеркало для отката. Прямую подписку не отключайте: петлю исключает origin = none. Старой базе нужен доступ к новой. Если у базы Supabase нет платного IPv4, она ходит только по IPv6.
# на новой: порт и доступ для старой базы
sudo ufw allow from OLD_IP to any port 5432 proto tcp
# допишите в pg_hba.conf:
hostssl app back_user OLD_IP/128 scram-sha-256
sudo systemctl reload postgresql
-- на новой, через sudo -u postgres psql -d app
CREATE ROLE back_user LOGIN REPLICATION BYPASSRLS PASSWORD '...';
GRANT USAGE ON SCHEMA public TO back_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO back_user;
CREATE PUBLICATION app_back FOR TABLE ...; -- тот же список, что в app_move
-- на старой
CREATE SUBSCRIPTION app_back_sub
CONNECTION 'host=NEW_HOST dbname=app user=back_user password=... sslmode=require'
PUBLICATION app_back WITH (copy_data = false, origin = none);Теперь само переключение:
Остановите фоновые процессы, которые пишут в базу мимо перемычки.
PAUSE app; запросы ждут.
Закройте старую базу для приложения, чтобы забытый скрипт не писал мимо.
Дождитесь, пока новая база подтвердит последнюю запись старой.
Скопируйте счётчики последовательностей и строки UNLOGGED-таблиц.
В pgbouncer.ini поставьте app = host=NEW_HOST port=5432 dbname=app, затем RELOAD; и RESUME app; Пароль app_user на обеих базах один.
Убедитесь, что запрос попал на новую базу, и запустите фоновые процессы уже на ней.
-- на старой: закрыть вход роли приложения
ALTER ROLE app_user NOLOGIN;
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'app_user';
-- на старой: позиция, которую надо дождаться
SELECT pg_current_wal_lsn();
-- на старой: сюда должна дойти новая база
SELECT confirmed_flush_lsn FROM pg_replication_slots WHERE slot_name = 'app_move_sub';
-- на старой: команды для последовательностей, выполнить на новой
SELECT format('SELECT setval(%L, %s, true);', format('%I.%I', schemaname, sequencename), last_value)
FROM pg_sequences WHERE schemaname = 'public' AND last_value IS NOT NULL;ALTER ROLE app_user LOGIN;, затем RESUME app; Приложение снова работает со старой базой.
Что теперь на вас
Обновления системы и PostgreSQL, безопасность, мониторинг.
Занятый диск и журнал ошибок. Слот репликации, который никто не читает, копит WAL, пока диск не кончится: поставьте оповещение.
После перезапуска PostgreSQL проверьте зеркало. У нас подписка на старой базе осталась выключенной из-за флага disable_on_error, и включить её пришлось вручную. Не включайте этот флаг.
Пока живёт любая подписка, изменения схемы не реплицируются. Миграцию схемы применяйте на обе базы, сначала на принимающей. Миграцию данных только на пишущей. Новую таблицу добавляйте в публикации и подписки.
Когда новая база себя показала, снимите зеркало и старую подписку. Иначе старая база продолжит расти.
Гайд основан на нашем переезде 3 ноября 2026 с PostgreSQL 17. Подставьте свои адреса и имена, прочитайте каждую команду перед запуском и держите свежий бэкап старой базы.
Тимур Ахимов, @akhimovcom, prtner.ai. Шрифт Medeo, пиктограммы Lucide (ISC).