PostgreSQL never learns that a client is on a VPN. It receives a TCP connection from an address and a set of credentials, and it decides what to do with them. Making access depend on the tunnel therefore means arranging three separate decisions so that they agree, then checking the one address the server actually sees rather than the one you drew on the diagram.

Three decisions that must not merge

Where the server listens is one decision. Which traffic can reach that address is a second, made by the host firewall and any network boundary in front of it. Which database identity may authenticate from which source is a third, made in pg_hba.conf.

Each one is enforced by a different component, and each one fails with a different message. Merging them in a discussion is how people end up editing the HBA file to fix a firewall problem. Keep them in order: a listener that is not bound cannot be reached, a filtered port cannot be connected to, and an HBA rule decides only what happens after a connection exists. When a change has no effect, identify which of the three layers you actually changed.

Where the server listens

Bind to an address that is actually assigned to the machine, not to an interface name:

listen_addresses = '10.20.0.30'

The database may sit behind the VPN gateway with no tunnel interface of its own, which is exactly why an address is the right thing to name. Changing this setting takes a full restart; a reload is not enough.

Then filter the port, normally 5432, on the host and at every boundary in front of it. Binding to a private address is not proof of public denial, because a port forward or a cloud address mapping can still deliver packets to that private listener.

The address the HBA actually sees

With routed access the server sees each client's inner tunnel address. With source translation it sees the gateway's translated address instead, and every client collapses into that single value. Inspect the source through the server's own connection evidence before you choose a prefix to allow.

For a deployment already using TLS and SCRAM, a deliberately narrow remote-access example looks like this:

hostssl appdb app_login 10.8.0.0/24 scram-sha-256
host all all 0.0.0.0/0 reject
host all all ::/0 reject

That permits one role and one database over TLS from the stated pool, then rejects other remote TCP access. It is not a replacement for a complete HBA file: keep local administration, replication and any other authorised service, and place the narrow rules before broader allowances.

When translation is in play, the HBA cannot tell clients apart by their original addresses. Keep network admission tight and require real database credentials, and never substitute trust authentication for an actual account.

First match wins, with no fallback

PostgreSQL reads the file top to bottom and uses the first record that matches the connection. There is no second attempt if that record's authentication fails, so an earlier broad allow silently defeats a later reject.

That ordering rule is the most common reason a carefully written reject never takes effect. Put the specific rules first and the broad ones last, then read the file as a sequence rather than as a set. A rule you added at the bottom is not a policy, it is a comment. Move it up and reload before you conclude that the file is being ignored.

The HBA reference defines both the order and the difference between host and hostssl records, which matters when the point of the rule is that the connection must be encrypted.

Reload, restart, and the denied case

HBA changes are applied by a reload, and the pg_hba_file_rules view helps you find parse errors. That view describes the file's current contents, not whether the running server successfully loaded them, so a clean listing is not proof that the new rules are live.

Listener changes need their own restart, and the two operations are easy to confuse when a change appears to have no effect.

Test with fresh sessions: an approved client with the intended account connects, the same account from an unapproved path does not, and an unauthorised database identity still fails from inside the VPN. Those three results separate the network boundary from database authorisation, which is the whole point of keeping the decisions apart.

Artiglyn mynjee