Databases and Search Deployment guide Advanced

How to Run PostgreSQL Securely on a VPS

Keep the listener off the public internet, get pg_hba.conf right, require TLS for remote connections, and give the application a role that cannot drop your tables — on PostgreSQL 17 and Ubuntu 24.04.

A PostgreSQL server bound to a private interface with role privileges listed

Tested with

OperatingSystem
Ubuntu 24.04 LTS
Postgresql
17.5, PGDG apt packages
Psql
17.5
Openssl
3.0.13

Before you start

  • An Ubuntu 24.04 VPS with sudo access
  • PostgreSQL 17 installed from the PGDG repository
  • An application that connects with a connection string you control
On this page

The default PostgreSQL install on Ubuntu is already reasonably safe: it listens on loopback only and uses peer authentication for local sockets. Most security incidents come from changes people make afterwards — opening listen_addresses = '*' to get a remote client working, and handing the application the postgres superuser because a GRANT failed.

This covers the four things that matter: where it listens, how clients authenticate, whether the connection is encrypted, and what the application role can actually do.

Requirements

PostgreSQL 17 from the PGDG repository on Ubuntu 24.04. Configuration lives in Debian’s layout:

bash
sudo -u postgres psql -c 'SHOW config_file;'
Expected output
bash
              config_file
---------------------------------------
 /etc/postgresql/17/main/postgresql.conf

Everything below assumes that path. On a Docker install the same settings apply but arrive as command flags or a mounted config file.

Keep the listener off the public internet

/etc/postgresql/17/main/postgresql.conf
# Same-host application: leave it here.
listen_addresses = 'localhost'

# Another server on a private network: bind that interface explicitly.
# listen_addresses = 'localhost,10.0.1.14'

port = 5432
max_connections = 100

Never '*'. If a remote client needs access, name the private address. If the client is across the internet, use a VPN or an SSH tunnel instead:

bash
ssh -N -L 5432:localhost:5432 deploy@db.example.com

Confirm what is actually bound after a restart:

bash
sudo systemctl restart postgresql@17-main
sudo ss -tlnp | grep 5432
Expected output
bash
LISTEN 0  200  127.0.0.1:5432  0.0.0.0:*  users:(("postgres",pid=8842,fd=6))

127.0.0.1:5432 is correct. 0.0.0.0:5432 means the port is reachable from anywhere your firewall allows.

Authentication rules

pg_hba.conf is evaluated top to bottom and the first matching line wins — including when it rejects. A permissive line above a strict one makes the strict one dead code.

/etc/postgresql/17/main/pg_hba.conf
# TYPE     DATABASE  USER      ADDRESS        METHOD
local      all       postgres                 peer
local      all       all                      scram-sha-256

# Application on this host, over loopback.
host       appdb     app_rw    127.0.0.1/32   scram-sha-256

# Read-only analytics from the private network, TLS required.
hostssl    appdb     app_ro    10.0.1.0/24    scram-sha-256

# Nothing else.
host       all       all       0.0.0.0/0      reject
hostssl    all       all       0.0.0.0/0      reject

Three deliberate choices there:

scram-sha-256 rather than md5. MD5 password verifiers are trivially crackable from a database dump. Make sure new passwords are hashed that way:

/etc/postgresql/17/main/postgresql.conf
password_encryption = scram-sha-256

Existing users keep their old verifier until the password is set again, so after changing this, run ALTER ROLE ... PASSWORD ... for every role.

hostssl for the non-loopback rule, so a client cannot negotiate plaintext.

An explicit reject at the bottom. The implicit default is also rejection, but a written rule shows in pg_hba_file_rules and makes the intent reviewable.

Reload and check that PostgreSQL parsed the file the way you meant:

bash
sudo -u postgres psql -c 'SELECT pg_reload_conf();'
sudo -u postgres psql -x -c \
  'SELECT line_number, type, database, user_name, address, auth_method, error FROM pg_hba_file_rules;'

Any row with a non-null error is a line PostgreSQL ignored.

Turn on TLS

Generate a certificate for the server’s hostname. Self-signed is fine when you control the clients.

bash
cd /etc/postgresql/17/main
sudo openssl req -new -x509 -days 825 -nodes \
  -subj "/CN=db.internal.example.com" \
  -addext "subjectAltName=DNS:db.internal.example.com" \
  -out server.crt -keyout server.key
sudo chmod 600 server.key
sudo chown postgres:postgres server.crt server.key
/etc/postgresql/17/main/postgresql.conf
ssl = on
ssl_cert_file = '/etc/postgresql/17/main/server.crt'
ssl_key_file = '/etc/postgresql/17/main/server.key'
ssl_min_protocol_version = 'TLSv1.2'

PostgreSQL refuses to start if server.key is group- or world-readable, which is a useful check rather than an obstacle.

On the client, sslmode=require encrypts but does not verify the server — it stops passive sniffing, not an active attacker. Distribute server.crt and use verify-full:

bash
psql "host=db.internal.example.com dbname=appdb user=app_ro \
      sslmode=verify-full sslrootcert=/etc/ssl/certs/pg-internal.crt"

Create a least-privilege application role

The application must not own its own schema. Separate the owner from the login.

setup-roles.sql
-- Owner holds the objects. Nobody logs in as it.
CREATE ROLE app_owner NOLOGIN;
CREATE DATABASE appdb OWNER app_owner;
setup-privileges.sql
\connect appdb

-- Strip the rights every role gets implicitly.
REVOKE ALL ON DATABASE appdb FROM PUBLIC;
REVOKE ALL ON SCHEMA public FROM PUBLIC;

CREATE SCHEMA app AUTHORIZATION app_owner;

-- Read/write role used by the application.
CREATE ROLE app_rw LOGIN PASSWORD 'generate-this-do-not-type-it';
GRANT CONNECT ON DATABASE appdb TO app_rw;
GRANT USAGE ON SCHEMA app TO app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_rw;

-- Read-only role for analytics and dashboards.
CREATE ROLE app_ro LOGIN PASSWORD 'a-different-generated-string';
GRANT CONNECT ON DATABASE appdb TO app_ro;
GRANT USAGE ON SCHEMA app TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;

-- Future tables created by app_owner inherit these grants.
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
  GRANT USAGE, SELECT ON SEQUENCES TO app_rw;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
  GRANT SELECT ON TABLES TO app_ro;

The ALTER DEFAULT PRIVILEGES lines are the ones people miss. Without them, every migration that creates a table produces ERROR: permission denied for table ... the next time the app runs.

Generate passwords rather than inventing them:

bash
openssl rand -base64 24

Timeouts, limits and logging

A single stuck client holding an open transaction blocks VACUUM and can hold locks that stall writes. Set boundaries at the role level so they apply to the application without affecting your own admin sessions:

sql
ALTER ROLE app_rw SET statement_timeout = '30s';
ALTER ROLE app_rw SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE app_rw SET lock_timeout = '5s';
ALTER ROLE app_rw CONNECTION LIMIT 40;

Logging that is worth having on:

/etc/postgresql/17/main/postgresql.conf
log_connections = on
log_disconnections = on
log_min_duration_statement = 500      # log statements slower than 500 ms
log_line_prefix = '%m [%p] %q%u@%d '
log_lock_waits = on

log_min_duration_statement gives you slow queries without the volume of logging everything. log_connections turns a suspicious login into something you can find afterwards.

The CONNECTION LIMIT above matters more than it looks. PostgreSQL allocates a backend process per connection, each with its own memory, so max_connections is a resource decision rather than a capacity dial. Raising it to fix too many clients already trades one failure for a slower, harder-to-diagnose one.

The actual fix is a pooler. PgBouncer in transaction mode multiplexes hundreds of idle application connections onto a handful of server ones, because most application connections are idle most of the time. Point the application at the pooler, keep max_connections modest, and set the per-role limit as a backstop so one misbehaving service cannot consume every slot.

Transaction mode has one constraint worth knowing before you adopt it: session state does not survive between transactions. SET outside a transaction, session-level advisory locks and LISTEN/NOTIFY all behave differently. Most web applications are unaffected; check yours rather than assuming.

Verify

bash
# 1. Correct listener
sudo ss -tlnp | grep 5432

# 2. The app role cannot create tables
psql "postgresql://app_rw@127.0.0.1/appdb" \
  -c 'CREATE TABLE should_fail (id int);'
Expected output
bash
ERROR:  permission denied for schema app
bash
# 3. Remote connections are actually encrypted
psql "host=10.0.1.14 dbname=appdb user=app_ro sslmode=verify-full" \
  -c 'SELECT ssl, version, cipher FROM pg_stat_ssl WHERE pid = pg_backend_pid();'
Expected output
bash
 ssl | version |         cipher
-----+---------+------------------------
 t   | TLSv1.3 | TLS_AES_256_GCM_SHA384
bash
# 4. Nothing else on the network can reach the port
nmap -Pn -p 5432 203.0.113.42

That last one must report filtered or closed.

Common errors

FATAL: no pg_hba.conf entry for host "10.0.1.9", user "app_ro", database "appdb", no encryption — the client connected without TLS and only a hostssl rule matches. Add sslmode=require or better on the client side.

FATAL: password authentication failed for user "app_rw" with a password you know is right — the role’s stored verifier is still MD5 after you switched password_encryption. Re-set the password with ALTER ROLE.

connection to server at "127.0.0.1", port 5432 failed: Connection refused — PostgreSQL is not running, or listen_addresses does not include the address you are dialling. Check ss -tlnp before anything else.

FATAL: role "app_owner" is not permitted to log in — working as intended. NOLOGIN roles hold privileges; they are not accounts.

ERROR: permission denied for table invoices for a table that was just created by a migration — ALTER DEFAULT PRIVILEGES was not set, or the migration ran as a role other than app_owner.

FATAL: sorry, too many clients already — connections exceed max_connections. Raising it is rarely the fix; a connection pooler such as PgBouncer in transaction mode is, because idle application connections are what consume the slots.

PostgreSQL will not start after enabling TLS, with private key file "server.key" has group or world access in the log. Set it to 0600 and owned by postgres.

Production notes

Take backups with pg_dump -Fc at minimum, and add WAL archiving when losing a day of writes is not acceptable. Restore one into a scratch database on a schedule — the untested backup is the one that fails.

Keep the server patched. PostgreSQL minor releases are bug and security fixes only and are safe to apply; they need a restart, not a dump and reload. Major version upgrades are a separate exercise with a maintenance window.

Be deliberate about extensions. CREATE EXTENSION generally requires elevated privileges, and some extensions execute code with the server’s operating-system permissions. Install the ones you need as postgres, then leave the application role without the privilege to add more.

Set the search_path explicitly on the application role rather than relying on the default. A role whose search_path still includes a writable schema can be tricked into calling a shadowing function:

sql
ALTER ROLE app_rw SET search_path = app, pg_catalog;

Revoking CREATE ON SCHEMA public from PUBLIC, as the setup above does, closes most of that hole. Pinning the search path closes the rest.

Audit roles quarterly:

sql
SELECT rolname, rolsuper, rolcreatedb, rolcreaterole, rolcanlogin
FROM pg_roles
WHERE rolname NOT LIKE 'pg\_%'
ORDER BY rolsuper DESC, rolname;

Anything with rolsuper = t other than postgres needs a justification you can state out loud.

Frequently asked questions

Should PostgreSQL ever listen on a public IP address?

Almost never. Bind to `localhost` for a same-host application, or to the private network interface when another server needs it. If a remote client genuinely must connect over the internet, put it behind a VPN or an SSH tunnel rather than opening 5432.

What is the difference between host and hostssl in pg_hba.conf?

`host` matches both encrypted and unencrypted TCP connections; `hostssl` matches only TLS ones. Use `hostssl` for every non-loopback rule so a client cannot negotiate down to plaintext.

Why does my app get permission denied on tables created after setup?

`GRANT ... ON ALL TABLES` applies only to tables that existed at that moment. Use `ALTER DEFAULT PRIVILEGES` so future tables created by the owner role inherit the grants automatically.

Is a superuser role ever acceptable for an application?

No. A superuser bypasses every permission check, can read arbitrary files through server-side COPY, and can install extensions. Application code needs none of that, and a SQL injection in a superuser session is a host compromise rather than a data leak.

Do I need a certificate from a public CA for PostgreSQL TLS?

Not if you control both ends. A self-signed certificate with clients configured for `sslmode=verify-full` and the CA certificate distributed to them gives you encryption plus server authentication, which is what matters here.

Sources

  1. The pg_hba.conf file PostgreSQL Global Development Group Primary source Accessed 18 Jul 2026
  2. Secure TCP/IP connections with SSL PostgreSQL Global Development Group Primary source Accessed 18 Jul 2026
  3. GRANT PostgreSQL Global Development Group Primary source Accessed 18 Jul 2026
  4. ALTER DEFAULT PRIVILEGES PostgreSQL Global Development Group Primary source Accessed 18 Jul 2026
  5. Client connection defaults PostgreSQL Global Development Group Primary source Accessed 18 Jul 2026
4 min read

Part of a learning path

  • Docker intermediate

    How to Back Up Docker Volumes Automatically

    A nightly backup for a self-hosted Docker host: database dumps taken with the right tools, file volumes snapshotted with restic, a systemd timer to run it, and a restore test that proves it works.

    4 min AI-assisted
  • Security intermediate

    How to Secure a VPS That Runs Docker Containers

    SSH hardening, a default-deny firewall, and the fix for the problem that catches almost everyone — Docker publishing container ports straight past UFW into the public internet.

    4 min
  • Cloudflare beginner

    How to Point a Cloudflare Domain at Your VPS

    Create the A record, pick the right proxy status, set the SSL mode to Full (strict) and lock the origin so only Cloudflare can reach it — plus what errors 521, 522, 525 and 526 actually mean.

    4 min