Qué vas a conseguir
Operar un servidor PostgreSQL en Linux con criterio: saber qué cambio se aplica con una recarga y cuál necesita reiniciar, dar permisos que no se rompen en el siguiente despliegue, abrir el acceso en pg_hba.conf solo a quien toca, ver si autovacuum llega a tiempo, entender cuándo faltan conexiones y cuándo sobran, encontrar las consultas que más pesan y resolver el ticket "la aplicación no conecta" leyendo cuál de los tres errores típicos es.
Antes de empezar
- PostgreSQL 16 o 17 en Ubuntu, Debian o RHEL (paquetes del proyecto, PGDG), con
sudoen el servidor. Se entra como superusuario consudo -u postgres psql. - La cuenta de la aplicación, la de copias, la copia programada y el retraso de la réplica están en la guía de MySQL, MariaDB y PostgreSQL: permisos mínimos y copias; la sesión que bloquea y el disco lleno, en la de PostgreSQL, MySQL y SQL Server.
- Una ventana de cambios para lo que exige reinicio (paso 1).
1. Dónde está todo y qué pide reinicio
| Instalación | Configuración | Datos | Servicio | Log |
|---|---|---|---|---|
| Ubuntu / Debian | /etc/postgresql/17/main/ | /var/lib/postgresql/17/main/ | postgresql@17-main | /var/log/postgresql/postgresql-17-main.log |
| RHEL (PGDG) | /var/lib/pgsql/17/data/ | /var/lib/pgsql/17/data/ | postgresql-17 | /var/lib/pgsql/17/data/log/ |
En la duda, el propio servidor lo dice:
sudo -u postgres psql -c "SHOW config_file;" -c "SHOW hba_file;" config_file----------------------------------------- /etc/postgresql/17/main/postgresql.conf hba_file------------------------------------- /etc/postgresql/17/main/pg_hba.confsudo -u postgres psql -c "SELECT name, setting, context, pending_restart FROM pg_settings WHERE name IN ('max_connections','listen_addresses','shared_preload_libraries','idle_in_transaction_session_timeout','log_min_duration_statement');" name | setting | context | pending_restart-------------------------------------+-----------------------+------------+----------------- idle_in_transaction_session_timeout | 0 | user | f listen_addresses | localhost,10.20.40.11 | postmaster | f log_min_duration_statement | -1 | superuser | f max_connections | 100 | postmaster | f shared_preload_libraries | | postmaster | fLa columna context responde a la pregunta de cada cambio:
postmaster: reinicio del servicio (max_connections,listen_addresses,shared_preload_libraries,shared_buffers). Corta todas las conexiones: ventana de cambios.sighup,superuser,user: basta con recargar (SELECT pg_reload_conf();osudo systemctl reload postgresql@17-main). Las sesiones abiertas siguen.pending_restart = ttras una recarga: el valor está en el fichero, pero no se aplicará hasta reiniciar. Es la forma de saber que un cambio "no ha entrado".
pg_hba.conf no es un parámetro, pero va igual: se recarga, no se reinicia. Para cambios en postgresql.conf, mejor un fichero propio incluido (include_dir = 'conf.d', que Debian ya trae) o ALTER SYSTEM SET ..., que escribe en postgresql.auto.conf dentro del directorio de datos y se lee al final.
2. Roles y privilegios: el esquema y lo que se cree mañana
En PostgreSQL los permisos van en tres niveles, y faltar cualquiera da permission denied:
- Base de datos:
GRANT CONNECT ON DATABASE erp TO .... - Esquema:
GRANT USAGE ON SCHEMA erp TO ...(ver lo que hay dentro) y, solo para quien crea tablas,CREATE. - Objetos:
GRANT SELECT ... ON ALL TABLES IN SCHEMA erppara las tablas que ya existen, yALTER DEFAULT PRIVILEGESpara las que se creen después.
Desde PostgreSQL 15, el esquema public ya no deja crear objetos a cualquiera (CREATE retirado a PUBLIC) y su dueño es pg_database_owner. Una aplicación migrada desde una versión antigua que creaba tablas en public sin permisos explícitos falla con permission denied for schema public tras la actualización. La solución buena es un esquema propio de la aplicación con su dueño, no devolver CREATE a todo el mundo.
CREATE ROLE erp_lectura NOLOGIN;GRANT CONNECT ON DATABASE erp TO erp_lectura;GRANT USAGE ON SCHEMA erp TO erp_lectura;GRANT SELECT ON ALL TABLES IN SCHEMA erp TO erp_lectura;ALTER DEFAULT PRIVILEGES FOR ROLE erp_owner IN SCHEMA erp GRANT SELECT ON TABLES TO erp_lectura;GRANT erp_lectura TO laura_prado;\dn+ erp List of schemas Name | Owner | Access privileges | Description------+-----------+--------------------------+------------- erp | erp_owner | erp_owner=UC/erp_owner +| | | erp_rw=U/erp_owner +| | | erp_lectura=U/erp_owner |\ddp Default access privileges Owner | Schema | Type | Access privileges-----------+--------+-------+----------------------- erp_owner | erp | table | erp_rw=arwd/erp_owner+ | | | erp_lectura=r/erp_owner\dn+muestra los permisos del esquema (U= USAGE,C= CREATE);\dp erp.facturas, los de una tabla (r= SELECT,a= INSERT,w= UPDATE,d= DELETE);\ddp, los privilegios por defecto.- La trampa de
ALTER DEFAULT PRIVILEGES: solo se aplica a los objetos que cree el rol deFOR ROLE. Si un despliegue se hace con otra cuenta (o conpostgres), sus tablas nuevas no los heredan y la aplicación da permission denied esa misma noche. Los despliegues, siempre con el dueño (erp_owner), y\ddpen la revisión. - Para lecturas de todo y para monitorización hay roles predefinidos:
pg_read_all_data(leer todas las tablas, PostgreSQL 14+),pg_monitorypg_read_all_stats(ver la actividad y el texto de las consultas de otros usuarios). Mejor eso que hacer superusuario a la herramienta de monitorización.
3. pg_hba.conf: quién entra, desde dónde y cómo
Cada línea es tipo base_de_datos usuario dirección método, y gana la primera que coincide, de arriba abajo. Si ninguna coincide, se rechaza la conexión.
# TYPE DATABASE USER ADDRESS METHODlocal all postgres peerlocal all all peerhost all backup 127.0.0.1/32 scram-sha-256hostssl erp erp_app 10.20.30.0/24 scram-sha-256hostssl erp +erp_lectura 10.20.50.0/24 scram-sha-256host all all 0.0.0.0/0 rejectlocales el socket Unix;peerdeja entrar al usuario del sistema con el mismo nombre (por eso funcionasudo -u postgres psql).hostes TCP con o sin TLS;hostssl, solo con TLS.+erp_lecturasignifica "cualquier miembro de ese rol": las altas y bajas se hacen conGRANT/REVOKE, sin tocar el fichero.scram-sha-256es el método recomendado y el cifrado por defecto de las contraseñas desde PostgreSQL 14 (password_encryption = scram-sha-256). Una cuenta cuya contraseña se guardó en MD5 (una base de datos migrada desde una versión antigua) no puede entrar por una líneascram-sha-256: hay que volver a poner su contraseña.SELECT rolname FROM pg_authid WHERE rolpassword LIKE 'md5%';dice cuáles son. MD5 está marcado como obsoleto desde PostgreSQL 18: no lo uses en líneas nuevas.- Nada de
trustfuera de un laboratorio, ni de0.0.0.0/0con un método que no seareject.
Antes de recargar, comprueba que el fichero se entiende:
sudo -u postgres psql -c "SELECT line_number, type, database, user_name, address, auth_method, error FROM pg_hba_file_rules WHERE error IS NOT NULL;" line_number | type | database | user_name | address | auth_method | error-------------+------+----------+-----------+---------+-------------+--------------------------------- 96 | host | {erp} | {erp_app} | | | invalid IP mask "/33": ...sudo -u postgres psql -c "SELECT pg_reload_conf();"pg_hba_file_rules lee el fichero tal como está en disco, no lo que está cargado: si muestra errores, una recarga dejaría el fichero anterior en uso y el log diría pg_hba.conf was not reloaded. Corrige y vuelve a mirar hasta que no salga nada. Y recuerda que el servidor tiene que estar escuchando en esa red: listen_addresses (por defecto, solo localhost) pide reinicio.
4. Autovacuum y tablas hinchadas
Un UPDATE o un DELETE no borra la fila: deja una tupla muerta que VACUUM marca después como espacio reutilizable. Autovacuum lo hace solo, por tabla, cuando las tuplas muertas superan autovacuum_vacuum_threshold (50) más autovacuum_vacuum_scale_factor (0,2, el 20 %) de la tabla. En una tabla de 50 millones de filas, eso son 10 millones de filas muertas antes de que empiece.
SELECT relname, n_live_tup, n_dead_tup, round(100.0*n_dead_tup/greatest(n_live_tup,1),1) AS pct_muertas, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 4; relname | n_live_tup | n_dead_tup | pct_muertas | last_autovacuum | last_autoanalyze----------------+------------+------------+-------------+-------------------------------+------------------------------- movimientos | 48211904 | 9120533 | 18.9 | 2026-10-19 04:12:51.20871+01 | 2026-11-03 06:40:02.11734+01 sesiones_web | 211560 | 1904377 | 900.2 | 2026-11-03 09:58:11.40325+01 | 2026-11-03 09:58:12.00412+01 facturas | 918244 | 3104 | 0.3 | 2026-11-02 23:10:44.88102+01 | 2026-11-02 23:10:45.10288+01Dos casos distintos en la misma pantalla:
movimientos: el último autovacuum fue hace dos semanas. Con el 20 % por defecto, en una tabla grande llega tarde. Se le baja el umbral solo a esa tabla:ALTER TABLE erp.movimientos SET (autovacuum_vacuum_scale_factor = 0.01);(1 %).sesiones_web: autovacuum pasó hace minutos y aun así hay 9 muertas por cada viva. Eso es que no puede limpiar: alguna transacción abierta desde hace horas retiene esas versiones. Se busca así:
SELECT pid, usename, application_name, state, now()-xact_start AS abierta, age(backend_xmin) AS xmin_age FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY age(backend_xmin) DESC LIMIT 3; pid | usename | application_name | state | abierta | xmin_age-------+------------+------------------+---------------------+-----------------+---------- 31877 | informes | DBeaver 24.2 | idle in transaction | 05:41:12.30211 | 8812440 32014 | erp_app | erp-api | active | 00:00:00.41022 | 12Una sesión idle in transaction de casi seis horas desde un cliente de escritorio. Mientras siga abierta, ningún VACUUM puede limpiar lo que ha cambiado desde entonces. Se termina con SELECT pg_terminate_backend(31877); (avisando al usuario) y se pone un tope para la cuenta: ALTER ROLE informes SET idle_in_transaction_session_timeout = '15min';. Las réplicas con hot_standby_feedback y los slots de replicación abandonados retienen igual: mira también pg_replication_slots.
- Hinchazón (bloat):
VACUUMdeja el espacio libre dentro de la tabla para reutilizarlo, no lo devuelve al disco. Para medirlo de verdad, la extensiónpgstattuple(SELECT * FROM pgstattuple('erp.sesiones_web');, campofree_percent). - Para recuperar el disco,
VACUUM FULLreescribe la tabla pero la bloquea por completo mientras dura: solo en ventana y en tablas pequeñas. En tablas grandes y sin parada, la extensión pg_repack. - Pon
log_autovacuum_min_duration = '1min'(desde PostgreSQL 15 viene en 10 minutos) para ver en el log qué tablas tardan en limpiarse.
5. Conexiones y PgBouncer
Cada conexión de PostgreSQL es un proceso del sistema con su memoria. max_connections viene en 100 y se cambia con reinicio. Subirlo a 1.000 no es gratis: cada consulta puede usar hasta work_mem por operación de ordenación o hash, y con cientos de conexiones activas la memoria se va.
SELECT usename, client_addr, state, count(*) FROM pg_stat_activity WHERE backend_type = 'client backend' GROUP BY 1,2,3 ORDER BY 4 DESC; usename | client_addr | state | count----------+-------------+---------------------+------- erp_app | 10.20.30.31 | idle | 58 erp_app | 10.20.30.32 | idle | 21 erp_app | 10.20.30.31 | active | 4 informes | 10.20.50.18 | idle in transaction | 179 conexiones idle para 4 activas: la aplicación abre muchas y usa pocas. Es el caso de PgBouncer: un pool delante de PostgreSQL que recibe cientos de conexiones de clientes y las sirve con unas pocas conexiones reales.
[databases]erp = host=127.0.0.1 port=5432 dbname=erp [pgbouncer]listen_addr = 10.20.40.11listen_port = 6432auth_type = scram-sha-256auth_file = /etc/pgbouncer/userlist.txtpool_mode = transactionmax_client_conn = 1000default_pool_size = 20admin_users = pgbouncer_admin- La aplicación apunta al puerto 6432 en lugar de al 5432; el resto de la cadena de conexión no cambia.
pool_mode = transactiones el que más ahorra: la conexión real se devuelve al acabar cada transacción. Lo que dependa de la sesión (SETsinLOCAL, bloqueos advisory, tablas temporales,LISTEN) no funciona igual; las sentencias preparadas sí, desde PgBouncer 1.21 conmax_prepared_statements. Confírmalo con el equipo de la aplicación o empieza enpool_mode = session.psql -h 10.20.40.11 -p 6432 -U pgbouncer_admin pgbouncer -c "SHOW POOLS;"muestra por pool los clientes activos y en espera (cl_active,cl_waiting) y las conexiones reales (sv_active,sv_idle).cl_waitingmayor que 0 de forma sostenida es que el pool se queda corto.
6. pg_stat_statements: las consultas que más pesan
pg_stat_activity dice qué pasa ahora; pg_stat_statements acumula todas las consultas agrupadas por forma, con tiempos y llamadas. Necesita cargarse al arrancar (un reinicio, una vez):
sudo -u postgres psql -c "ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';"sudo systemctl restart postgresql@17-mainsudo -u postgres psql -d erp -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"SELECT calls, round(total_exec_time/1000) AS total_s, round(mean_exec_time::numeric,1) AS media_ms, rows, left(query,70) AS consulta FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 3; calls | total_s | media_ms | rows | consulta--------+---------+----------+---------+------------------------------------------------------------------------ 412880 | 6120 | 14.8 | 412880 | SELECT * FROM erp.movimientos WHERE cuenta_id = $1 AND fecha >= $2 ORDER BY 288 | 2210 | 7673.6 | 288 | SELECT cliente_id, sum(importe) FROM erp.facturas f JOIN erp.lineas l ON 91220 | 480 | 5.3 | 91220 | UPDATE erp.sesiones_web SET ultimo_acceso = $1 WHERE id = $2- Ordena por
total_exec_time: la primera consulta tarda 15 ms, pero se ejecuta 400.000 veces y suma más que el informe de 7,7 segundos. Los valores aparecen como$1,$2. - El siguiente paso es
EXPLAIN (ANALYZE, BUFFERS)con valores reales, en un entorno de pruebas o con una consulta de solo lectura: unSeq Scansobremovimientoscon millones de filas pide un índice, que se crea sin bloquear las escrituras conCREATE INDEX CONCURRENTLY idx_mov_cuenta_fecha ON erp.movimientos (cuenta_id, fecha);. SELECT pg_stat_statements_reset();pone los contadores a cero: hazlo después de un cambio para medir el antes y el después. Para ver el texto de las consultas de otros usuarios hace falta superusuario opg_read_all_stats.- Para ver en el log cada consulta lenta:
log_min_duration_statement = '2s'y recarga.
7. El ticket: "la aplicación no conecta a la base de datos"
Martes, 8:45. El ERP no abre en dos sedes; en otra funciona. La aplicación muestra un error genérico. Lo primero es leer el FATAL exacto en el log del servidor, porque hay tres muy distintos:
sudo grep -o "FATAL: .*" /var/log/postgresql/postgresql-17-main.log | sort | uniq -c | sort -rn | head 412 FATAL: no pg_hba.conf entry for host "10.20.31.14", user "erp_app", database "erp", SSL encryption 97 FATAL: sorry, too many clients already 12 FATAL: remaining connection slots are reserved for ...| Mensaje | Qué significa | Arreglo | ¿Recarga o reinicio? |
|---|---|---|---|
no pg_hba.conf entry for host ... | Llegó al servidor, pero ninguna línea de pg_hba.conf acepta esa IP, usuario y base de datos | Añadir la línea correcta | Recarga |
sorry, too many clients already | Todas las conexiones de max_connections en uso, incluidas las reservadas | Liberar conexiones; pool | Ninguno para liberar; reinicio si se sube max_connections |
remaining connection slots are reserved for ... | Quedan libres solo las reservadas para superusuarios (superuser_reserved_connections, 3 por defecto) o para pg_use_reserved_connections (reserved_connections, desde PostgreSQL 16) | Igual que el anterior: está a punto de llenarse | Igual |
Caso 1: no pg_hba.conf entry. La IP 10.20.31.14 es de la red nueva de una de las sedes, migrada el fin de semana. pg_hba.conf solo tiene 10.20.30.0/24:
sudoedit /etc/postgresql/17/main/pg_hba.conf# añadir debajo de la línea de 10.20.30.0/24:# hostssl erp erp_app 10.20.31.0/24 scram-sha-256sudo -u postgres psql -tAc "SELECT count(*) FROM pg_hba_file_rules WHERE error IS NOT NULL;"0sudo -u postgres psql -c "SELECT pg_reload_conf();"psql "host=srv-db-02.contoso.local dbname=erp user=erp_app sslmode=require" -c "SELECT 1;"- La línea va antes de cualquier
rejectgeneral: gana la primera que coincide. - El mensaje dice
SSL encryptionono encryption: si el cliente conecta sin TLS y la línea eshostssl, tampoco coincide. - Si el error cambia a
password authentication failed for user "erp_app", la línea ya coincide: el problema es la contraseña o un hash MD5 con métodoscram-sha-256(paso 3). - Y si el cliente recibe
Connection refusedo un tiempo de espera, ni siquiera llegó:listen_addresses, el cortafuegos (5432/tcp) o la red. Eso no deja FATAL en el log.
Casos 2 y 3: conexiones agotadas. Entra como superusuario por el socket (sudo -u postgres psql usa uno de los huecos reservados; si sale too many clients already, ni eso queda) y mira quién las tiene:
SHOW max_connections; 100SELECT usename, client_addr, state, count(*), max(now()-state_change) AS mas_antigua FROM pg_stat_activity WHERE backend_type='client backend' GROUP BY 1,2,3 ORDER BY 4 DESC LIMIT 3; usename | client_addr | state | count | mas_antigua---------+-------------+---------------------+-------+----------------- erp_app | 10.20.30.32 | idle | 74 | 13:02:44.11 erp_app | 10.20.30.31 | active | 6 | 00:00:01.20 erp_app | 10.20.30.32 | idle in transaction | 11 | 02:15:09.84SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename='erp_app' AND client_addr='10.20.30.32' AND state IN ('idle','idle in transaction') AND now()-state_change > interval '10 minutes';- Devolver el servicio: cerrar las conexiones inactivas de ese origen, con más de 10 minutos, como arriba. No hace falta reiniciar PostgreSQL, y reiniciarlo cortaría también a los usuarios que sí funcionan.
- La causa: un nodo (
10.20.30.32) con 85 conexiones, 11 de ellasidle in transactiondesde hace dos horas. Al equipo de la aplicación: el tamaño del pool por nodo y la transacción que no se cierra. - Que el servidor se defienda:
ALTER ROLE erp_app SET idle_in_transaction_session_timeout = '10min';(se aplica a las sesiones nuevas de ese rol, sin reinicio) y, si la aplicación lo tolera,idle_session_timeoutpara las inactivas sin transacción. - La solución de fondo: PgBouncer (paso 5) delante, con la aplicación en el 6432. Subir
max_connectionses la última opción: necesita reinicio en ventana y memoria para ello.
En el ticket: qué FATAL era (con el recuento del log), la línea añadida a pg_hba.conf o las conexiones cerradas y de qué origen, si fue recarga o reinicio, y la alerta nueva: conexiones > 80 % de max_connections con postgres_exporter.
Siguientes pasos
- Monitoriza conexiones por estado, tuplas muertas por tabla, la edad de la transacción más antigua y
cl_waitingde PgBouncer conpostgres_exportery la guía de Grafana. - Para alta disponibilidad con conmutación automática, Patroni sobre la réplica en streaming; para copias físicas con recuperación a un momento concreto, pgBackRest.
- La guía de MySQL y MariaDB resuelve el mismo problema de conexiones agotadas en el otro motor.
¿Falta algo o en tu entorno es distinto? Escríbeme y lo corrijo.
What you'll get
Running a PostgreSQL server on Linux with judgement: knowing which change applies with a reload and which needs a restart, granting permissions that do not break on the next deployment, opening access in pg_hba.conf only to whoever needs it, seeing whether autovacuum keeps up, understanding when connections are short and when there are too many, finding the heaviest queries and resolving the ticket "the application cannot connect" by reading which of the three typical errors it is.
Before you start
- PostgreSQL 16 or 17 on Ubuntu, Debian or RHEL (project packages, PGDG), with
sudoon the server. You log in as superuser withsudo -u postgres psql. - The application account, the backup account, the scheduled backup and replica lag are in the MySQL, MariaDB and PostgreSQL: least privilege and backups guide; the blocking session and the full disk, in the PostgreSQL, MySQL and SQL Server one.
- A change window for whatever needs a restart (step 1).
1. Where everything lives and what needs a restart
| Install | Configuration | Data | Service | Log |
|---|---|---|---|---|
| Ubuntu / Debian | /etc/postgresql/17/main/ | /var/lib/postgresql/17/main/ | postgresql@17-main | /var/log/postgresql/postgresql-17-main.log |
| RHEL (PGDG) | /var/lib/pgsql/17/data/ | /var/lib/pgsql/17/data/ | postgresql-17 | /var/lib/pgsql/17/data/log/ |
When in doubt, the server itself tells you:
sudo -u postgres psql -c "SHOW config_file;" -c "SHOW hba_file;" config_file----------------------------------------- /etc/postgresql/17/main/postgresql.conf hba_file------------------------------------- /etc/postgresql/17/main/pg_hba.confsudo -u postgres psql -c "SELECT name, setting, context, pending_restart FROM pg_settings WHERE name IN ('max_connections','listen_addresses','shared_preload_libraries','idle_in_transaction_session_timeout','log_min_duration_statement');" name | setting | context | pending_restart-------------------------------------+-----------------------+------------+----------------- idle_in_transaction_session_timeout | 0 | user | f listen_addresses | localhost,10.20.40.11 | postmaster | f log_min_duration_statement | -1 | superuser | f max_connections | 100 | postmaster | f shared_preload_libraries | | postmaster | fThe context column answers the question for every change:
postmaster: service restart (max_connections,listen_addresses,shared_preload_libraries,shared_buffers). It drops every connection: change window.sighup,superuser,user: a reload is enough (SELECT pg_reload_conf();orsudo systemctl reload postgresql@17-main). Open sessions carry on.pending_restart = tafter a reload: the value is in the file but will not apply until a restart. It is how you know a change "did not take".
pg_hba.conf is not a parameter, but it works the same way: reload, not restart. For postgresql.conf changes, better an included file of your own (include_dir = 'conf.d', which Debian already ships) or ALTER SYSTEM SET ..., which writes to postgresql.auto.conf in the data directory and is read last.
2. Roles and privileges: the schema and what gets created tomorrow
In PostgreSQL permissions sit at three levels, and missing any of them gives permission denied:
- Database:
GRANT CONNECT ON DATABASE erp TO .... - Schema:
GRANT USAGE ON SCHEMA erp TO ...(see what is inside) and, only for whoever creates tables,CREATE. - Objects:
GRANT SELECT ... ON ALL TABLES IN SCHEMA erpfor tables that already exist, andALTER DEFAULT PRIVILEGESfor those created later.
Since PostgreSQL 15, the public schema no longer lets anyone create objects (CREATE revoked from PUBLIC) and its owner is pg_database_owner. An application migrated from an old version that created tables in public without explicit grants fails with permission denied for schema public after the upgrade. The right fix is a schema of the application's own with its owner, not giving CREATE back to everyone.
CREATE ROLE erp_lectura NOLOGIN;GRANT CONNECT ON DATABASE erp TO erp_lectura;GRANT USAGE ON SCHEMA erp TO erp_lectura;GRANT SELECT ON ALL TABLES IN SCHEMA erp TO erp_lectura;ALTER DEFAULT PRIVILEGES FOR ROLE erp_owner IN SCHEMA erp GRANT SELECT ON TABLES TO erp_lectura;GRANT erp_lectura TO laura_prado;\dn+ erp List of schemas Name | Owner | Access privileges | Description------+-----------+--------------------------+------------- erp | erp_owner | erp_owner=UC/erp_owner +| | | erp_rw=U/erp_owner +| | | erp_lectura=U/erp_owner |\ddp Default access privileges Owner | Schema | Type | Access privileges-----------+--------+-------+----------------------- erp_owner | erp | table | erp_rw=arwd/erp_owner+ | | | erp_lectura=r/erp_owner\dn+shows the schema's permissions (U= USAGE,C= CREATE);\dp erp.facturas, a table's (r= SELECT,a= INSERT,w= UPDATE,d= DELETE);\ddp, the default privileges.- The
ALTER DEFAULT PRIVILEGEStrap: it only applies to objects created by theFOR ROLErole. If a deployment runs under another account (orpostgres), its new tables do not inherit them and the application gets permission denied that same night. Deployments always as the owner (erp_owner), and\ddpin the review. - For read-everything and monitoring there are predefined roles:
pg_read_all_data(read every table, PostgreSQL 14+),pg_monitorandpg_read_all_stats(see other users' activity and query text). Better that than making the monitoring tool a superuser.
3. pg_hba.conf: who gets in, from where and how
Each line is type database user address method, and the first match wins, top to bottom. If none matches, the connection is rejected.
# TYPE DATABASE USER ADDRESS METHODlocal all postgres peerlocal all all peerhost all backup 127.0.0.1/32 scram-sha-256hostssl erp erp_app 10.20.30.0/24 scram-sha-256hostssl erp +erp_lectura 10.20.50.0/24 scram-sha-256host all all 0.0.0.0/0 rejectlocalis the Unix socket;peerlets in the OS user with the same name (that is whysudo -u postgres psqlworks).hostis TCP with or without TLS;hostssl, TLS only.+erp_lecturameans "any member of that role": joiners and leavers are handled withGRANT/REVOKE, without touching the file.scram-sha-256is the recommended method and the default password encryption since PostgreSQL 14 (password_encryption = scram-sha-256). An account whose password was stored as MD5 (a database migrated from an old version) cannot log in through ascram-sha-256line: its password has to be set again.SELECT rolname FROM pg_authid WHERE rolpassword LIKE 'md5%';tells you which. MD5 is deprecated as of PostgreSQL 18: do not use it in new lines.- No
trustoutside a lab, and no0.0.0.0/0with any method other thanreject.
Before reloading, check the file parses:
sudo -u postgres psql -c "SELECT line_number, type, database, user_name, address, auth_method, error FROM pg_hba_file_rules WHERE error IS NOT NULL;" line_number | type | database | user_name | address | auth_method | error-------------+------+----------+-----------+---------+-------------+--------------------------------- 96 | host | {erp} | {erp_app} | | | invalid IP mask "/33": ...sudo -u postgres psql -c "SELECT pg_reload_conf();"pg_hba_file_rules reads the file as it is on disk, not what is loaded: if it shows errors, a reload would keep the previous file in use and the log would say pg_hba.conf was not reloaded. Fix it and look again until nothing comes back. And remember the server has to be listening on that network: listen_addresses (by default only localhost) needs a restart.
4. Autovacuum and bloated tables
An UPDATE or DELETE does not remove the row: it leaves a dead tuple that VACUUM later marks as reusable space. Autovacuum does it on its own, per table, when dead tuples exceed autovacuum_vacuum_threshold (50) plus autovacuum_vacuum_scale_factor (0.2, i.e. 20%) of the table. On a 50-million-row table, that is 10 million dead rows before it starts.
SELECT relname, n_live_tup, n_dead_tup, round(100.0*n_dead_tup/greatest(n_live_tup,1),1) AS pct_dead, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 4; relname | n_live_tup | n_dead_tup | pct_dead | last_autovacuum | last_autoanalyze----------------+------------+------------+----------+-------------------------------+------------------------------- movimientos | 48211904 | 9120533 | 18.9 | 2026-10-19 04:12:51.20871+01 | 2026-11-03 06:40:02.11734+01 sesiones_web | 211560 | 1904377 | 900.2 | 2026-11-03 09:58:11.40325+01 | 2026-11-03 09:58:12.00412+01 facturas | 918244 | 3104 | 0.3 | 2026-11-02 23:10:44.88102+01 | 2026-11-02 23:10:45.10288+01Two different cases on the same screen:
movimientos: the last autovacuum was two weeks ago. With the default 20%, it arrives late on a large table. Lower the threshold for that table only:ALTER TABLE erp.movimientos SET (autovacuum_vacuum_scale_factor = 0.01);(1%).sesiones_web: autovacuum ran minutes ago and there are still 9 dead rows per live one. That means it cannot clean up: some transaction open for hours is holding those row versions. Find it like this:
SELECT pid, usename, application_name, state, now()-xact_start AS open_for, age(backend_xmin) AS xmin_age FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY age(backend_xmin) DESC LIMIT 3; pid | usename | application_name | state | open_for | xmin_age-------+------------+------------------+---------------------+-----------------+---------- 31877 | informes | DBeaver 24.2 | idle in transaction | 05:41:12.30211 | 8812440 32014 | erp_app | erp-api | active | 00:00:00.41022 | 12An idle in transaction session of almost six hours from a desktop client. While it stays open, no VACUUM can clean up what has changed since. End it with SELECT pg_terminate_backend(31877); (telling the user) and set a cap for the account: ALTER ROLE informes SET idle_in_transaction_session_timeout = '15min';. Replicas with hot_standby_feedback and abandoned replication slots hold things back the same way: check pg_replication_slots too.
- Bloat:
VACUUMleaves the free space inside the table for reuse; it does not give it back to the disk. To measure it properly, thepgstattupleextension (SELECT * FROM pgstattuple('erp.sesiones_web');, fieldfree_percent). - To reclaim disk,
VACUUM FULLrewrites the table but locks it completely while it runs: only in a window and on small tables. On large tables with no downtime, the pg_repack extension. - Set
log_autovacuum_min_duration = '1min'(since PostgreSQL 15 it defaults to 10 minutes) to see in the log which tables take long to clean.
5. Connections and PgBouncer
Each PostgreSQL connection is an OS process with its own memory. max_connections defaults to 100 and changes with a restart. Raising it to 1,000 is not free: each query can use up to work_mem per sort or hash operation, and with hundreds of active connections memory runs out.
SELECT usename, client_addr, state, count(*) FROM pg_stat_activity WHERE backend_type = 'client backend' GROUP BY 1,2,3 ORDER BY 4 DESC; usename | client_addr | state | count----------+-------------+---------------------+------- erp_app | 10.20.30.31 | idle | 58 erp_app | 10.20.30.32 | idle | 21 erp_app | 10.20.30.31 | active | 4 informes | 10.20.50.18 | idle in transaction | 179 idle connections for 4 active ones: the application opens many and uses few. That is the case for PgBouncer: a pool in front of PostgreSQL that takes hundreds of client connections and serves them with a few real ones.
[databases]erp = host=127.0.0.1 port=5432 dbname=erp [pgbouncer]listen_addr = 10.20.40.11listen_port = 6432auth_type = scram-sha-256auth_file = /etc/pgbouncer/userlist.txtpool_mode = transactionmax_client_conn = 1000default_pool_size = 20admin_users = pgbouncer_admin- The application points to port 6432 instead of 5432; the rest of the connection string stays the same.
pool_mode = transactionsaves the most: the real connection goes back to the pool at the end of each transaction. Anything that depends on the session (SETwithoutLOCAL, advisory locks, temporary tables,LISTEN) does not behave the same; prepared statements do work, since PgBouncer 1.21 withmax_prepared_statements. Confirm it with the application team or start withpool_mode = session.psql -h 10.20.40.11 -p 6432 -U pgbouncer_admin pgbouncer -c "SHOW POOLS;"shows per pool the active and waiting clients (cl_active,cl_waiting) and the real connections (sv_active,sv_idle). A sustainedcl_waitingabove 0 means the pool is too small.
6. pg_stat_statements: the heaviest queries
pg_stat_activity says what is happening now; pg_stat_statements accumulates every query grouped by shape, with timings and calls. It has to be loaded at startup (one restart, once):
sudo -u postgres psql -c "ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';"sudo systemctl restart postgresql@17-mainsudo -u postgres psql -d erp -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"SELECT calls, round(total_exec_time/1000) AS total_s, round(mean_exec_time::numeric,1) AS mean_ms, rows, left(query,70) AS query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 3; calls | total_s | mean_ms | rows | query--------+---------+---------+---------+------------------------------------------------------------------------ 412880 | 6120 | 14.8 | 412880 | SELECT * FROM erp.movimientos WHERE cuenta_id = $1 AND fecha >= $2 ORDER BY 288 | 2210 | 7673.6 | 288 | SELECT cliente_id, sum(importe) FROM erp.facturas f JOIN erp.lineas l ON 91220 | 480 | 5.3 | 91220 | UPDATE erp.sesiones_web SET ultimo_acceso = $1 WHERE id = $2- Sort by
total_exec_time: the first query takes 15 ms, but runs 400,000 times and adds up to more than the 7.7-second report. Values show up as$1,$2. - The next step is
EXPLAIN (ANALYZE, BUFFERS)with real values, in a test environment or on a read-only query: aSeq Scanovermovimientoswith millions of rows calls for an index, created without blocking writes withCREATE INDEX CONCURRENTLY idx_mov_cuenta_fecha ON erp.movimientos (cuenta_id, fecha);. SELECT pg_stat_statements_reset();zeroes the counters: do it after a change to measure before and after. Seeing other users' query text needs superuser orpg_read_all_stats.- To see each slow query in the log:
log_min_duration_statement = '2s'and reload.
7. The ticket: "the application cannot connect to the database"
Tuesday, 8:45. The ERP will not open at two sites; at another it works. The application shows a generic error. The first thing is to read the exact FATAL in the server log, because there are three very different ones:
sudo grep -o "FATAL: .*" /var/log/postgresql/postgresql-17-main.log | sort | uniq -c | sort -rn | head 412 FATAL: no pg_hba.conf entry for host "10.20.31.14", user "erp_app", database "erp", SSL encryption 97 FATAL: sorry, too many clients already 12 FATAL: remaining connection slots are reserved for ...| Message | What it means | Fix | Reload or restart? |
|---|---|---|---|
no pg_hba.conf entry for host ... | It reached the server, but no line in pg_hba.conf accepts that IP, user and database | Add the right line | Reload |
sorry, too many clients already | All max_connections slots in use, reserved ones included | Free connections; pooling | Neither to free them; restart if max_connections is raised |
remaining connection slots are reserved for ... | Only the slots reserved for superusers (superuser_reserved_connections, 3 by default) or for pg_use_reserved_connections (reserved_connections, since PostgreSQL 16) are left | Same as above: it is about to fill up | Same |
Case 1: no pg_hba.conf entry. IP 10.20.31.14 belongs to one site's new network, migrated over the weekend. pg_hba.conf only has 10.20.30.0/24:
sudoedit /etc/postgresql/17/main/pg_hba.conf# add below the 10.20.30.0/24 line:# hostssl erp erp_app 10.20.31.0/24 scram-sha-256sudo -u postgres psql -tAc "SELECT count(*) FROM pg_hba_file_rules WHERE error IS NOT NULL;"0sudo -u postgres psql -c "SELECT pg_reload_conf();"psql "host=srv-db-02.contoso.local dbname=erp user=erp_app sslmode=require" -c "SELECT 1;"- The line goes before any catch-all
reject: the first match wins. - The message says
SSL encryptionorno encryption: if the client connects without TLS and the line ishostssl, it does not match either. - If the error changes to
password authentication failed for user "erp_app", the line now matches: the problem is the password or an MD5 hash against ascram-sha-256method (step 3). - And if the client gets
Connection refusedor a timeout, it never even arrived:listen_addresses, the firewall (5432/tcp) or the network. That leaves no FATAL in the log.
Cases 2 and 3: connections exhausted. Log in as superuser through the socket (sudo -u postgres psql uses one of the reserved slots; if you get too many clients already, not even that is left) and see who holds them:
SHOW max_connections; 100SELECT usename, client_addr, state, count(*), max(now()-state_change) AS oldest FROM pg_stat_activity WHERE backend_type='client backend' GROUP BY 1,2,3 ORDER BY 4 DESC LIMIT 3; usename | client_addr | state | count | oldest---------+-------------+---------------------+-------+----------------- erp_app | 10.20.30.32 | idle | 74 | 13:02:44.11 erp_app | 10.20.30.31 | active | 6 | 00:00:01.20 erp_app | 10.20.30.32 | idle in transaction | 11 | 02:15:09.84SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename='erp_app' AND client_addr='10.20.30.32' AND state IN ('idle','idle in transaction') AND now()-state_change > interval '10 minutes';- Restore service: close the idle connections from that source older than 10 minutes, as above. No PostgreSQL restart needed, and restarting would also cut off the users who are working.
- The cause: one node (
10.20.30.32) with 85 connections, 11 of themidle in transactionfor two hours. To the application team: the per-node pool size and the transaction that never closes. - Let the server defend itself:
ALTER ROLE erp_app SET idle_in_transaction_session_timeout = '10min';(applies to new sessions of that role, no restart) and, if the application tolerates it,idle_session_timeoutfor idle sessions with no transaction. - The real fix: PgBouncer (step 5) in front, with the application on 6432. Raising
max_connectionsis the last option: it needs a restart in a window and the memory to back it.
In the ticket: which FATAL it was (with the count from the log), the line added to pg_hba.conf or the connections closed and from which source, whether it was a reload or a restart, and the new alert: connections > 80% of max_connections with postgres_exporter.
Next steps
- Monitor connections by state, dead tuples per table, the age of the oldest transaction and PgBouncer's
cl_waitingwithpostgres_exporterand the Grafana guide. - For high availability with automatic failover, Patroni on top of streaming replication; for physical backups with point-in-time recovery, pgBackRest.
- The MySQL and MariaDB guide solves the same exhausted-connections problem on the other engine.
Is something missing, or different in your environment? Get in touch and I'll fix it.
Tech Career Lab · newsletter
¿Te ha servido? Cada dos semanas envío una idea práctica para perfiles IT: LinkedIn, proyectos, networking y procesos de selección.Found this useful? Every two weeks I send one practical idea for IT professionals: LinkedIn, projects, networking and hiring processes.
Apúntate y llévate las 33 ideasSign up and get the 33 ideas →