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
- In Coolify, apri il tuo progetto
- Add Resource → cerca PostgreSQL
- Chiamalo
postgres-your-app(es.postgres-mazzaimmobiliare) per distinguerlo da database futuri - Seleziona il template PostgreSQL e fai deploy
- Coolify crea un container con database e utente di default
- Opzionale, per accesso locale: aggiungi il port mapping
5433:5432nella resource → Configuration → Ports così puoi usare Prisma Studio o eseguire migrazioni via SSH tunnel (vedi sotto) - Nelle impostazioni della resource, trova la sezione Connection o Environment
- Copia il Postgres URL (internal) da usare come
DATABASE_URLdella 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:
- Coolify → postgres-your-app → Terminal
- Esegui:
psql -U postgres -c "CREATE DATABASE your_app_name;"
psql -U postgres -l
- Usa
postgresql://USER:PASSWORD@HOST:5432/your_app_namecomeDATABASE_URL(sostituisci/postgrescon/your_app_namenell'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
- Login su Cloudflare Dashboard
- R2 Object Storage → Overview → Create bucket
- Dagli un nome (es.
your-app-images). Usa un bucket per app per isolamento e gestione più facile - Scegli una location (es. Automatic)
- Crea
Abilita l'accesso pubblico
Importante: senza questo, le immagini nel bucket non sono raggiungibili dai browser. L'app non riuscirà a mostrarle.
- Apri il bucket → Settings → General
- Sotto Public Development URL, click su Enable (se mostra "disabled")
- Cloudflare genera
https://pub-xxx.r2.dev. Copia questo URL: è il tuoR2_PUBLIC_URL - Non usare l'endpoint S3 API (
https://xxx.r2.cloudflarestorage.com) comeR2_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
- R2 → Overview → Manage R2 API Tokens
- Create API token
- Permessi: Object Read & Write
- Specifica il bucket (oppure lascia "Apply to all buckets")
- 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:
- Nel bucket → Settings → Public access → Custom domain
- Aggiungi
images.your-domain.com - Se il dominio è su Cloudflare, Cloudflare crea il CNAME in automatico
- Se il dominio è su Namecheap (o un altro registrar), aggiungi un record CNAME:
- Name:
images(ocdn,static, ecc.) - Value: il target mostrato da Cloudflare (es.
your-app-images.YOUR_ACCOUNT_ID.r2.cloudflarestorage.com)
- Name:
- Imposta
R2_PUBLIC_URL=https://images.your-domain.comnelle 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:
-
Esponi Postgres sull'host: in Coolify, apri la resource PostgreSQL → aggiungi il port mapping
5433:5432. Salva e riavvia la resource. -
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 -
In
.env, impostaDATABASE_URL="postgresql://postgres:PASSWORD@localhost:5433/your_app_name?sslmode=disable" -
In un altro terminale:
pnpm db:migrate(onpx 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:
- Punta
DATABASE_URLal nuovo PostgreSQL (via tunnel o URL interno) - Esegui
pnpm db:migrate(oprisma migrate deploy) nella tua app - 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
- Assicurati che la tua app abbia
pg,@aws-sdk/client-s3edotenvcome dipendenze - 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) - Esegui dalla directory dell'app:
pnpm migrate:images(onode scripts/migrate-images-to-r2.mjs) - Verifica qualche URL di immagine nel browser
Aggiorna il dominio
Se l'app era su Vercel con dominio custom:
- In Coolify, aggiungi il dominio alla tua app (es.
your-app.com,www.your-app.com) - Nel tuo DNS (Cloudflare o Namecheap), punta il record A per
@all'IP del server Hetzner - Aggiungi un CNAME per
wwwverso il dominio root (o come suggerisce Coolify) - Rimuovi il dominio dal progetto Vercel (altrimenti continuerà a risolvere lì)
- Aspetta la propagazione DNS (minuti o ore)
- Coolify rilascerà il certificato SSL in automatico
Riepilogo
| Step | Cosa hai fatto |
|---|---|
| 1 | Creato PostgreSQL su Hetzner (Coolify o esterno) |
| 2 | Creato bucket R2, abilitato accesso pubblico, preso API token |
| 3 | Eseguite le migrazioni Prisma, esportati i dati da Neon (--data-only, escludendo _prisma_migrations), troncate le tabelle, importati su Hetzner |
| 4 | Eseguito lo script di migrazione per copiare le immagini dagli URL UploadThing a R2 (senza API key UploadThing) |
| 5 | Puntato il dominio al nuovo deployment |
La tua app gira ora sulla tua infrastruttura con database e object storage dedicati.