Loading theme
~/saverio

Migrazione PostgreSQL e R2

9 feb 2025· agg. 24 feb 2026· 7 min
postgresqlr2cloudflaremigrationneon

Migrazione PostgreSQL e R2

Cosa otterrai: un database PostgreSQL dedicato su Hetzner, un bucket R2 con accesso pubblico per le immagini e una migrazione passo-passo dei dati esistenti da Neon e UploadThing.

Questa sezione copre l'aggiunta di una seconda app che ha bisogno di un database e di image storage. La prima app viveva su Vercel con Neon PostgreSQL e UploadThing. Vuoi spostarla sul tuo server Hetzner e su Cloudflare R2.

PostgreSQL su Hetzner

Hai due opzioni: aggiungere PostgreSQL come servizio Coolify, oppure usare un database managed esterno.

Opzione A: PostgreSQL via Coolify

  1. In Coolify, apri il tuo progetto
  2. Add Resource → cerca PostgreSQL
  3. Chiamalo postgres-your-app (es. postgres-mazzaimmobiliare) per distinguerlo da database futuri
  4. Seleziona il template PostgreSQL e fai deploy
  5. Coolify crea un container con database e utente di default
  6. Opzionale, per accesso locale: aggiungi il port mapping 5433:5432 nella resource → ConfigurationPorts così puoi usare Prisma Studio o eseguire migrazioni via SSH tunnel (vedi sotto)
  7. Nelle impostazioni della resource, trova la sezione Connection o Environment
  8. Copia il Postgres URL (internal) da usare come DATABASE_URL della tua app

Il formato è:

postgresql://USER:PASSWORD@HOST:5432/DATABASE?sslmode=disable

Se PostgreSQL gira sullo stesso server della tua app, HOST è il valore mostrato da Coolify (spesso un UUID tipo awksg4ssww4ooscwwc8gks8g), non il nome della resource e neanche l'IP esterno del server. La porta deve essere 5432 (PostgreSQL), non 3000. Per il Postgres interno Docker di Coolify (SSL disabilitato), usa sslmode=disable. Copia l'URL interno esattamente dalle impostazioni della resource.

Crea un database dedicato per la tua app:

  1. Coolify → postgres-your-app → Terminal
  2. Esegui:
psql -U postgres -c "CREATE DATABASE your_app_name;"
psql -U postgres -l
  1. Usa postgresql://USER:PASSWORD@HOST:5432/your_app_name come DATABASE_URL (sostituisci /postgres con /your_app_name nell'URL interno)

Opzione B: PostgreSQL managed esterno

Se preferisci un database managed (es. Supabase, Neon, o Hetzner Managed Database), crea una nuova istanza e usa la sua connection string. Assicurati che il tuo server Hetzner possa raggiungerlo (consenti l'IP del server nel firewall del database).


Bucket Cloudflare R2 con accesso pubblico

R2 è object storage S3-compatible. Ti servono un bucket e l'accesso pubblico così le immagini possono essere servite ai visitatori.

Crea il bucket

  1. Login su Cloudflare Dashboard
  2. R2 Object StorageOverviewCreate bucket
  3. Dagli un nome (es. your-app-images). Usa un bucket per app per isolamento e gestione più facile
  4. Scegli una location (es. Automatic)
  5. Crea

Abilita l'accesso pubblico

Importante: senza questo, le immagini nel bucket non sono raggiungibili dai browser. L'app non riuscirà a mostrarle.

  1. Apri il bucket → SettingsGeneral
  2. Sotto Public Development URL, click su Enable (se mostra "disabled")
  3. Cloudflare genera https://pub-xxx.r2.dev. Copia questo URL: è il tuo R2_PUBLIC_URL
  4. Non usare l'endpoint S3 API (https://xxx.r2.cloudflarestorage.com) come R2_PUBLIC_URL. Quello serve per gli upload, non per le letture pubbliche

Per un dominio custom (es. images.your-domain.com), vedi lo step Custom Domain qui sotto. Usa prima l'URL R2.dev per verificare che tutto funzioni.

Crea API token

  1. R2OverviewManage R2 API Tokens
  2. Create API token
  3. Permessi: Object Read & Write
  4. Specifica il bucket (oppure lascia "Apply to all buckets")
  5. Crea e copia:
    • Access Key ID
    • Secret Access Key

Annota anche il tuo Account ID (nell'URL della overview R2 o nella sidebar destra).

Dominio custom (opzionale)

Per servire le immagini da images.your-domain.com:

  1. Nel bucket → SettingsPublic accessCustom domain
  2. Aggiungi images.your-domain.com
  3. Se il dominio è su Cloudflare, Cloudflare crea il CNAME in automatico
  4. Se il dominio è su Namecheap (o un altro registrar), aggiungi un record CNAME:
    • Name: images (o cdn, static, ecc.)
    • Value: il target mostrato da Cloudflare (es. your-app-images.YOUR_ACCOUNT_ID.r2.cloudflarestorage.com)
  5. Imposta R2_PUBLIC_URL=https://images.your-domain.com nelle env della tua app

Errori comuni: usare l'endpoint S3 API (r2.cloudflarestorage.com) come R2_PUBLIC_URL. Le immagini non caricheranno. Usare R2_PUBLIC_URL con un path tipo /mazzaimmobiliare. L'app appende la chiave in automatico. Se il DB ha già URL sbagliati, esegui pnpm fix:r2-urls (vedi la sezione Deployment Troubleshooting).


Migra il database

Esporti dal sorgente (es. Neon) e importi nel nuovo PostgreSQL su Hetzner.

Quando usi Prisma: lo schema su Hetzner viene creato da pnpm db:migrate. Esporta solo i dati da Neon, poi importa. Non esportare lo schema; andrebbe in conflitto.

Esporta da Neon (o qualsiasi PostgreSQL)

Match di versione: Neon usa PostgreSQL 17. Il tuo pg_dump deve essere uguale o più nuovo. Se psql/pg_dump non sono installati, usa Docker con postgres:17 o postgres:latest.

Utenti zsh: cita la connection string; altrimenti il ? scatena la glob expansion.

Solo dati (quando lo schema esiste già su Hetzner):

docker run --rm -v "$(pwd)":/work -w /work postgres:17 pg_dump \
  "postgresql://user:[email protected]/neondb?sslmode=require" \
  --no-owner --no-acl --data-only \
  --exclude-table-data=_prisma_migrations \
  -f backup-data.sql

--exclude-table-data=_prisma_migrations evita conflitti con lo storico di migrazioni Prisma su Hetzner.

Dump completo (solo se il DB Hetzner è vuoto e non stai usando Prisma):

docker run --rm -v "$(pwd)":/work -w /work postgres:17 pg_dump \
  "postgresql://user:[email protected]/neondb?sslmode=require" \
  --no-owner --no-acl \
  -f backup.sql

Tronca le tabelle prima di importare (solo dati)

Se importi dati in uno schema esistente, svuota prima le tabelle. Usa la porta 5433 quando ti colleghi via SSH tunnel:

docker run --rm --network host -v "$(pwd)":/work -w /work postgres:17 psql \
  "postgresql://postgres:PASSWORD@localhost:5433/your_app_name?sslmode=disable" \
  -c "TRUNCATE your_tables CASCADE;"

Adatta i nomi delle tabelle al tuo schema. Su macOS, usa host.docker.internal invece di localhost se il tunnel è sull'host.

Importa nel PostgreSQL Hetzner

Usa la porta 5433 quando il tunnel forwarda al Postgres esposto del server:

docker run --rm --network host -v "$(pwd)":/work -w /work postgres:17 psql \
  "postgresql://postgres:PASSWORD@localhost:5433/your_app_name?sslmode=disable" \
  -f backup-data.sql

Se PostgreSQL non è esposto (Coolify spesso tiene i database interni), aggiungi il port mapping 5433:5432 in Coolify e usa un SSH tunnel (vedi Eseguire migrazioni dal locale sopra). Connection string: usa @localhost:5433 quando ti colleghi via tunnel.

Eseguire migrazioni dal locale (SSH tunnel)

Coolify tiene PostgreSQL interno di default. Per eseguire pnpm db:migrate o Prisma Studio dalla tua macchina:

  1. Esponi Postgres sull'host: in Coolify, apri la resource PostgreSQL → aggiungi il port mapping 5433:5432. Salva e riavvia la resource.

  2. Avvia l'SSH tunnel (lascia questo terminale aperto):

    ssh -L 5433:localhost:5433 -p 2222 -o IdentitiesOnly=yes \
      -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
      -i ~/.ssh/hetzner-platform deploy@YOUR_SERVER_IP
    
  3. In .env, imposta DATABASE_URL="postgresql://postgres:PASSWORD@localhost:5433/your_app_name?sslmode=disable"

  4. In un altro terminale: pnpm db:migrate (o npx prisma studio)

Vedi Prisma Studio / DB client via SSH tunnel in Deployment Troubleshooting per più dettaglio.

Alternativa: migrazioni al deploy

Aggiungi al Start Command della tua app in Coolify:

pnpm exec prisma migrate deploy && pnpm --filter your-app start

Le migrazioni pending partono ogni volta che il container si avvia. Non serve esporre il database.

Alternativa: schema pulito + import dei dati

Se preferisci uno schema pulito (es. migrazioni Prisma) e migrare solo i dati:

  1. Punta DATABASE_URL al nuovo PostgreSQL (via tunnel o URL interno)
  2. Esegui pnpm db:migrate (o prisma migrate deploy) nella tua app
  3. Esporta i dati dal vecchio DB come JSON/CSV e adatta lo script di seed per importarli

Migra le immagini (UploadThing a R2)

Le immagini su UploadThing hanno URL del tipo https://utfs.io/f/xxx. Il tuo database memorizza url e key per ogni immagine. La migrazione scarica ogni file dall'URL pubblico (non servono credenziali UploadThing API) e lo carica su R2, poi aggiorna il database con il nuovo URL e key.

Script (Node.js)

Crea scripts/migrate-images-to-r2.mjs nella tua app (es. apps/your-app/scripts/) così può usare i node_modules dell'app:

import "dotenv/config";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import pg from "pg";

const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID;
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY;
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL;
const DATABASE_URL = process.env.DATABASE_URL;

const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: R2_ACCESS_KEY_ID,
    secretAccessKey: R2_SECRET_ACCESS_KEY,
  },
  forcePathStyle: true,
});

async function migrateTable(pool, tableName, prefix) {
  const res = await pool.query(
    `SELECT id, url, key FROM "${tableName}" WHERE url LIKE 'https://utfs.io%' OR url LIKE 'https://%.ufs.sh%'`
  );

  for (const row of res.rows) {
    const response = await fetch(row.url);
    const buffer = Buffer.from(await response.arrayBuffer());
    const ext = row.key.split(".").pop() || "jpg";
    const newKey = `${prefix}/${row.id}.${ext}`;

    await s3.send(
      new PutObjectCommand({
        Bucket: R2_BUCKET_NAME,
        Key: newKey,
        Body: buffer,
        ContentType: `image/${ext === "jpg" ? "jpeg" : ext}`,
      })
    );

    const newUrl = `${R2_PUBLIC_URL.replace(/\/$/, "")}/${newKey}`;
    await pool.query(
      `UPDATE "${tableName}" SET url = $1, key = $2 WHERE id = $3`,
      [newUrl, newKey, row.id]
    );
    console.log(`Migrated ${tableName} ${row.id}`);
  }
}

async function main() {
  const pool = new pg.Pool({ connectionString: DATABASE_URL });

  await migrateTable(pool, "PropertyImage", "properties");
  await migrateTable(pool, "TownImage", "towns");
  await migrateTable(pool, "HeroImage", "hero");
  await migrateTable(pool, "ResidenceImage", "residence");

  await pool.end();
}

main().catch(console.error);

Adatta nomi tabelle e prefissi al tuo schema.

Esegui lo script

  1. Assicurati che la tua app abbia pg, @aws-sdk/client-s3 e dotenv come dipendenze
  2. Imposta le env var in .env: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL, DATABASE_URL (il nuovo DB Hetzner)
  3. Esegui dalla directory dell'app: pnpm migrate:images (o node scripts/migrate-images-to-r2.mjs)
  4. Verifica qualche URL di immagine nel browser

Aggiorna il dominio

Se l'app era su Vercel con dominio custom:

  1. In Coolify, aggiungi il dominio alla tua app (es. your-app.com, www.your-app.com)
  2. Nel tuo DNS (Cloudflare o Namecheap), punta il record A per @ all'IP del server Hetzner
  3. Aggiungi un CNAME per www verso il dominio root (o come suggerisce Coolify)
  4. Rimuovi il dominio dal progetto Vercel (altrimenti continuerà a risolvere lì)
  5. Aspetta la propagazione DNS (minuti o ore)
  6. Coolify rilascerà il certificato SSL in automatico

StepCosa hai fatto
1Creato PostgreSQL su Hetzner (Coolify o esterno)
2Creato bucket R2, abilitato accesso pubblico, preso API token
3Eseguite le migrazioni Prisma, esportati i dati da Neon (--data-only, escludendo _prisma_migrations), troncate le tabelle, importati su Hetzner
4Eseguito lo script di migrazione per copiare le immagini dagli URL UploadThing a R2 (senza API key UploadThing)
5Puntato il dominio al nuovo deployment

La tua app gira ora sulla tua infrastruttura con database e object storage dedicati.