<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Adrian Fabricio Salas Acuña</title>
    <description>The latest articles on DEV Community by Adrian Fabricio Salas Acuña (@adriansalasepis).</description>
    <link>https://dev.to/adriansalasepis</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4188083%2Fcee5d887-f8c7-4136-bca3-455bd74b5a8f.png</url>
      <title>DEV Community: Adrian Fabricio Salas Acuña</title>
      <link>https://dev.to/adriansalasepis</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adriansalasepis"/>
    <language>en</language>
    <item>
      <title>Respaldos que sí se pueden restaurar: estrategia automatizada para PostgreSQL con GitHub Actions, Render y Neon</title>
      <dc:creator>Adrian Fabricio Salas Acuña</dc:creator>
      <pubDate>Fri, 16 Oct 2026 07:09:00 +0000</pubDate>
      <link>https://dev.to/adriansalasepis/respaldos-que-si-se-pueden-restaurar-estrategia-automatizada-para-postgresql-con-github-actions-3glj</link>
      <guid>https://dev.to/adriansalasepis/respaldos-que-si-se-pueden-restaurar-estrategia-automatizada-para-postgresql-con-github-actions-3glj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Un respaldo que nunca se restauró es solo una esperanza. En este artículo construyo una app real en la nube (FastAPI + PostgreSQL 17) cuyo respaldo diario se &lt;strong&gt;genera, se restaura en una base desechable, se compara fila por fila, se cifra con AES-256 y se guarda fuera del proveedor&lt;/strong&gt;, todo con GitHub Actions. Cada &lt;code&gt;git push&lt;/code&gt; además prueba y despliega la app automáticamente.&lt;/p&gt;

&lt;p&gt;🔗 &lt;strong&gt;Repositorio:&lt;/strong&gt; &lt;a href="https://github.com/AdrianSalasEPIS/pg-backup-guardian" rel="noopener noreferrer"&gt;https://github.com/AdrianSalasEPIS/pg-backup-guardian&lt;/a&gt;&lt;br&gt;
🌐 &lt;strong&gt;App publicada:&lt;/strong&gt; &lt;a href="https://pg-backup-guardian.onrender.com" rel="noopener noreferrer"&gt;https://pg-backup-guardian.onrender.com&lt;/a&gt;&lt;br&gt;
🎥 &lt;strong&gt;Video (menos de 5 min):&lt;/strong&gt; &lt;a href="https://youtu.be/hWiH6ANFUmQ" rel="noopener noreferrer"&gt;https://youtu.be/hWiH6ANFUmQ&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/hWiH6ANFUmQ" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  1. El problema: tener backups no es lo mismo que poder recuperarse
&lt;/h2&gt;

&lt;p&gt;Casi todos los equipos "tienen backups". Pocos pueden responder con seguridad estas tres preguntas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;¿Cuántos datos puedo perder como máximo?&lt;/strong&gt; → &lt;strong&gt;RPO&lt;/strong&gt; (&lt;em&gt;Recovery Point Objective&lt;/em&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;¿Cuánto tiempo tardo en volver a estar en línea?&lt;/strong&gt; → &lt;strong&gt;RTO&lt;/strong&gt; (&lt;em&gt;Recovery Time Objective&lt;/em&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;¿Cuándo fue la última vez que restauré un backup y funcionó?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Si la respuesta a la tercera es "nunca", el RPO y el RTO reales son desconocidos. Por eso el principio que guía todo este proyecto es simple: &lt;strong&gt;cada respaldo se restaura automáticamente antes de darlo por bueno&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Decidí trabajar con &lt;strong&gt;PostgreSQL&lt;/strong&gt; (y no con MS SQL Server) porque es open source, sus herramientas de respaldo (&lt;code&gt;pg_dump&lt;/code&gt;, &lt;code&gt;pg_restore&lt;/code&gt;, &lt;code&gt;pg_basebackup&lt;/code&gt;) vienen incluidas y existen proveedores en la nube con plan gratuito.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. La regla 3-2-1 aplicada a un proyecto pequeño
&lt;/h2&gt;

&lt;p&gt;La regla clásica dice: &lt;strong&gt;3&lt;/strong&gt; copias de los datos, en &lt;strong&gt;2&lt;/strong&gt; medios distintos, &lt;strong&gt;1&lt;/strong&gt; fuera del sitio. Así la apliqué:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Copia&lt;/th&gt;
&lt;th&gt;Dónde vive&lt;/th&gt;
&lt;th&gt;Tipo&lt;/th&gt;
&lt;th&gt;Protege contra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Datos vivos&lt;/td&gt;
&lt;td&gt;Neon (PostgreSQL 17 gestionado)&lt;/td&gt;
&lt;td&gt;Producción&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Historial WAL del proveedor&lt;/td&gt;
&lt;td&gt;Neon (PITR / &lt;em&gt;branching&lt;/em&gt;)&lt;/td&gt;
&lt;td&gt;Continuo, segundos de RPO&lt;/td&gt;
&lt;td&gt;Errores humanos (&lt;code&gt;DELETE&lt;/code&gt; sin &lt;code&gt;WHERE&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Dump lógico cifrado&lt;/td&gt;
&lt;td&gt;GitHub (artefacto de Actions, 30 días)&lt;/td&gt;
&lt;td&gt;Diario&lt;/td&gt;
&lt;td&gt;Caída o pérdida de la cuenta del proveedor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;La copia 3 es la clave: &lt;strong&gt;está fuera del proveedor de la base de datos&lt;/strong&gt;. Si mañana pierdo el acceso a Neon, el dump cifrado sigue en GitHub y puedo restaurarlo en cualquier PostgreSQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Arquitectura
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; git push ──► GitHub Actions ─┬─ CI: pruebas contra PostgreSQL 17 real (API + backup/restore)
                              ├─ CD: deploy hook ──► Render (FastAPI) ──► Neon (PostgreSQL 17)
                              └─ Cron diario: pg_dump ─► restauración de prueba ─► cifrado AES-256
                                                     ─► artefacto (30 días) ─► registro en backup_log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App:&lt;/strong&gt; una pequeña biblioteca (libros y préstamos) con un panel que muestra el historial de respaldos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base de datos:&lt;/strong&gt; PostgreSQL 17 en Neon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting:&lt;/strong&gt; Render, configurado como código con un &lt;code&gt;render.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatización:&lt;/strong&gt; dos workflows de GitHub Actions: &lt;code&gt;ci-cd.yml&lt;/code&gt; y &lt;code&gt;backup.yml&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F44dxuhs3eaqjgud1lns3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F44dxuhs3eaqjgud1lns3.png" alt="Panel de la app publicada en Render: indicadores de respaldo, RPO actual y estado de integridad de los datos" width="799" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. El corazón: &lt;code&gt;backup.py&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Escribí un script de Python con cuatro subcomandos: &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;verify&lt;/code&gt;, &lt;code&gt;prune&lt;/code&gt; y &lt;code&gt;log&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1 Crear el respaldo + manifiesto
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;pg_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_dump&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--format=custom&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--compress=9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--no-owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--no-acl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--dbname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;database_url&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uso el &lt;strong&gt;formato custom&lt;/strong&gt; (&lt;code&gt;-Fc&lt;/code&gt;) porque está comprimido y permite restaurar tablas sueltas con &lt;code&gt;pg_restore&lt;/code&gt;. Junto al &lt;code&gt;.dump&lt;/code&gt; se guarda un &lt;strong&gt;manifiesto JSON&lt;/strong&gt; con la huella del respaldo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"guardian_20261016T060304Z.dump"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"logical-full"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"server_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"17.11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"size_bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7757&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sha256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9213e404c8ddf8f8..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"row_counts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"backup_log"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"books"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"loans"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4.2 Verificar = restaurar de verdad
&lt;/h3&gt;

&lt;p&gt;La verificación tiene tres pasos:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Integridad:&lt;/strong&gt; se recalcula el SHA-256 y se compara con el manifiesto (detecta archivos corruptos o alterados).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restauración real:&lt;/strong&gt; se crea una base temporal &lt;code&gt;restore_check_&amp;lt;fecha&amp;gt;&lt;/code&gt; y se ejecuta &lt;code&gt;pg_restore --exit-on-error&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparación:&lt;/strong&gt; se cuentan las filas de cada tabla restaurada y se comparan con &lt;code&gt;row_counts&lt;/code&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Integridad OK (SHA-256 coincide)
  backup_log           esperado=0        restaurado=0
  books                esperado=5        restaurado=5
  loans                esperado=2        restaurado=2
Restauración verificada en 'restore_check_20261016060305': 3 tablas coinciden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si algo no coincide, el script termina con error y &lt;strong&gt;el pipeline falla en rojo&lt;/strong&gt;: me entero el mismo día, no cuando ya necesito el backup.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.3 Retención
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;prune --keep 7&lt;/code&gt; conserva los N respaldos más recientes. En producción la retención la aplica GitHub (&lt;code&gt;retention-days: 30&lt;/code&gt;), pero el script sirve para copias locales.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. El workflow de respaldo diario
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;   &lt;span class="c1"&gt;# 06:00 UTC = 01:00 hora de Perú&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;restore-target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# PostgreSQL desechable para probar la restauración&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1. Crear respaldo completo&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python scripts/backup.py create --out-dir backups --github-output&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2. Verificar restauración&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python scripts/backup.py verify --file "$DUMP_FILE" --admin-url postgresql://postgres:postgres@localhost:5432/postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3. Cifrar con AES-256&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpg --batch --pinentry-mode loopback --passphrase "$PASSPHRASE" --symmetric --cipher-algo AES256 "$DUMP_FILE"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4. Guardar copia externa (artefacto, 30 días)&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v5&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5. Registrar en backup_log&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python scripts/backup.py log --status ok --verified ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Versión simplificada: &lt;code&gt;$DUMP_FILE&lt;/code&gt; es la ruta que el paso 1 exporta como output. El archivo completo está en &lt;code&gt;.github/workflows/backup.yml&lt;/code&gt; del repositorio.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tres detalles importantes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cifrado obligatorio.&lt;/strong&gt; El repositorio es público; un dump sin cifrar sería una filtración de datos. La contraseña vive en &lt;code&gt;secrets.BACKUP_PASSPHRASE&lt;/code&gt;, nunca en el código.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versión del cliente = versión del servidor.&lt;/strong&gt; &lt;code&gt;pg_dump&lt;/code&gt; 16 no puede respaldar un servidor 17, así que el workflow instala &lt;code&gt;postgresql-client-17&lt;/code&gt; desde el repositorio oficial PGDG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los fallos también se registran.&lt;/strong&gt; Un paso con &lt;code&gt;if: failure()&lt;/code&gt; escribe &lt;code&gt;status = failed&lt;/code&gt; en &lt;code&gt;backup_log&lt;/code&gt;, y el panel de la app lo muestra en rojo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Despliegue automatizado (CI/CD)
&lt;/h2&gt;

&lt;p&gt;El workflow &lt;code&gt;ci-cd.yml&lt;/code&gt; se ejecuta en cada &lt;code&gt;push&lt;/code&gt; a &lt;code&gt;main&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;test:&lt;/strong&gt; levanta un PostgreSQL 17 como &lt;em&gt;service container&lt;/em&gt; y ejecuta 15 pruebas con &lt;code&gt;pytest&lt;/code&gt;: la API, un backup que se restaura de verdad, un archivo alterado que la verificación debe detectar y el ciclo completo punto → desastre → restauración para los tres escenarios de desastre.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;deploy:&lt;/strong&gt; solo si las pruebas pasan, llama al &lt;em&gt;Deploy Hook&lt;/em&gt; de Render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;smoke test:&lt;/strong&gt; consulta &lt;code&gt;/health&lt;/code&gt; de la URL pública hasta que responde con el &lt;strong&gt;SHA del commit recién desplegado&lt;/strong&gt; y &lt;code&gt;"database": "up"&lt;/code&gt;. Así el pipeline confirma que lo publicado es exactamente lo que se probó.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 40&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP_URL&lt;/span&gt;&lt;span class="s2"&gt;/health"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Deploy verificado"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;20
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En Render, &lt;code&gt;autoDeploy: false&lt;/code&gt; evita que se publique código que no pasó las pruebas: la única puerta de entrada a producción es el pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5arkv8098yzvnnueemi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5arkv8098yzvnnueemi.png" alt="Historial de GitHub Actions: ejecuciones de CI/CD y del respaldo diario" width="799" height="491"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;El historial también es honesto: la primera ejecución del respaldo falló (un carácter invisible se había colado en el secreto de la conexión), quedó registrada en rojo y la siguiente, ya corregida, pasó en verde.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Laboratorio de recuperación: provocar un desastre a propósito
&lt;/h2&gt;

&lt;p&gt;Explicar que un backup funciona no es lo mismo que &lt;strong&gt;demostrarlo&lt;/strong&gt;. Por eso la página principal de la app tiene un laboratorio que cualquiera puede ver funcionar contra la base de producción:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;⚡ Crear punto de restauración.&lt;/strong&gt; En una transacción &lt;code&gt;REPEATABLE READ&lt;/code&gt; (instantánea consistente) se copia cada tabla con &lt;code&gt;COPY ... TO STDOUT&lt;/code&gt; a CSV, se empaqueta en un ZIP y se guarda la huella: filas y &lt;strong&gt;md5 del contenido&lt;/strong&gt; por tabla, más el SHA-256 del archivo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;✅ Verificar.&lt;/strong&gt; Se restaura en un esquema temporal &lt;strong&gt;dentro de una transacción que siempre se revierte&lt;/strong&gt; (&lt;code&gt;force_rollback&lt;/code&gt;): producción ni se entera, y se comparan filas y md5 tabla por tabla.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;💥 Simular desastre.&lt;/strong&gt; Tres escenarios reales: &lt;code&gt;DELETE&lt;/code&gt; sin &lt;code&gt;WHERE&lt;/code&gt;, un script que corrompe datos y un &lt;code&gt;DROP TABLE&lt;/code&gt;. El panel detecta al instante que los datos ya no coinciden con el último punto y se pone en rojo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;♻️ Restaurar.&lt;/strong&gt; &lt;code&gt;TRUNCATE&lt;/code&gt; + &lt;code&gt;COPY FROM&lt;/code&gt; + reajuste de secuencias en &lt;strong&gt;una sola transacción&lt;/strong&gt;: si la huella final no coincide, se revierte todo. En producción (Render + Neon) la base vuelve a estar sana en unos 3 segundos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Así se ve justo después de provocar el desastre: el aviso se pone en rojo y la consola narra cada paso.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fapeze50ctt04l33unzxa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fapeze50ctt04l33unzxa.png" alt="Laboratorio tras simular un DELETE sin WHERE: libros y préstamos quedan en 0 y el panel lo detecta" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Y unos segundos después de pulsar &lt;strong&gt;Restaurar&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foy0jlayc1qxmcgawdwob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foy0jlayc1qxmcgawdwob.png" alt="Laboratorio tras restaurar: las tablas vuelven a 5 y 2 filas con la huella md5 verificada" width="800" height="556"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TRUNCATE books, loans RESTART IDENTITY CASCADE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# COPY FROM de cada CSV
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tables&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;IntegrityError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;La restauración no coincide; se revirtió&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Las acciones destructivas están protegidas con un &lt;strong&gt;PIN de administrador&lt;/strong&gt; (con bloqueo tras 5 intentos fallidos), porque la app es pública. Este punto de restauración es una capa &lt;strong&gt;rápida&lt;/strong&gt; que vive en el mismo servidor; no reemplaza la copia externa cifrada del paso anterior, y la propia app lo dice en su tabla de "qué NO protege".&lt;/p&gt;

&lt;p&gt;Cada acción queda registrada en una línea de tiempo, y cada punto de restauración se puede descargar como ZIP (CSV + manifiesto):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxrdujpetx5olobhvfi9g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxrdujpetx5olobhvfi9g.png" alt="Puntos de restauración verificados y línea de tiempo del laboratorio, en modo oscuro" width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  8. ¿Y si hay que restaurar producción?
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Descargar el artefacto pg-backup-N desde GitHub Actions&lt;/span&gt;
gpg &lt;span class="nt"&gt;--decrypt&lt;/span&gt; guardian_20261016T060000Z.dump.gpg &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; guardian.dump
&lt;span class="c"&gt;# 2. Restaurar (en una base nueva o sobre la existente)&lt;/span&gt;
pg_restore &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--clean&lt;/span&gt; &lt;span class="nt"&gt;--if-exists&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; guardian.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Para errores recientes (un &lt;code&gt;DELETE&lt;/code&gt; accidental de hace 10 minutos), es más rápido usar el &lt;strong&gt;PITR del proveedor&lt;/strong&gt;: crear una &lt;em&gt;branch&lt;/em&gt; de Neon en el instante anterior al error. El dump diario es el seguro para cuando el proveedor completo falla.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Lecciones aprendidas
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Automatiza la restauración, no solo el respaldo.&lt;/strong&gt; Es lo que convierte un archivo en una garantía.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guarda al menos una copia fuera del proveedor&lt;/strong&gt;, y &lt;strong&gt;cífrala&lt;/strong&gt; si sale de tu infraestructura.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usa la misma versión de herramientas que el servidor.&lt;/strong&gt; Es el error más común con &lt;code&gt;pg_dump&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Combina estrategias.&lt;/strong&gt; El PITR da un RPO de segundos; el dump lógico da portabilidad e independencia del proveedor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haz visible el estado.&lt;/strong&gt; Una tabla &lt;code&gt;backup_log&lt;/code&gt; y un panel convierten "creo que hay backups" en evidencia.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Enlaces
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;strong&gt;Repositorio:&lt;/strong&gt; &lt;a href="https://github.com/AdrianSalasEPIS/pg-backup-guardian" rel="noopener noreferrer"&gt;https://github.com/AdrianSalasEPIS/pg-backup-guardian&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🌐 &lt;strong&gt;App en la nube:&lt;/strong&gt; &lt;a href="https://pg-backup-guardian.onrender.com" rel="noopener noreferrer"&gt;https://pg-backup-guardian.onrender.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🎥 &lt;strong&gt;Video:&lt;/strong&gt; &lt;a href="https://youtu.be/hWiH6ANFUmQ" rel="noopener noreferrer"&gt;https://youtu.be/hWiH6ANFUmQ&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📖 Artículo complementario de mi compañera Keyla López: &lt;em&gt;Completo, incremental y PITR en PostgreSQL 17&lt;/em&gt; — &lt;a href="https://dev.to/keylalopezz/completo-incremental-y-pitr-en-postgresql-17-guia-practica-con-restauracion-verificada-37pl"&gt;https://dev.to/keylalopezz/completo-incremental-y-pitr-en-postgresql-17-guia-practica-con-restauracion-verificada-37pl&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Referencias
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL Global Development Group. (2024). &lt;em&gt;PostgreSQL 17 documentation: Backup and restore&lt;/em&gt;. &lt;a href="https://www.postgresql.org/docs/17/backup.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/17/backup.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL Global Development Group. (2024). &lt;em&gt;pg_dump&lt;/em&gt;. &lt;a href="https://www.postgresql.org/docs/17/app-pgdump.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/17/app-pgdump.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub. (s. f.). &lt;em&gt;Workflow syntax for GitHub Actions&lt;/em&gt;. &lt;a href="https://docs.github.com/actions" rel="noopener noreferrer"&gt;https://docs.github.com/actions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Render. (s. f.). &lt;em&gt;Deploy hooks&lt;/em&gt;. &lt;a href="https://render.com/docs/deploy-hooks" rel="noopener noreferrer"&gt;https://render.com/docs/deploy-hooks&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Adrian Fabricio Salas Acuña — Estudiante de Ingeniería de Sistemas, Universidad Privada de Tacna. Proyecto del curso Base de Datos II.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>githubactions</category>
    </item>
  </channel>
</rss>
