PostgreSQL en la práctica: instalar, usuarios y permisos, conexiones seguras y respaldos

PostgreSQL es la base de datos relacional de código abierto más completa y una de las más usadas en servidores. Esta guía va de la instalación a dejarla lista para producción: quién puede entrar y desde dónde, qué puede hacer cada usuario, cómo saber si un índice sirve, qué pasa cuando dos personas cambian lo mismo a la vez y cómo hacer respaldos que de verdad se restauran. Cada error que vas a encontrar está provocado a propósito y explicado.

Todo lo que ves aquí se ejecutó de verdad. Montamos un laboratorio con un servidor Debian 13 y PostgreSQL 17.11, un cliente con psql y ssh, y la imagen oficial de Docker con PostgreSQL 18.6, en una red aislada. Las salidas son las que dieron esas máquinas. Usamos la base tienda del curso de SQL, así que puedes practicar con los mismos datos. Las contraseñas son de laboratorio: usa las tuyas, largas y únicas.

Qué es PostgreSQL y en qué se distingue de MySQL y SQLite

PostgreSQL guarda los datos en tablas relacionadas y se consulta con SQL, como MySQL o SQLite. Lo que lo distingue es que se toma muy en serio el estándar y la integridad de los datos: tipos estrictos, restricciones que se cumplen siempre, transacciones para todo (también para cambiar la estructura de las tablas) y extensiones para casi cualquier cosa, de datos geográficos con PostGIS a búsqueda de texto y vectores para IA. Lo desarrolla una comunidad, no una empresa, con licencia libre.

  • Frente a SQLite. SQLite es una biblioteca: la base es un archivo y no hay servidor, usuarios ni red. Es perfecta para una app de escritorio, un celular o un sitio pequeño con un solo proceso que escribe. Cuando varias personas o servicios escriben a la vez desde otras máquinas, necesitas un servidor como PostgreSQL.
  • Frente a MySQL. Los dos son servidores maduros y rápidos. MySQL es muy común en hospedaje compartido y con WordPress; PostgreSQL suele preferirse para aplicaciones con datos complejos, consultas pesadas o reglas estrictas, y tiene más funciones de SQL (las del curso de SQL funcionan todas). Para aprender, lo que sepas de uno te sirve en el otro.

Si todavía no sabes SQL, empieza por el curso de SQL; esta guía trata de administrar el servidor, no del lenguaje.

El laboratorio de esta guía

Para que puedas reproducirlo todo, estas son las piezas. El servidor (IP 172.30.7.10) es un Debian 13 con PostgreSQL instalado desde el paquete de Debian, con systemd y SSH; las órdenes las ejecutó ana, un usuario normal con sudo, como en un servidor en la nube. El cliente (172.30.7.50) es otra máquina con psql y ssh, con el usuario luis. Y pg18 es la imagen oficial de Docker. Ninguna tiene salida a internet.

Instalarlo: en Debian o con Docker

Hay dos caminos sencillos. En Debian o Ubuntu, el paquete de la distribución instala la versión que esa distribución eligió (en Debian 13, la 17), la arranca y la deja configurada para iniciar con el sistema:

sudo apt update
sudo apt install postgresql
psql --version; pg_lsclusters
salidapsql (PostgreSQL) 17.11 (Debian 17.11-0+deb13u1)
Ver Cluster Port Status Owner    Data directory              Log file
17  main    5432 online postgres /var/lib/postgresql/17/main /var/log/postgresql/postgresql-17-main.log

pg_lsclusters es una herramienta propia de Debian que lista los servidores instalados: versión, nombre, puerto, si está en marcha, dónde están los datos y el registro. Es un servicio de systemd más:

systemctl status postgresql@17-main --no-pager | head -12
salida● postgresql@17-main.service - PostgreSQL Cluster 17-main
     Loaded: loaded (/usr/lib/systemd/system/postgresql@.service; enabled-runtime; preset: enabled)
     Active: active (running) since Sat 2026-11-07 17:53:11 UTC; 14s ago
 Invocation: 929783fb0bca4c34862f4bd405696a58
    Process: 76 ExecStart=/usr/bin/pg_ctlcluster --skip-systemctl-redirect 17-main start (code=exited, status=0/SUCCESS)
   Main PID: 96 (postgres)
      Tasks: 6 (limit: 150)
     Memory: 36.5M (peak: 36.5M)
        CPU: 183ms
     CGroup: /system.slice/system-postgresql.slice/postgresql@17-main.service
             ├─ 96 /usr/lib/postgresql/17/bin/postgres -D /var/lib/postgresql/17/main -c config_file=/etc/postgresql/17/main/postgresql.conf
             ├─ 97 "postgres: 17/main: checkpointer "

Si necesitas la versión más reciente en Debian o Ubuntu, el proyecto mantiene su propio repositorio (apt.postgresql.org), que se instala igual y convive con el de la distribución. Cada versión mayor recibe correcciones durante cinco años; la 18 es la estable actual y la 19 tiene prevista su salida el 19 de noviembre de 2026, como contamos en esta noticia.

Con Docker, la imagen oficial postgres trae la última versión de cada rama. Así se arranca de forma razonable:

docker run -d --name pg18 \
  -e POSTGRES_PASSWORD='una-contraseña-larga' \
  -v pgdatos:/var/lib/postgresql \
  -p 127.0.0.1:5432:5432 \
  postgres:18

Tres detalles importan. La contraseña de POSTGRES_PASSWORD es la del superusuario postgres. El volumen pgdatos guarda los datos fuera del contenedor, para que no se pierdan al borrarlo; desde la versión 18 la imagen usa /var/lib/postgresql como punto de montaje y guarda los datos en una subcarpeta por versión. Y -p 127.0.0.1:5432:5432 publica el puerto solo para el propio equipo: un -p 5432:5432 a secas lo abre a toda la red, y Docker se salta el firewall del sistema para hacerlo. En el laboratorio:

psql -U postgres -c "SELECT version();"; echo; ls /var/lib/postgresql/
salida                                                      version                                                       
--------------------------------------------------------------------------------------------------------------------
 PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
(1 row)

18
psql -U postgres -Atc 'SHOW data_directory'; grep -v '^#' $(psql -U postgres -Atc 'SHOW hba_file') | grep -v '^\s*$' | tail -3
salida/var/lib/postgresql/18/docker
host    replication     all             127.0.0.1/32            trust
host    replication     all             ::1/128                 trust
host all all all scram-sha-256
docker logs pg18 | tail -4
salida	
	
2026-11-07 17:56:14.127 UTC [73] LOG:  checkpoint starting: immediate force wait
2026-11-07 17:56:14.353 UTC [73] LOG:  checkpoint complete: wrote 2516 buffers (15.4%), wrote 1 SLRU buffers; 0 WAL file(s) added, 0 removed, 4 recycled; write=0.144 s, sync=0.007 s, total=0.226 s; sync files=14, longest=0.003 s, average=0.001 s; distance=67488 kB, estimate=85053 kB; lsn=0/B2DB4F0, redo lsn=0/B2DB498

Fíjate en la última línea de pg_hba.conf de la imagen: host all all all scram-sha-256, es decir, cualquiera desde cualquier dirección con contraseña, y escucha en 0.0.0.0. Es cómodo dentro de una red de contenedores, pero por eso importa tanto no publicar el puerto hacia fuera. Si los contenedores te suenan nuevos, la guía de Docker explica volúmenes y redes.

Clúster, bases de datos y esquemas

Un servidor de PostgreSQL se llama clúster: un proceso, un puerto y una carpeta de datos. No tiene que ver con varias máquinas; es solo el nombre. Dentro de un clúster hay varias bases de datos, y cada conexión entra a una sola: no se puede consultar una tabla de otra base en la misma sentencia. Dentro de cada base, las tablas viven en esquemas, que son como carpetas; si no creas ninguno, todo va a public. Los roles (usuarios y grupos) son del clúster entero.

Clúster, bases, esquemas y tablas Clúster 17/main un servidor, un puerto (5432), una carpeta Roles ana, reportes, app_tienda: valen en todas Base de datos tienda cada conexión entra a una sola base Esquema public un espacio de nombres dentro de la base clientes productos pedidos visitas se nombran public.clientes o clientes Otros esquemas, si los creas p. ej. contabilidad.facturas

Esquema: decodigo.com.

Por dentro, el servidor es un proceso principal y varios de servicio: el que escribe el diario de cambios (WAL), el que hace limpieza (autovacuum) y el que guarda los datos en disco cada cierto tiempo. Cada conexión abre un proceso más:

ps -u postgres -o pid,cmd --forest
salida    PID CMD
     96 /usr/lib/postgresql/17/bin/postgres -D /var/lib/postgresql/17/main -c config_file=/etc/postgresql/17/main/postgresql.conf
     97  \_ postgres: 17/main: checkpointer 
     98  \_ postgres: 17/main: background writer 
    100  \_ postgres: 17/main: walwriter 
    101  \_ postgres: 17/main: autovacuum launcher 
    102  \_ postgres: 17/main: logical replication launcher 

En Debian la configuración está en /etc/postgresql/17/main/ y los datos en /var/lib/postgresql/17/main/. Los dos archivos que vas a tocar son postgresql.conf (cómo funciona el servidor) y pg_hba.conf (quién puede conectarse); además, todo lo que pongas en conf.d/ se lee al final de postgresql.conf, así que es el mejor lugar para tus cambios:

ls /etc/postgresql/17/main/; echo; sudo ls /var/lib/postgresql/17/main/
salidaconf.d
environment
pg_ctl.conf
pg_hba.conf
pg_ident.conf
postgresql.conf
start.conf

PG_VERSION
base
global
pg_commit_ts
pg_dynshmem
pg_logical
pg_multixact
pg_notify
pg_replslot
pg_serial
pg_snapshots
pg_stat
pg_stat_tmp
pg_subtrans
pg_tblspc
pg_twophase
pg_wal
pg_xact
postgresql.auto.conf
postmaster.opts
postmaster.pid

Entrar por primera vez y crear la base tienda

La instalación crea un usuario del sistema y un rol de PostgreSQL, los dos llamados postgres, que es el superusuario. Si intentas entrar con él desde tu usuario, falla:

psql -U postgres
salidapsql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL:  Peer authentication failed for user "postgres"

No es una contraseña equivocada: no hay contraseña. Las conexiones locales, por el socket de Unix, usan el método peer: PostgreSQL le pregunta al sistema operativo quién es el usuario que se conecta y solo lo deja entrar con el rol del mismo nombre. ana no es postgres, así que no pasa. La forma correcta es ejecutar psql como el usuario del sistema postgres:

sudo -u postgres psql -c 'SELECT version();'
salida                                                       version                                                       
---------------------------------------------------------------------------------------------------------------------
 PostgreSQL 17.11 (Debian 17.11-0+deb13u1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
(1 row)

Y si ana ejecuta psql sin más, intenta entrar con el rol ana, que todavía no existe:

psql
salidapsql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL:  role "ana" does not exist

Lo práctico es crear un rol con tu nombre de usuario que pueda crear bases, pero que no sea superusuario, y trabajar con él. Así no necesitas sudo para el día a día y no tienes todo el poder a cada momento:

sudo -u postgres createuser --createdb ana && psql -d postgres -c '\du'
salida                             List of roles
 Role name |                         Attributes                         
-----------+------------------------------------------------------------
 ana       | Create DB
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS

Ahora ana crea la base tienda, que queda a su nombre, y carga el archivo del curso de SQL:

dropdb tienda && createdb tienda && psql -d tienda -f tienda.sql | sort | uniq -c
salida      6 CREATE TABLE
      1 INSERT 0 12
      1 INSERT 0 15
      1 INSERT 0 22
      1 INSERT 0 41
      1 INSERT 0 5
      1 INSERT 0 8

psql y sus metacomandos

psql es la consola de PostgreSQL. Además de SQL, entiende metacomandos, que empiezan con una barra invertida y no llevan punto y coma. Estos son los que más vas a usar: \l lista las bases, \c cambia de base, \dt lista las tablas, \d describe una, \du lista los roles, \x muestra los resultados en vertical, \timing mide cuánto tarda cada consulta y \q sale.

psql -d tienda -c '\l tienda'
salida                                            List of databases
  Name  | Owner | Encoding | Locale Provider | Collate |  Ctype  | Locale | ICU Rules | Access privileges 
--------+-------+----------+-----------------+---------+---------+--------+-----------+-------------------
 tienda | ana   | UTF8     | libc            | C.UTF-8 | C.UTF-8 |        |           | 
(1 row)
psql -d tienda -c '\dt'
salida          List of relations
 Schema |    Name    | Type  | Owner 
--------+------------+-------+-------
 public | categorias | table | ana
 public | clientes   | table | ana
 public | detalle    | table | ana
 public | empleados  | table | ana
 public | pedidos    | table | ana
 public | productos  | table | ana
(6 rows)
psql -d tienda -c '\d productos'
salida                       Table "public.productos"
    Column    |         Type          | Collation | Nullable | Default 
--------------+-----------------------+-----------+----------+---------
 id           | integer               |           | not null | 
 nombre       | character varying(60) |           | not null | 
 categoria_id | integer               |           |          | 
 precio       | numeric(10,2)         |           | not null | 
 stock        | integer               |           | not null | 0
Indexes:
    "productos_pkey" PRIMARY KEY, btree (id)
Check constraints:
    "productos_precio_check" CHECK (precio > 0::numeric)
Foreign-key constraints:
    "productos_categoria_id_fkey" FOREIGN KEY (categoria_id) REFERENCES categorias(id)
Referenced by:
    TABLE "detalle" CONSTRAINT "detalle_producto_id_fkey" FOREIGN KEY (producto_id) REFERENCES productos(id)

\d enseña mucho más que las columnas: la clave primaria, las restricciones y quién depende de la tabla. Cuando una fila tiene muchas columnas, \x la muestra en vertical:

psql -d tienda -c '\x on' -c 'SELECT * FROM clientes WHERE id = 3;'
salidaExpanded display is on.
-[ RECORD 1 ]----------------
id     | 3
nombre | Camila Rojas
ciudad | Bogotá
pais   | Colombia
email  | camila.r@ejemplo.com
alta   | 2025-02-27

En la consola interactiva, \? lista todos los metacomandos y \h seguido de una orden de SQL (por ejemplo \h CREATE INDEX) muestra su sintaxis. Con -c, como en esta guía, psql ejecuta una orden y sale.

Roles y contraseñas

En PostgreSQL no hay «usuarios» y «grupos» por separado: todo son roles. Un rol con el atributo LOGIN es lo que llamaríamos un usuario. Creamos dos: reportes, que solo leerá, y app_tienda, que usará la aplicación:

sudo -u postgres psql -c "CREATE ROLE reportes LOGIN PASSWORD 'Lectura-lab-2026';" \
                      -c "CREATE ROLE app_tienda LOGIN PASSWORD 'App-lab-2026';"
salidaCREATE ROLE
CREATE ROLE

La contraseña no se guarda tal cual. Desde la versión 14, PostgreSQL usa por defecto SCRAM-SHA-256: guarda una huella derivada con sal y 4,096 rondas, y en el inicio de sesión ni siquiera viaja la contraseña, sino una prueba de que la conoces. Esto es lo que queda guardado (recortado):

sudo -u postgres psql -Atc "SELECT rolname, left(rolpassword, 35) || '…' FROM pg_authid WHERE rolname IN ('reportes','app_tienda') ORDER BY 1"
salidaapp_tienda|SCRAM-SHA-256$4096:cq7NxT+XnMiFwOJe…
reportes|SCRAM-SHA-256$4096:si1bvfGkhuGx/+RE…

Escribir la contraseña en un CREATE ROLE tiene un problema: la orden puede quedar en el historial de psql o en los registros del servidor. En la consola interactiva usa \password reportes, que la pide sin mostrarla y la envía ya convertida. Si alguna vez ves el método md5 en pg_hba.conf o contraseñas guardadas así, son de versiones antiguas y conviene migrarlas.

Si la contraseña no es la correcta:

PGPASSWORD=otra-cosa psql -h localhost -U reportes -d tienda -c 'SELECT 1'
salidapsql: error: connection to server at "localhost" (::1), port 5432 failed: FATAL:  password authentication failed for user "reportes"
connection to server at "localhost" (::1), port 5432 failed: FATAL:  password authentication failed for user "reportes"

El mensaje sale dos veces porque psql primero intenta conectarse cifrado con SSL y, si falla, lo reintenta sin cifrar. No es que lo hayas intentado dos veces.

Permisos con el mínimo privilegio

El principio de mínimo privilegio dice que cada rol debe poder hacer lo que necesita y nada más. Si la aplicación se conecta como superusuario o como dueña de las tablas y alguien encuentra una inyección de SQL en ella, puede borrarlo todo. Con un rol limitado, el daño se queda en lo que ese rol puede tocar.

Un rol nuevo no puede leer nada que no sea suyo:

PGPASSWORD=Lectura-lab-2026 psql -h localhost -U reportes -d tienda -c 'SELECT count(*) FROM clientes'
salidaERROR:  permission denied for table clientes

Le damos permiso de lectura sobre todas las tablas que hay ahora:

psql -d tienda -c 'GRANT SELECT ON ALL TABLES IN SCHEMA public TO reportes;'
salidaGRANT
PGPASSWORD=Lectura-lab-2026 psql -h localhost -U reportes -d tienda -c 'SELECT count(*) FROM clientes' -c "UPDATE productos SET precio = 1 WHERE id = 1"
salida count 
-------
    12
(1 row)

ERROR:  permission denied for table productos

Lee, pero no puede cambiar nada: justo lo que queremos para un rol de reportes. Para la aplicación vamos más fino: cerramos la base a cualquiera que no tenga permiso expreso, y le damos a app_tienda solo lo que usa, incluso por columna:

-- la base deja de estar abierta a cualquier rol con LOGIN
REVOKE CONNECT ON DATABASE tienda FROM PUBLIC;
GRANT CONNECT ON DATABASE tienda TO reportes, app_tienda;

-- la app lee el catálogo, registra pedidos y solo puede cambiar el stock
GRANT SELECT ON productos, categorias TO app_tienda;
GRANT SELECT, INSERT ON pedidos, detalle TO app_tienda;
GRANT UPDATE (stock) ON productos TO app_tienda;
export PGPASSWORD=App-lab-2026
psql -h localhost -U app_tienda -d tienda \
  -c 'UPDATE productos SET stock = stock - 1 WHERE id = 2' \
  -c 'UPDATE productos SET precio = 1 WHERE id = 2' \
  -c 'DELETE FROM pedidos WHERE id = 1'
salidaUPDATE 1
ERROR:  permission denied for table productos
ERROR:  permission denied for table pedidos

Puede bajar el stock, pero no cambiar precios ni borrar pedidos. Y un rol cualquiera que no está en la lista ya no puede ni entrar a la base, porque quitamos el CONNECT que por defecto tiene PUBLIC (todos los roles):

psql -h localhost -U curioso -d tienda -c 'SELECT 1'
salidapsql: error: connection to server at "localhost" (::1), port 5432 failed: FATAL:  permission denied for database "tienda"
DETAIL:  User does not have CONNECT privilege.

\dp muestra los permisos de una tabla. Cada letra es uno: r leer (SELECT), a añadir (INSERT), w escribir (UPDATE), d borrar (DELETE), y lo que va después de la barra es quién lo concedió:

psql -d tienda -c '\dp productos'
salida                               Access privileges
 Schema |   Name    | Type  | Access privileges | Column privileges  | Policies 
--------+-----------+-------+-------------------+--------------------+----------
 public | productos | table | ana=arwdDxtm/ana +| stock:            +| 
        |           |       | reportes=r/ana   +|   app_tienda=w/ana | 
        |           |       | app_tienda=r/ana  |                    | 
(1 row)

Cuatro puertas para leer una tabla 1. Entrar a la base GRANT CONNECT ON DATABASE si falta: permission denied for database 2. Pasar al esquema GRANT USAGE ON SCHEMA si falta: permission denied for schema 3. Tocar la tabla GRANT SELECT, INSERT… ON tabla si falta: permission denied for table 4. Una columna (opcional) GRANT UPDATE (stock) ON tabla si falta: permission denied for table El dueño de la tabla y el superusuario pasan sin preguntar: por eso la app no debe conectarse con ninguno de los dos.

Esquema: decodigo.com.

Las tablas nuevas y el esquema public

Dos sorpresas que se encuentra casi todo el mundo. La primera: GRANT ... ON ALL TABLES solo vale para las tablas que existen en ese momento. Si después creas una, el rol de reportes no la puede leer:

psql -d tienda -c 'CREATE TABLE cupones (codigo text PRIMARY KEY, descuento int)' \
               -c "INSERT INTO cupones VALUES ('BUENFIN', 15)"
psql -h localhost -U reportes -d tienda -c 'SELECT * FROM cupones'
salidaCREATE TABLE
INSERT 0 1
ERROR:  permission denied for table cupones

La solución son los privilegios por defecto: le dices a PostgreSQL que las tablas que cree ana en ese esquema nazcan ya con permiso de lectura para reportes. Ojo, que tampoco son retroactivos: la tabla que ya creaste necesita su propio GRANT:

psql -d tienda -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO reportes;' \
               -c 'GRANT SELECT ON cupones TO reportes;' \
               -c 'CREATE TABLE envios (pedido_id int, guia text)'
psql -h localhost -U reportes -d tienda -c 'SELECT count(*) FROM cupones' -c 'SELECT count(*) FROM envios'
salidaALTER DEFAULT PRIVILEGES
GRANT
CREATE TABLE
 count 
-------
     1
(1 row)

 count 
-------
     0
(1 row)
psql -d tienda -c '\ddp'
salida         Default access privileges
 Owner | Schema | Type  | Access privileges 
-------+--------+-------+-------------------
 ana   | public | table | reportes=r/ana
(1 row)

Los privilegios por defecto son por el rol que crea las tablas: si las crea otro rol (por ejemplo, el que ejecuta las migraciones de tu app), hay que declararlos con ALTER DEFAULT PRIVILEGES FOR ROLE ese_rol ....

La segunda sorpresa es el esquema public. Hasta PostgreSQL 14, cualquier rol podía crear tablas en él; desde la 15, solo su dueño, que es el dueño de la base:

psql -h localhost -U reportes -d tienda -c 'CREATE TABLE notas (t text)'
salidaERROR:  permission denied for schema public
LINE 1: CREATE TABLE notas (t text)
                     ^

Es un cambio de seguridad: evita que un rol con pocos permisos cree objetos con el mismo nombre que otros para engañar a quien los use. Si tu aplicación necesita crear tablas, dale a su rol CREATE en un esquema propio, en lugar de reabrir public.

Conexiones por red: postgresql.conf y pg_hba.conf

Recién instalado, PostgreSQL solo acepta conexiones del propio servidor. Desde el cliente:

psql -h servidor -U reportes -d tienda -c 'SELECT 1'
salidapsql: error: connection to server at "servidor" (172.30.7.10), port 5432 failed: Connection refused
	Is the server running on that host and accepting TCP/IP connections?

Connection refused quiere decir que nadie escucha en esa dirección y puerto. Lo controla listen_addresses en postgresql.conf, que por defecto vale localhost:

sudo -u postgres psql -Atc "SHOW listen_addresses"; sudo -u postgres psql -Atc 'SHOW port'; sudo -u postgres psql -Atc 'SHOW password_encryption'; ss -ltn | grep 5432
salidalocalhost
5432
scram-sha-256
LISTEN 0      200        127.0.0.1:5432       0.0.0.0:*   
LISTEN 0      200            [::1]:5432          [::]:*   

La pondremos en un archivo propio dentro de conf.d/, para no editar el original. Este ajuste necesita reiniciar el servidor; la mayoría de los demás basta con recargarlos. Le decimos que escuche también en su IP de la red interna, no en todas ('*'):

echo "listen_addresses = 'localhost, 172.30.7.10'" | sudo tee /etc/postgresql/17/main/conf.d/red.conf
sudo systemctl restart postgresql@17-main
ss -ltn | grep 5432
salida840:include_dir = 'conf.d'			# include files ending in '.conf' from
listen_addresses = 'localhost, 172.30.7.10'
LISTEN 0      200        127.0.0.1:5432       0.0.0.0:*   
LISTEN 0      200      172.30.7.10:5432       0.0.0.0:*   
LISTEN 0      200            [::1]:5432          [::]:*   

Ya escucha, pero todavía no deja pasar:

psql -h servidor -U reportes -d tienda -c 'SELECT 1'
salidapsql: error: connection to server at "servidor" (172.30.7.10), port 5432 failed: FATAL:  no pg_hba.conf entry for host "172.30.7.50", user "reportes", database "tienda", SSL encryption
connection to server at "servidor" (172.30.7.10), port 5432 failed: FATAL:  no pg_hba.conf entry for host "172.30.7.50", user "reportes", database "tienda", no encryption

Ahora el portero es pg_hba.conf (host-based authentication). Cada línea dice: tipo de conexión, base, rol, dirección de origen y método. PostgreSQL las lee de arriba abajo y usa la primera que coincide; si ninguna coincide, rechaza. Estas son las de Debian:

sudo grep -v '^#' /etc/postgresql/17/main/pg_hba.conf | grep -v '^\s*$'
salidalocal   all             postgres                                peer
local   all             all                                     peer
host    all             all             127.0.0.1/32            scram-sha-256
host    all             all             ::1/128                 scram-sha-256
local   replication     all                                     peer
host    replication     all             127.0.0.1/32            scram-sha-256
host    replication     all             ::1/128                 scram-sha-256

local es el socket del propio servidor (con peer); host, cualquier conexión TCP; hostssl, solo las cifradas. Añadimos una línea lo más estrecha posible: solo la base tienda, solo el rol reportes, solo desde la IP del cliente, cifrada y con contraseña. Los cambios de pg_hba.conf se aplican con una recarga, sin cortar a nadie, y la vista pg_hba_file_rules enseña cómo los entendió el servidor (la columna error avisa si una línea está mal escrita):

hostssl  tienda  reportes  172.30.7.50/32  scram-sha-256
echo 'hostssl  tienda  reportes  172.30.7.50/32  scram-sha-256' | sudo tee -a /etc/postgresql/17/main/pg_hba.conf
sudo -u postgres psql -c 'SELECT pg_reload_conf()'
sudo -u postgres psql -c 'SELECT line_number, type, database, user_name, address, auth_method, error FROM pg_hba_file_rules ...'
salidahostssl tienda  reportes  172.30.7.50/32  scram-sha-256
t
 line_number |  type   |   database    | user_name  |   address   |  auth_method  | error 
-------------+---------+---------------+------------+-------------+---------------+-------
         123 | local   | {all}         | {all}      |             | peer          | 
         125 | host    | {all}         | {all}      | 127.0.0.1   | scram-sha-256 | 
         127 | host    | {all}         | {all}      | ::1         | scram-sha-256 | 
         130 | local   | {replication} | {all}      |             | peer          | 
         131 | host    | {replication} | {all}      | 127.0.0.1   | scram-sha-256 | 
         132 | host    | {replication} | {all}      | ::1         | scram-sha-256 | 
         133 | hostssl | {tienda}      | {reportes} | 172.30.7.50 | scram-sha-256 | 
(7 rows)
psql -h servidor -U reportes -d tienda -c '\conninfo' -c 'SELECT count(*) FROM productos'
salidaYou are connected to database "tienda" as user "reportes" on host "servidor" (address "172.30.7.10") at port "5432".
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off, ALPN: postgresql)
 count 
-------
    15
(1 row)

Entró, y \conninfo confirma que la conexión va cifrada con TLS 1.3 (el paquete de Debian trae un certificado propio de fábrica; en producción conviene uno tuyo). Si el cliente pide ir sin cifrar, la línea hostssl no coincide y se queda fuera:

psql 'host=servidor user=reportes dbname=tienda sslmode=disable' -c 'SELECT 1'
salidapsql: error: connection to server at "servidor" (172.30.7.10), port 5432 failed: FATAL:  no pg_hba.conf entry for host "172.30.7.50", user "reportes", database "tienda", no encryption

El camino de una conexión psql -h servidor ¿Escucha en esa dirección? listen_addresses (postgresql.conf) si no: Connection refused ¿Hay una línea que coincida? pg_hba.conf, de arriba abajo si no: no pg_hba.conf entry for host ¿Pasa el método de esa línea? scram-sha-256: la contraseña si no: password authentication failed ¿Puede entrar a esa base? GRANT CONNECT ON DATABASE si no: permission denied for database Conectado

Esquema: decodigo.com.

Si en vez de una IP pones 0.0.0.0/0 y en vez de un rol pones all, cualquiera en internet puede intentar adivinar contraseñas de cualquier rol. Las líneas de pg_hba.conf deben ser tan concretas como puedas.

No abras el 5432: entra por un túnel SSH

Aunque sepas configurar pg_hba.conf, lo más seguro es que el puerto 5432 no sea alcanzable desde internet. Hay robots que recorren todas las direcciones buscando servidores de PostgreSQL para probar contraseñas o aprovechar fallos, igual que con SSH. Si necesitas entrar desde tu computadora, la forma limpia es un túnel SSH: PostgreSQL sigue escuchando solo en localhost, y tú llegas a través de la conexión SSH que ya usas para administrar el servidor.

Primero dejamos el servidor como estaba, solo en local:

sudo rm /etc/postgresql/17/main/conf.d/red.conf
sudo systemctl restart postgresql@17-main
ss -ltn | grep 5432
salidaLISTEN 0      200        127.0.0.1:5432       0.0.0.0:*   
LISTEN 0      200            [::1]:5432          [::]:*   

Y desde el cliente abrimos el túnel: el puerto 5433 de la máquina de luis lleva, por dentro de SSH, al 5432 del servidor. Para PostgreSQL la conexión llega desde el propio servidor, así que la atienden las líneas de localhost:

ssh -f -N -L 5433:localhost:5432 ana@servidor
ss -ltn | grep 5433
psql -h localhost -p 5433 -U reportes -d tienda -c '\conninfo' -c 'SELECT nombre, precio FROM productos ORDER BY precio DESC LIMIT 3'
salidaLISTEN 0      128        127.0.0.1:5433       0.0.0.0:*   
LISTEN 0      128            [::1]:5433          [::]:*   
You are connected to database "tienda" as user "reportes" on host "localhost" (address "::1") at port "5433".
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off, ALPN: postgresql)
      nombre      | precio  
------------------+---------
 Portátil Nova 16 | 1849.00
 Portátil Aero 14 | 1299.99
 Monitor 27" 4K   |  429.90
(3 rows)

-L 5433:localhost:5432 es «lo que llegue a mi 5433, mándalo al 5432 visto desde el servidor»; -N no abre una consola y -f lo deja en segundo plano. Los programas gráficos, como DBeaver o pgAdmin, traen la opción de túnel SSH en la ventana de conexión. Si es una aplicación en otra máquina la que necesita la base, la alternativa es una red privada entre las dos (la red interna de tu proveedor, una VPN como WireGuard o Tailscale) y una línea de pg_hba.conf solo para esa IP.

Índices medidos con EXPLAIN ANALYZE

Un índice es una estructura aparte, ordenada por una columna, que permite encontrar filas sin leer la tabla entera, como el índice de un libro. Para verlo con números creamos una tabla de un millón de visitas:

psql -d tienda -c '\timing on' -c "CREATE TABLE visitas AS
  SELECT g AS id, (g % 50000) + 1 AS cliente_id, 1 + (g % 15) AS producto_id,
         timestamp '2026-01-01' + (g % 300) * interval '1 day' + (g % 86400) * interval '1 second' AS momento
  FROM generate_series(1, 1000000) AS g;" -c 'ANALYZE visitas;'
salidaTiming is on.
SELECT 1000000
Time: 909.284 ms
ANALYZE
Time: 72.766 ms

EXPLAIN ANALYZE ejecuta la consulta y cuenta cómo la resolvió y cuánto tardó cada paso. Buscamos las visitas de un cliente:

psql -d tienda -c 'EXPLAIN ANALYZE SELECT count(*) FROM visitas WHERE cliente_id = 4321;'
salida                                                           QUERY PLAN                                                            
---------------------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=12608.57..12608.58 rows=1 width=8) (actual time=16.512..19.034 rows=1 loops=1)
   ->  Gather  (cost=12608.35..12608.56 rows=2 width=8) (actual time=16.424..19.029 rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate  (cost=11608.35..11608.36 rows=1 width=8) (actual time=14.083..14.084 rows=1 loops=3)
               ->  Parallel Seq Scan on visitas  (cost=0.00..11608.33 rows=8 width=0) (actual time=1.130..14.080 rows=7 loops=3)
                     Filter: (cliente_id = 4321)
                     Rows Removed by Filter: 333327
 Planning Time: 0.215 ms
 Execution Time: 19.100 ms
(10 rows)

Parallel Seq Scan significa que leyó la tabla entera, repartida entre tres procesos: cada uno descartó unas 333,000 filas para quedarse con siete. Tardó 19.1 ms. Creamos el índice y repetimos:

psql -d tienda -c '\timing on' -c 'CREATE INDEX visitas_cliente_idx ON visitas (cliente_id);'
salidaTiming is on.
CREATE INDEX
Time: 207.260 ms
psql -d tienda -c 'EXPLAIN ANALYZE SELECT count(*) FROM visitas WHERE cliente_id = 4321;'
salida                                                             QUERY PLAN                                                             
------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=81.53..81.54 rows=1 width=8) (actual time=0.332..0.333 rows=1 loops=1)
   ->  Bitmap Heap Scan on visitas  (cost=4.58..81.48 rows=20 width=0) (actual time=0.072..0.326 rows=20 loops=1)
         Recheck Cond: (cliente_id = 4321)
         Heap Blocks: exact=20
         ->  Bitmap Index Scan on visitas_cliente_idx  (cost=0.00..4.58 rows=20 width=0) (actual time=0.051..0.051 rows=20 loops=1)
               Index Cond: (cliente_id = 4321)
 Planning Time: 0.407 ms
 Execution Time: 0.427 ms
(8 rows)

Ahora va directo a las 20 filas que cumplen (Bitmap Index Scan): 0.43 ms, unas 44 veces menos. El índice ocupa espacio y hace un poco más lentas las escrituras, porque cada INSERT o UPDATE también lo actualiza:

psql -d tienda -c "SELECT pg_size_pretty(pg_relation_size('visitas')) AS tabla, pg_size_pretty(pg_relation_size('visitas_cliente_idx')) AS indice;"
salida tabla | indice  
-------+---------
 50 MB | 7600 kB
(1 row)

Un detalle que rompe muchos índices: si en la condición la columna aparece dentro de una operación o una función, el índice ya no sirve, porque está ordenado por cliente_id, no por cliente_id + 0. Lo mismo pasa con lower(email) o date(momento), que necesitan un índice sobre esa expresión:

psql -d tienda -c "EXPLAIN ANALYZE SELECT count(*) FROM visitas WHERE cliente_id + 0 = 4321;"
salida                                                             QUERY PLAN                                                             
------------------------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=13655.42..13655.43 rows=1 width=8) (actual time=17.975..20.522 rows=1 loops=1)
   ->  Gather  (cost=13655.21..13655.42 rows=2 width=8) (actual time=17.865..20.517 rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate  (cost=12655.21..12655.22 rows=1 width=8) (actual time=15.532..15.533 rows=1 loops=3)
               ->  Parallel Seq Scan on visitas  (cost=0.00..12650.00 rows=2083 width=0) (actual time=1.091..15.529 rows=7 loops=3)
                     Filter: ((cliente_id + 0) = 4321)
                     Rows Removed by Filter: 333327
 Planning Time: 0.343 ms
 Execution Time: 20.649 ms
(10 rows)

Reglas prácticas: indexa las columnas que usas para buscar, unir tablas (JOIN) y ordenar; las claves primarias y las UNIQUE ya llevan el suyo, pero las claves foráneas no; y mide antes y después con EXPLAIN ANALYZE, porque el planificador a veces prefiere leer la tabla entera, y con razón. En una tabla en uso, crea los índices con CREATE INDEX CONCURRENTLY, que tarda más pero no bloquea las escrituras. El curso de SQL tiene una lección sobre índices y rendimiento con más casos.

Transacciones y bloqueos

Una transacción agrupa varias órdenes para que se apliquen todas o ninguna. Entre BEGIN y COMMIT los cambios son provisionales y solo los ve tu sesión; con ROLLBACK desaparecen:

psql -d tienda -c 'BEGIN' -c 'DELETE FROM detalle' -c 'SELECT count(*) FROM detalle' -c 'ROLLBACK' -c 'SELECT count(*) FROM detalle'
salidaBEGIN
DELETE 41
 count 
-------
     0
(1 row)

ROLLBACK
 count 
-------
    41
(1 row)

Cuando dos sesiones quieren cambiar la misma fila, la segunda espera a que la primera termine. Lo provocamos: la sesión 1 cambia el stock del producto 1 y tarda seis segundos en confirmar; un segundo después, la sesión 2 intenta cambiar la misma fila; y una tercera mira qué pasa con pg_stat_activity:

# Dos sesiones cambian el mismo producto: la segunda espera a que la primera termine.
cd ~
psql -d tienda -c 'BEGIN' -c 'UPDATE productos SET stock = stock - 1 WHERE id = 1' -c 'SELECT pg_sleep(6)' -c 'COMMIT' > s1.txt 2>&1 &
sleep 1
psql -d tienda -c '\timing on' -c 'UPDATE productos SET stock = stock - 1 WHERE id = 1' > s2.txt 2>&1 &
sleep 1
echo '== mientras tanto, una tercera sesión mira quién espera a quién =='
psql -d tienda -c "SELECT pid, pg_blocking_pids(pid) AS bloqueado_por, wait_event_type AS espera, state, left(query, 48) AS consulta FROM pg_stat_activity WHERE datname = 'tienda' AND pid <> pg_backend_pid() ORDER BY pid"
wait
echo '== sesión 1 =='; cat s1.txt; echo '== sesión 2 =='; cat s2.txt; rm -f s1.txt s2.txt
salida== mientras tanto, una tercera sesión mira quién espera a quién ==
 pid | bloqueado_por | espera  | state  |                     consulta                     
-----+---------------+---------+--------+--------------------------------------------------
 648 | {}            | Timeout | active | SELECT pg_sleep(6)
 651 | {648}         | Lock    | active | UPDATE productos SET stock = stock - 1 WHERE id 
(2 rows)

== sesión 1 ==
BEGIN
UPDATE 1
 pg_sleep 
----------
 
(1 row)

COMMIT
== sesión 2 ==
Timing is on.
UPDATE 1
Time: 5007.490 ms (00:05.007)

La columna bloqueado_por dice que la 651 espera a la 648, y la sesión 2 tardó 5 segundos en un UPDATE que normalmente es instantáneo. Leer no bloquea: un SELECT nunca espera a un UPDATE ni al revés, porque PostgreSQL guarda varias versiones de cada fila (eso se llama MVCC). Las esperas vienen de escribir lo mismo a la vez o de cambiar la estructura de una tabla mientras se usa.

Si una sesión prefiere fallar a esperar indefinidamente, lock_timeout pone un límite:

psql -d tienda -c "SET lock_timeout = '2s'" -c '\timing on' -c 'UPDATE productos SET stock = stock - 1 WHERE id = 1'
salidaSET
Timing is on.
ERROR:  canceling statement due to lock timeout
CONTEXT:  while updating tuple (0,18) in relation "productos"
Time: 2002.464 ms (00:02.002)

Dos consejos que evitan la mayoría de los problemas: las transacciones deben ser cortas (nada de abrir una y esperar a que el usuario haga clic), y una sesión que se quedó «idle in transaction» en pg_stat_activity retiene bloqueos hasta que termine; SELECT pg_terminate_backend(pid) la corta si hace falta.

Respaldos con pg_dump

pg_dump saca una copia coherente de una base mientras está en uso, sin detener a nadie. Usa el formato custom (-Fc): va comprimido y luego permite restaurar todo o solo partes:

mkdir -p respaldos && pg_dump -Fc -d tienda -f respaldos/tienda.dump && ls -lh respaldos/
salidatotal 7.5M
-rw-r--r-- 1 ana ana 7.5M Nov  7 17:55 tienda.dump

pg_restore -l lista lo que contiene sin restaurar nada. Fíjate en las líneas ACL: el respaldo también guarda los permisos de cada tabla:

pg_restore -l respaldos/tienda.dump
salida217; 1259 16387 TABLE public categorias ana
3492; 0 0 ACL public TABLE categorias ana
218; 1259 16394 TABLE public clientes ana
3493; 0 0 ACL public TABLE clientes ana
223; 1259 16451 TABLE public cupones ana
3494; 0 0 ACL public TABLE cupones ana
221; 1259 16423 TABLE public detalle ana
3495; 0 0 ACL public TABLE detalle ana
222; 1259 16439 TABLE public empleados ana
3496; 0 0 ACL public TABLE empleados ana
224; 1259 16459 TABLE public envios ana
3497; 0 0 ACL public TABLE envios ana
220; 1259 16413 TABLE public pedidos ana
3498; 0 0 ACL public TABLE pedidos ana
219; 1259 16401 TABLE public productos ana
3499; 0 0 ACL public TABLE productos ana
3500; 0 0 ACL public COLUMN productos.stock ana
225; 1259 16465 TABLE public visitas ana
3501; 0 0 ACL public TABLE visitas ana
3476; 0 16387 TABLE DATA public categorias ana

Dos variantes útiles: una sola tabla, y solo la estructura, sin datos (para crear una base vacía igual o revisar cambios):

pg_dump -Fc -d tienda -t clientes -f respaldos/clientes.dump
pg_restore -l respaldos/clientes.dump
salida218; 1259 16394 TABLE public clientes ana
3452; 0 0 ACL public TABLE clientes ana
3444; 0 16394 TABLE DATA public clientes ana
3296; 2606 16400 CONSTRAINT public clientes clientes_email_key ana
3298; 2606 16398 CONSTRAINT public clientes clientes_pkey ana
pg_dump -d tienda --schema-only | grep -E '^CREATE (TABLE|INDEX)'
salidaCREATE TABLE public.categorias (
CREATE TABLE public.clientes (
CREATE TABLE public.cupones (
CREATE TABLE public.detalle (
CREATE TABLE public.empleados (
CREATE TABLE public.envios (
CREATE TABLE public.pedidos (
CREATE TABLE public.productos (
CREATE TABLE public.visitas (
CREATE INDEX visitas_cliente_idx ON public.visitas USING btree (cliente_id);

Un detalle que se olvida: pg_dump respalda una base, pero los roles no son de ninguna base, son del clúster. Se respaldan aparte con pg_dumpall --globals-only (aquí ocultamos las huellas de las contraseñas):

sudo -u postgres pg_dumpall --globals-only
salidaCREATE ROLE ana;
ALTER ROLE ana WITH NOSUPERUSER INHERIT NOCREATEROLE CREATEDB LOGIN NOREPLICATION NOBYPASSRLS;
CREATE ROLE app_tienda;
ALTER ROLE app_tienda WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'SCRAM-SHA-256$…';
CREATE ROLE curioso;
ALTER ROLE curioso WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'SCRAM-SHA-256$…';
CREATE ROLE postgres;
ALTER ROLE postgres WITH SUPERUSER INHERIT CREATEROLE CREATEDB LOGIN REPLICATION BYPASSRLS;
CREATE ROLE reportes;
ALTER ROLE reportes WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB LOGIN NOREPLICATION NOBYPASSRLS PASSWORD 'SCRAM-SHA-256$…';

Restaurar y comprobar la restauración

Un respaldo que nunca se ha restaurado no es un respaldo, es una esperanza. Lo restauramos en una base nueva, sin tocar la original:

createdb tienda_prueba
pg_restore -d tienda_prueba respaldos/tienda.dump
echo "código de salida: $?"
salidacódigo de salida: 0

Que no dé errores no basta. Comparamos lo que importa: cuántas filas tiene cada tabla y una suma de dinero, en las dos bases:

SELECT 'categorias' AS tabla, count(*) FROM categorias UNION ALL SELECT 'clientes', count(*) FROM clientes
UNION ALL SELECT 'productos', count(*) FROM productos UNION ALL SELECT 'pedidos', count(*) FROM pedidos
UNION ALL SELECT 'detalle', count(*) FROM detalle UNION ALL SELECT 'visitas', count(*) FROM visitas
UNION ALL SELECT 'total vendido', round(sum(cantidad * precio_unitario)) FROM detalle;
for b in tienda tienda_prueba; do psql -d $b -Atf comparar.sql > $b.txt; done
diff tienda.txt tienda_prueba.txt && echo 'IGUALES: la restauración tiene los mismos datos'
salidatienda          | tienda_prueba
categorias|5   ·   categorias|5
clientes|12   ·   clientes|12
productos|15   ·   productos|15
pedidos|22   ·   pedidos|22
detalle|41   ·   detalle|41
visitas|1000000   ·   visitas|1000000
total vendido|15212   ·   total vendido|15212

IGUALES: la restauración tiene los mismos datos

Y los permisos venían en el respaldo: reportes puede leer la copia igual que la original:

psql -h localhost -U reportes -d tienda_prueba -c 'SELECT count(*) FROM clientes'
salida count 
-------
    12
(1 row)

Restaurar en otro servidor tiene una trampa: los roles. Llevamos el respaldo al servidor con PostgreSQL 18, que todavía no tenía los roles de la tienda:

createdb -U postgres tienda2
pg_restore -U postgres -d tienda2 tienda.dump 2> errores.txt
echo "código de salida: $?"
salidacódigo de salida: 1
pg_restore: error: could not execute query: ERROR:  role "ana" does not exist
Command was: ALTER TABLE public.categorias OWNER TO ana;

pg_restore: error: could not execute query: ERROR:  role "ana" does not exist
…
pg_restore: warning: errors ignored on restore: 20

Los datos se cargan, pero las tablas quedan a nombre de postgres y se pierden los permisos: veinte errores que se pasan por alto si no se mira el código de salida. El orden correcto es restaurar primero los roles y después la base (el error de postgres es normal: ese rol ya existía):

psql -U postgres -f globales.sql
createdb -U postgres -O ana tienda
pg_restore -U postgres -d tienda tienda.dump
echo "código de salida: $?"
psql -U postgres -d tienda -Atc 'SELECT version()' -Atc 'SELECT count(*) FROM visitas' -Atc 'SELECT round(sum(cantidad * precio_unitario)) FROM detalle'
salidapsql:globales.sql:22: ERROR:  role "postgres" already exists
código de salida: 0
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
1000000
15212

Si en el destino no quieres los mismos dueños y permisos, pg_restore --no-owner --no-acl deja todo a nombre del rol que restaura.

Un respaldo que de verdad sirve tienda la base en uso tienda.dump formato custom pg_dump -Fc pg_restore tienda_prueba otra base, aparte Comprobar, no suponer mismos conteos por tabla y mismas sumas en la original y en la restaurada Cada día, solo un temporizador de systemd lo repite, borra los de más de 7 días y una copia sale a otra máquina (rsync, ssh)

Esquema: decodigo.com.

Respaldo diario automático, con rotación

Un respaldo manual se olvida. Lo automatizamos con un script, un servicio y un temporizador de systemd, como en la guía de systemd y cron. El script guarda la copia con la fecha en el nombre, comprueba que el archivo se puede leer antes de darlo por bueno y borra las de más de siete días:

#!/bin/bash
# Respaldo diario de la base «tienda»: formato custom, comprobación mínima y se quedan los últimos 7 días.
set -euo pipefail
DIR=/var/backups/postgresql
ARCH="$DIR/tienda-$(date +%F-%H%M).dump"
pg_dump -Fc -d tienda -f "$ARCH.tmp"
pg_restore -l "$ARCH.tmp" > /dev/null      # que el archivo se pueda leer
mv "$ARCH.tmp" "$ARCH"
find "$DIR" -name 'tienda-*.dump' -mtime +7 -print -delete
echo "respaldo listo: $ARCH ($(du -h "$ARCH" | cut -f1))"

El servicio lo ejecuta como el usuario postgres, que entra por el socket con peer sin contraseña guardada en ningún archivo:

[Unit]
Description=Respaldo de la base tienda
After=postgresql.service

[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/bin/respaldo-tienda.sh

Y el temporizador lo lanza cada día a las 3:30, con hasta diez minutos de margen al azar; con Persistent=true, si el servidor estaba apagado a esa hora, lo hace al encender:

[Unit]
Description=Respaldo diario de la base tienda

[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=10min
Persistent=true

[Install]
WantedBy=timers.target
sudo install -m 755 respaldo-tienda.sh /usr/local/bin/
sudo install -m 644 respaldo-tienda.service respaldo-tienda.timer /etc/systemd/system/
sudo install -d -o postgres -g postgres -m 700 /var/backups/postgresql
sudo systemctl daemon-reload
sudo systemctl enable --now respaldo-tienda.timer
systemctl list-timers respaldo-tienda.timer
salidaCreated symlink '/etc/systemd/system/timers.target.wants/respaldo-tienda.timer' → '/etc/systemd/system/respaldo-tienda.timer'.
NEXT                        LEFT LAST PASSED UNIT                  ACTIVATES
Sun 2026-11-08 03:38:59 UTC   9h -         - respaldo-tienda.timer respaldo-tienda.service

1 timers listed.
Pass --all to see loaded but inactive timers, too.

No esperes a las 3:30 para saber si funciona: lánzalo a mano y revisa su registro:

sudo systemctl start respaldo-tienda.service
sudo journalctl -u respaldo-tienda.service -o cat
sudo ls -l /var/backups/postgresql
salidaStarting respaldo-tienda.service - Respaldo de la base tienda...
respaldo listo: /var/backups/postgresql/tienda-2026-11-07-1756.dump (7.5M)
respaldo-tienda.service: Deactivated successfully.
Finished respaldo-tienda.service - Respaldo de la base tienda.
total 7596
-rw-r--r-- 1 postgres postgres 7776993 Nov  7 17:56 tienda-2026-11-07-1756.dump

Para probar la rotación, fabricamos tres respaldos viejos, de hace 9, 8 y 3 días, y lo lanzamos otra vez. Borró los dos de más de siete días y dejó el de tres:

sudo ls /var/backups/postgresql
sudo systemctl start respaldo-tienda.service
sudo journalctl -u respaldo-tienda.service -o cat -n 7
sudo ls /var/backups/postgresql
salidatienda-2026-10-29-0330.dump
tienda-2026-10-30-0330.dump
tienda-2026-11-04-0330.dump
tienda-2026-11-07-1756.dump

Starting respaldo-tienda.service - Respaldo de la base tienda...
/var/backups/postgresql/tienda-2026-10-30-0330.dump
/var/backups/postgresql/tienda-2026-10-29-0330.dump
respaldo listo: /var/backups/postgresql/tienda-2026-11-07-1757.dump (7.5M)
respaldo-tienda.service: Deactivated successfully.
Finished respaldo-tienda.service - Respaldo de la base tienda.

tienda-2026-11-04-0330.dump
tienda-2026-11-07-1756.dump
tienda-2026-11-07-1757.dump

Falta una pieza: estas copias están en el mismo disco que la base. Si el servidor se pierde, se pierden con él. Añade un paso que las copie a otra máquina o a un almacenamiento externo (por ejemplo, con rsync sobre SSH) y, cada cierto tiempo, restaura una en una base de prueba como hicimos arriba. Para bases grandes o si necesitas volver a un minuto exacto, existen los respaldos físicos con archivado del WAL (pg_basebackup y herramientas como pgBackRest), que quedan fuera de esta guía.

VACUUM y autovacuum

Por cómo funciona MVCC, un UPDATE no sobrescribe la fila: escribe una versión nueva y deja la vieja marcada como muerta, por si alguna transacción todavía la necesita. Un DELETE, igual. Para verlo, apagamos un momento la limpieza automática de la tabla y cambiamos sus un millón de filas:

psql -d tienda -c 'ALTER TABLE visitas SET (autovacuum_enabled = off);'
psql -d tienda -c 'UPDATE visitas SET producto_id = producto_id;'
psql -d tienda -c "SELECT n_live_tup AS vivas, n_dead_tup AS muertas, pg_size_pretty(pg_relation_size('visitas')) AS tamano
                  FROM pg_stat_user_tables WHERE relname = 'visitas'"
salidaALTER TABLE
 tamano 
--------
 50 MB
(1 row)

Timing is on.
UPDATE 1000000
Time: 3242.451 ms (00:03.242)
Timing is off.
  vivas  | muertas | tamano 
---------+---------+--------
 1000000 | 1000000 | 100 MB
(1 row)

Un millón de filas vivas y un millón de muertas: la tabla pasó de 50 a 100 MB sin tener un solo dato nuevo. VACUUM recoge las filas muertas para que su espacio se reutilice:

psql -d tienda -c 'VACUUM (VERBOSE) visitas;'
psql -d tienda -c "SELECT n_live_tup AS vivas, n_dead_tup AS muertas, pg_size_pretty(pg_relation_size('visitas')) AS tamano, last_vacuum ..."
salidaINFO:  vacuuming "tienda.public.visitas"
INFO:  finished vacuuming "tienda.public.visitas": index scans: 1
pages: 0 removed, 12739 remain, 12739 scanned (100.00% of total)
tuples: 1000000 removed, 1000000 remain, 0 are dead but not yet removable
removable cutoff: 846, which was 0 XIDs old when operation ended
new relfrozenxid: 845, which is 76 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan needed: 6370 pages from table (50.00% of total) had 1000000 dead item identifiers removed
index "visitas_cliente_idx": pages: 1896 in total, 0 newly deleted, 0 currently deleted, 0 reusable
avg read rate: 1.458 MB/s, avg write rate: 1.603 MB/s
buffer usage: 33779 hits, 20 misses, 22 dirtied
WAL usage: 27368 records, 0 full page images, 7708909 bytes
  vivas  | muertas | tamano |          last_vacuum          
---------+---------+--------+-------------------------------
 1000000 |       0 | 100 MB | 2026-11-07 17:58:02.348106+00
(1 row)

Las muertas bajaron a cero, pero la tabla sigue midiendo 100 MB: VACUUM deja el espacio libre dentro de la tabla para las próximas escrituras, no se lo devuelve al disco. Eso solo lo hace VACUUM FULL, que reescribe la tabla entera y la bloquea por completo mientras tanto, así que se reserva para casos excepcionales:

psql -d tienda -c 'VACUUM FULL visitas;'
psql -d tienda -c "SELECT pg_size_pretty(pg_relation_size('visitas'))"
psql -d tienda -c 'ALTER TABLE visitas RESET (autovacuum_enabled);'
salidaTiming is on.
VACUUM
Time: 560.662 ms
Timing is off.
 tamano 
--------
 50 MB
(1 row)

ALTER TABLE

En el día a día no tienes que hacer nada de esto: el proceso autovacuum, que vimos en la lista de procesos, revisa cada minuto las tablas y limpia las que acumularon suficientes filas muertas (por defecto, 50 más el 20 % de la tabla). También actualiza las estadísticas que usa el planificador para elegir índices. Lo importante es no apagarlo y vigilar n_dead_tup si una tabla crece sin motivo:

psql -d tienda -Atc "SELECT name || ' = ' || setting FROM pg_settings WHERE name IN ('autovacuum', 'autovacuum_vacuum_scale_factor', 'autovacuum_vacuum_threshold', 'autovacuum_naptime') ORDER BY name"
salidaautovacuum = on
autovacuum_naptime = 60
autovacuum_vacuum_scale_factor = 0.2
autovacuum_vacuum_threshold = 50

Actualizar a una versión mayor

Las versiones menores (de 17.10 a 17.11) solo traen correcciones y se instalan con la actualización normal del sistema y un reinicio. Las mayores (de 17 a 18) cambian el formato interno de los datos y necesitan un paso de migración. Hay dos caminos:

  • Volcar y restaurar. Respaldar con pg_dumpall --globals-only y pg_dump, instalar la versión nueva y restaurar, justo lo que hicimos arriba de la 17 a la 18. Es lo más sencillo y deja todo limpio, pero la base está fuera de servicio el tiempo que tarde, que con muchos gigas pueden ser horas. La documentación recomienda usar el pg_dump de la versión nueva para leer la vieja.
  • pg_upgrade. Convierte la carpeta de datos en el sitio, en minutos aunque la base sea enorme (con --link o --clone ni siquiera copia los archivos). Se ejecuta primero con --check, que solo revisa. En Debian y Ubuntu, con el repositorio del proyecto, el paquete trae pg_upgradecluster 17 main, que hace todo el proceso y reconfigura los puertos.

En cualquiera de los dos: haz un respaldo antes, lee las notas de la versión nueva (sección «Migration»), prueba tu aplicación contra ella en otra máquina y ten a mano cómo volver atrás.

Errores típicos y cómo resolverlos

Todos estos mensajes salieron en el laboratorio. PostgreSQL también los anota en su registro, en Debian en /var/log/postgresql/, que es lo primero que hay que mirar cuando algo falla. La guía de logs y archivos de configuración explica cómo leer este tipo de registros:

sudo grep -E 'FATAL|ERROR' /var/log/postgresql/postgresql-17-main.log | tail -5
salida2026-11-07 17:54:34.059 UTC [481] reportes@tienda FATAL:  no pg_hba.conf entry for host "172.30.7.50", user "reportes", database "tienda", SSL encryption
2026-11-07 17:54:34.062 UTC [482] reportes@tienda FATAL:  no pg_hba.conf entry for host "172.30.7.50", user "reportes", database "tienda", no encryption
2026-11-07 17:54:34.530 UTC [501] reportes@tienda FATAL:  no pg_hba.conf entry for host "172.30.7.50", user "reportes", database "tienda", no encryption
2026-11-07 17:55:20.768 UTC [673] ana@tienda ERROR:  canceling statement due to lock timeout
2026-11-07 17:58:16.974 UTC [1102] reportes@tienda FATAL:  remaining connection slots are reserved for roles with the SUPERUSER attribute
  • Peer authentication failed for user "postgres": te conectas por el socket local con un rol distinto de tu usuario del sistema. Usa sudo -u postgres psql, crea un rol con tu nombre de usuario, o conéctate por TCP con -h localhost y contraseña.
  • role "ana" does not exist: psql usa tu nombre de usuario del sistema como rol si no le dices otro con -U. Crea el rol o indica el correcto. Al restaurar en otro servidor, significa que faltan los roles: restaura antes pg_dumpall --globals-only.
  • password authentication failed for user: la contraseña no coincide, o el rol no tiene ninguna (los roles creados sin PASSWORD no pueden entrar por contraseña). Cámbiala con \password rol como superusuario.
  • permission denied for table, for schema o for database: el rol entró, pero le falta una de las cuatro puertas. El mensaje dice cuál; revisa con \dp tabla, \dn+ y \l. Recuerda que GRANT ... ON ALL TABLES no incluye las tablas futuras.
  • Connection refused: nadie escucha en esa dirección y puerto. O el servidor está detenido (systemctl status postgresql), o listen_addresses no incluye esa interfaz, o el puerto es otro. Si el mensaje habla del socket (No such file or directory), el servidor no está corriendo en esa máquina.
  • no pg_hba.conf entry for host: llegó, pero ninguna línea de pg_hba.conf coincide con esa combinación de base, rol, dirección y cifrado. El mensaje trae los cuatro datos; compáralos con pg_hba_file_rules y recarga después de cambiar el archivo.
  • remaining connection slots are reserved o sorry, too many clients already: se acabaron las conexiones (max_connections, 100 por defecto, de las que unas pocas se reservan para el superusuario).

Así se ve el último, con max_connections bajado a 6 para provocarlo: tres reservadas para superusuario y tres para los demás, así que la cuarta conexión de reportes ya no entra:

bash conexiones.sh   # abre tres conexiones de reportes que esperan 8 s y prueba una cuarta
salida== la cuarta conexión de reportes ==
psql: error: connection to server at "localhost" (::1), port 5432 failed: FATAL:  remaining connection slots are reserved for roles with the SUPERUSER attribute
== quién está conectado (como postgres) ==
 usename  | count 
----------+-------
 postgres |     1
 reportes |     3
(2 rows)

Subir max_connections rara vez es la solución, porque cada conexión es un proceso con su propia memoria. Lo habitual es que la aplicación abra conexiones y no las cierre, o que no use un pool; para muchas conexiones cortas existe PgBouncer, que reparte pocas conexiones reales entre muchos clientes.

Y por último, el servidor detenido, por socket y por TCP:

sudo systemctl stop postgresql@17-main
psql -d tienda -c 'SELECT 1'
psql -h localhost -U reportes -d tienda -c 'SELECT 1'
salidapsql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory
	Is the server running locally and accepting connections on that socket?
psql: error: connection to server at "localhost" (::1), port 5432 failed: Connection refused
	Is the server running on that host and accepting TCP/IP connections?
connection to server at "localhost" (127.0.0.1), port 5432 failed: Connection refused
	Is the server running on that host and accepting TCP/IP connections?
active

Hoja de referencia

# entrar
sudo -u postgres psql                    # como superusuario, por el socket (peer)
psql -h servidor -U reportes -d tienda   # por red, con contraseña
\l  \c tienda  \dt  \d tabla  \du  \dp tabla  \ddp  \x  \timing  \conninfo  \password  \q

# roles y permisos
CREATE ROLE app LOGIN PASSWORD '…';          -- mejor: \password app
REVOKE CONNECT ON DATABASE tienda FROM PUBLIC;
GRANT CONNECT ON DATABASE tienda TO app;
GRANT USAGE ON SCHEMA public TO app;         -- solo si no es el dueño de la base
GRANT SELECT, INSERT ON pedidos TO app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO reportes;

# configuración (Debian: /etc/postgresql/17/main/)
SHOW config_file;  SHOW hba_file;  SELECT * FROM pg_hba_file_rules;
SELECT pg_reload_conf();                     # pg_hba.conf y casi todo postgresql.conf
sudo systemctl restart postgresql@17-main    # listen_addresses, max_connections, shared_buffers

# rendimiento y bloqueos
EXPLAIN ANALYZE SELECT …;
CREATE INDEX CONCURRENTLY idx ON tabla (columna);
SELECT pid, pg_blocking_pids(pid), wait_event_type, query FROM pg_stat_activity;
SET lock_timeout = '2s';

# respaldos
pg_dump -Fc -d tienda -f tienda.dump         # una base
pg_dump -Fc -d tienda -t clientes -f c.dump  # una tabla
pg_dump -d tienda --schema-only              # solo la estructura
sudo -u postgres pg_dumpall --globals-only > roles.sql
createdb tienda_prueba && pg_restore -d tienda_prueba tienda.dump
pg_restore -l tienda.dump                    # ver qué contiene

# mantenimiento
SELECT relname, n_dead_tup FROM pg_stat_user_tables ORDER BY 2 DESC;
VACUUM (VERBOSE, ANALYZE) tabla;

Si administras el servidor por tu cuenta, te servirán también las guías de comandos de Linux, SSH y systemd y cron; para practicar consultas, el curso de SQL y su editor en el navegador, y para levantar PostgreSQL en contenedores, la de Docker.

Fuentes: documentación de PostgreSQL sobre autenticación de clientes y pg_hba.conf, GRANT, ALTER DEFAULT PRIVILEGES, esquemas y el cambio de public en la versión 15, EXPLAIN, bloqueos, pg_dump, pg_restore, VACUUM y actualizar de versión; política de versiones; imagen oficial de Docker. Laboratorio: Debian 13, PostgreSQL 17.11 y 18.6, OpenSSH y systemd 257, en contenedores desechables.

Imagen de portada: «Elephants rushing to water (Etosha, 2013)», de Yathin S Krishnappa, con licencia CC BY-SA 3.0; el elefante es la mascota de PostgreSQL. Postgres, PostgreSQL y el logotipo Slonik son marcas registradas de la PostgreSQL Community Association of Canada; esta guía no está afiliada a ella.