Jesús Erro
Explorar conexiones
Transición controlada de un PostgreSQL local hacia una plataforma gestionada con ramas de rehearsal, gates de verificación y dominios de datos aislados.

De Docker a Neon: un cutover PostgreSQL con branches, writers y autoridad verificable

Migrar una base de datos no consiste sólo en mover datos. Consiste en trasladar autoridad sin perder identidad, seguridad ni capacidad de volver atrás.

Ése fue el aprendizaje central al mover la autoridad PostgreSQL canónica de jesuserro.com desde un Docker local hasta Neon production. La dificultad no estaba en copiar unas tablas. Estaba en poder responder, antes y después del cambio, cuatro preguntas distintas:

  • ¿qué código estamos ejecutando?;
  • ¿qué estructura espera ese código?;
  • ¿sobre qué timeline de PostgreSQL estamos trabajando?;
  • ¿contiene el destino exactamente el mismo corpus?

La respuesta corta tampoco es «migramos un servidor PostgreSQL». Migramos un dominio de datos de una autoridad PostgreSQL a otra.

Esa distinción cambia la forma de diseñar el cutover. Obliga a separar copia y activación, a tratar los writers como capacidades y a probar la receta en un entorno aislado antes de ejecutarla sobre producción.

El punto de partida: una separación segura que empezó a costar

Durante la fase shadow, la separación física era deliberada:

LOCAL DOCKER POSTGRESQL
├── literary.*              canonical
└── taxonomy.*              canonical

NEON PRODUCTION
├── goodreads.*             evidence
├── external_evidence.*     evidence
├── reporting.*             read models
└── control_plane_private.* private runtime state

Reviews y Readings tenían su autoridad canónica en literary.* y taxonomy.*, persistida en un volumen Docker local. Neon alojaba evidencia externa, modelos de lectura y estado privado del control plane.

La frontera física protegía una idea importante: evidencia y estado canónico no son lo mismo. Mientras el nuevo modelo se estaba construyendo, dos PostgreSQL distintos reducían el riesgo de que una herramienta operacional escribiese por accidente en el dominio editorial.

Pero una frontera útil en una fase puede convertirse después en fricción:

  • la autoridad durable dependía de un entorno local;
  • había dos PostgreSQL físicos que operar y observar;
  • las lecturas entre dominios requerían composición en aplicación, FDW o dblink;
  • humanos y agentes no podían consultar directamente toda la plataforma de datos;
  • ensayar una topología parecida a producción era más incómodo;
  • la observabilidad y el análisis cross-domain quedaban fragmentados.

La arquitectura había cumplido su función. El siguiente paso no era eliminar sus límites, sino trasladarlos desde la separación física hacia contratos explícitos de schemas, roles y writers.

Por qué no bastaba con mover el container

El container era el runtime de PostgreSQL, no la identidad de los datos. El corpus persistía en el volumen y en el data directory gestionado por PostgreSQL.

Neon, además, no recibe un data directory local para convertirlo en su almacenamiento. Es PostgreSQL gestionado y controla su almacenamiento físico. Y la base de producción ya contenía otros dominios que debían conservarse.

No queríamos reemplazar Neon ni restaurar por encima de todo neondb. Queríamos incorporar literary.* y taxonomy.* sin alterar goodreads.*, external_evidence.*, reporting.* ni control_plane_private.*.

pg_dump y pg_restore habrían podido transportar schemas o filas. Eso no habría resuelto por sí solo:

  • quién tenía autoridad después de la copia;
  • qué principal podía escribir en cada dominio;
  • si la aplicación estaba usando realmente el destino;
  • si el corpus era idéntico;
  • si los datos operacionales seguían intactos;
  • ni cómo recuperar el servicio si el runtime fallaba tras el cambio.

Mover bytes es una parte de la migración. No es el cutover completo.

Un almacenamiento compartido no implica una autoridad compartida

El resultado físico es más simple:

NEON / neondb
├── goodreads.*               evidence
├── external_evidence.*       evidence
├── literary.*                canonical
├── taxonomy.*                canonical
├── reporting.*               read models
└── control_plane_private.*   private runtime state

Una única base gestionada permite SQL cross-domain, observabilidad común y un destino remoto durable. Sin embargo, la unidad física no convierte todos sus datos en un único dominio.

La regla que lo hace seguro es doble:

shared storage != shared authority
shared storage != shared writer

Los schemas permanecen separados. También los migradores: las migraciones operacionales y Liquibase conservan sus propios ledgers y responsabilidades. Sobre todo, permanecen separados los principals con capacidad de escritura.

Un writer es una capability

Llamar canonical_writer a un rol no le concede propiedades especiales. La seguridad procede de sus atributos, memberships y grants efectivos.

El modelo conceptual era éste:

canonical_writer
├── puede mutar literary.*
├── puede mutar taxonomy.*
└── no puede mutar evidence, reporting ni private

control_plane
└── lectura controlada mediante reporting.*
    no escritura canonical

Liquibase / admin
└── evolución de schema y provisioning
    no runtime normal

El writer canónico es, por tanto, una capacidad acotada. No es simplemente «el usuario de la base de datos». Una connection string que autentica correctamente todavía puede representar el principal equivocado.

Esta separación permite compartir almacenamiento sin colapsar ownership. El control plane no adquiere escritura canónica por estar en la misma base. El writer canónico tampoco puede alterar evidencia ni productos de reporting. El migrador puede evolucionar estructura, pero no es la identidad habitual del runtime.

Neon Branches: parecidas a Git, con un límite importante

La analogía «Neon Branches se parece a Git para PostgreSQL» resulta útil si se usa con cuidado.

Una branch puede nacer del estado de su padre con schema, tablas, índices, constraints y datos. A partir de ahí diverge mediante copy-on-write sin mutar la branch de producción.

production
├── rehearsal fallido
└── rehearsal limpio
    ├── migrations
    ├── roles
    ├── corpus
    ├── fingerprints
    └── security verification

Eso la convierte en un laboratorio production-like: permite encontrar incompatibilidades de PostgreSQL gestionado, probar idempotencia, verificar privilegios y copiar el corpus contra un target realista.

Pero una Neon branch no es un commit Git. No existe una correspondencia uno a uno entre ambos, ni un hash de branch de Neon equivale al SHA del código. Versionan cosas distintas.

Tampoco promovimos la branch de rehearsal para convertirla en production. La usamos para demostrar que la receta funcionaba; después repetimos esa receta, de forma supervisada, sobre production.

Esa elección evita confundir dos objetivos: ensayar un procedimiento y decidir cuál es el timeline autoritativo.

Las cuatro identidades de un entorno verificable

Decir «estamos en la versión correcta» es demasiado ambiguo para una migración. Un entorno relevante se describe mejor como una composición:

Environment =
  Git SHA
  + schema identity
  + Neon branch
  + data fingerprint / point-in-time cuando importe

1. Git identity

El commit SHA identifica el código, los scripts y la intención que estamos ejecutando. Permite ligar una orden, una validación y una evidencia al mismo HEAD exacto.

2. Schema identity

Los changesets aplicados por Liquibase identifican la evolución estructural que la aplicación espera. Dos bases pueden contener filas parecidas y no compartir el mismo contrato de schema.

3. Neon branch identity

La branch identifica el timeline y el entorno PostgreSQL: rehearsal, production u otra derivación. Es la respuesta a «¿sobre qué estado aislado estamos operando?», no a «¿qué commit de aplicación se ejecuta?».

4. Data identity

Un fingerprint determinista identifica el corpus. Permite demostrar que source y target contienen los mismos datos relevantes sin publicar bodies, credenciales ni volcados completos.

Git versiona intención y código. Liquibase versiona evolución del schema. Neon Branches aíslan estados de PostgreSQL. Los fingerprints identifican el corpus cuando necesitamos demostrar igualdad. Ninguna de estas identidades sustituye a las otras.

Copiar datos no cambia la autoridad

Durante el cutover existió una situación intermedia perfectamente válida:

Docker
  corpus canonical
  AUTHORITY = YES

Neon
  copia idéntica
  AUTHORITY = NO

El target podía tener el mismo fingerprint y seguir sin ser la autoridad durable. Todavía no era el destino efectivo de las herramientas canónicas ni del writer previsto.

Por eso el patrón correcto separa tres acciones:

copy
→ verify
→ switch authority

Mezclar copia y activación reduce la ventana de observación y complica la recuperación. Mantenerlas separadas permite verificar el target mientras el source continúa siendo la verdad reconocida. El cutover conceptual ocurre sólo cuando el destino verificado adquiere explícitamente la autoridad.

Dieciséis pasos, cinco fases

El procedimiento real tuvo pasos A–P, pero esas letras eran el protocolo concreto de este proyecto, no un estándar universal. El modelo reutilizable cabe en cinco fases:

Fase Pasos Qué demuestra
Preservar y medir A–B Hay backup recuperable, fingerprint del source y baseline de production.
Preparar el destino C–F El schema converge, la repetición es idempotente y los privilegios respetan los límites.
Planificar y copiar G–J El humano aprueba la operación exacta, APPLY copia sólo lo previsto y target = source.
Probar invariantes K–M La proyección sigue CURRENT, la seguridad se conserva y los datos operacionales no cambian.
Cambiar autoridad y preservar recovery N–P El runtime usa la nueva autoridad, existe backup posterior y el fallback continúa disponible.

La agrupación importa porque evita convertir el método en una checklist sin significado. Cada fase responde a una clase de riesgo distinta: pérdida, incompatibilidad estructural, operación no autorizada, regresión y recuperación.

Dos hashes, dos preguntas

El proceso usó hashes truncados en la evidencia pública, pero no eran intercambiables.

El corpus fingerprint respondía:

¿Son éstos exactamente los mismos datos?

source → 9bb2…
target → 9bb2…

El PLAN SHA respondía:

¿Estamos ejecutando exactamente la operación que el humano auditó?

PLAN

SHA e454…

human approval of e454…

APPLY requiring e454…

El fingerprint identifica estado. El PLAN SHA liga intención, revisión y ejecución. Que el target termine con el fingerprint esperado no autoriza cualquier camino para llegar a él; que el plan sea el aprobado tampoco demuestra por sí solo que los datos finales coincidan.

El gate humano debe aprobar el plan exacto

Una autorización genérica como «haz la migración» no equivale a «he revisado esta operación y autorizo este PLAN SHA».

Entre PLAN y APPLY pueden cambiar el source, el target o el código. Por eso APPLY volvió a calcular el plan y exigió el hash aprobado. Si algo hubiese cambiado, debía fallar cerrado como plan obsoleto o incompatible.

El gate humano no era una ceremonia. Acotaba la capacidad de mutación a un artefacto concreto:

human intent
  + exact PLAN
  + expected SHA
  + unchanged inputs
= authorized APPLY

Cuando validate escribió: parar sin empeorar la sorpresa

Otro aprendizaje apareció antes de aplicar los changesets. Esperábamos que Liquibase validate fuese completamente read-only. En aquel target vacío, la ejecución creó las tablas de tracking de Liquibase, aunque no aplicó ningún changeset ni mutó datos canónicos.

La respuesta correcta fue:

unexpected write
→ STOP
→ observar
→ comprobar ausencia de canonical mutations
→ aceptar explícitamente la desviación
→ continuar con nuevo baseline

Una sorpresa no implica automáticamente rollback. Implica STOP, observación y una nueva decisión.

También dejó una regla operacional sencilla: no hagas una segunda escritura sólo para ocultar la primera. Borrar inmediatamente las tablas vacías habría añadido otra mutación y otro riesgo justo antes de que Liquibase fuese a necesitarlas. Tras la auditoría, el estado observado se aceptó como nuevo baseline para el siguiente paso.

Aquí conviene hablar de fallback o recovery cuando eso es lo que realmente existe. Deshacer una migración operacional completa rara vez es equivalente al rollback transaccional de una única operación SQL.

La PR como bus durable entre Director e Implementer

La migración no fue una sesión continua de comandos. La PR actuó como bus durable y audit trail entre tres roles distintos:

Director Order

STEP X ONLY
allowed / forbidden
expected evidence
STOP

Implementer
ejecuta

PR Evidence
CUTOVER STEP X: COMPLETE | BLOCKED
next_step_requested
STOP

Director
audita independientemente
autoriza el siguiente paso

El Implementer mutaba o ejecutaba. El Director observaba, auditaba y decidía. El humano aceptaba riesgo en los gates que lo requerían.

Cada evidencia quedaba ligada al HEAD exacto. Auditar el commit A no autorizaba el commit B. El mismo principio se aplicó al PLAN: aprobar una intención general no autorizaba un payload distinto.

La PR resultó mejor que un chat efímero porque conservó órdenes, límites, resultados, bloqueos y decisiones junto al cambio que los hacía relevantes. El chat podía coordinar; la PR podía sostener una auditoría posterior.

Para qué mereció la pena

El beneficio no es simplemente «tener PostgreSQL en la nube».

La autoridad y el corpus canónico son ahora durables y remotos en Neon; el compute gestionado puede autosuspenderse según configuración. El portátil, WSL y el volumen Docker dejan de ser dependencia de la autoridad de producción, aunque Docker siga siendo valioso para desarrollo, tests desechables y fallback.

La unificación permite además:

  • SQL entre evidence, canonical y reporting sin FDW ni dblink;
  • observabilidad común sobre métricas, cardinalidades, locks, estructura y queries;
  • análisis directo por humanos y agentes con credenciales autorizadas;
  • futuros cambios estructurales estudiados sobre branches y evolucionados, tras aprobación, con Liquibase;
  • laboratorios production-like de bajo riesgo mediante Neon Branches;
  • writers least-privilege aunque la base física sea compartida;
  • fingerprints y recovery verificables;
  • una base mejor para Data Quality y reporting cross-domain;
  • una topología de producción conceptualmente más simple.

La posibilidad de conectar un agente no elimina la frontera de seguridad. ChatGPT u otros agentes pueden consultar la base gestionada cuando el conector y las credenciales autorizadas lo permiten. Lo importante es que esa observación puede abarcar la plataforma de datos sin convertir al agente ni al control plane en writer canónico.

El patrón portable

El esqueleto que conservaría para otra migración es éste:

DISCOVER

freeze / measure source

BACKUP + RECOVERY PROOF

REHEARSAL ON ISOLATED TARGET

MIGRATION PLAN

HUMAN APPROVAL OF EXACT PLAN

COPY

VERIFY IDENTITY + INVARIANTS

SWITCH AUTHORITY

VERIFY RUNTIME

KEEP FALLBACK

Otra migración tendrá adaptadores diferentes. Una base legacy hacia PostgreSQL puede requerir traducción semántica, reconciliación de identidades o una Anti-Corruption Layer en lugar de una copia PostgreSQL a PostgreSQL. También tendrá otros invariantes y otra estrategia de recovery.

Lo portable no son las letras A–P ni una herramienta concreta. Es la separación de responsabilidades: medir antes de tocar, ensayar la receta, aprobar la operación exacta, distinguir copia de autoridad y conservar una salida comprobada.

El destino final puede compartir almacenamiento. No debe compartir autoridad por accidente.