Put your app and its PostgreSQL database on two private servers

Run your database on its own server, reachable only from your app server over the private network, with a password, TLS and a firewall rule.

Tested on Ubuntu 24.04 LTS

Before you start

  • Two Ubuntu 24.04 servers on your private network (the same host)
  • Root or a user with sudo on both
  • PostgreSQL installed on the database server

To give your app its own database server, put both servers on your private network, make PostgreSQL listen on the database server's private address, allow only the app server's private address in pg_hba.conf (over TLS only) and in the firewall, and connect from the app with sslmode=require. The database never listens on the public internet, and its traffic doesn't count toward your monthly transfer.

Each of the four layers (where PostgreSQL listens, its access rules, the firewall, the password) stops a different mistake, so this tutorial sets them all and then checks each one: the app gets in over TLS, an unencrypted login is refused, your other servers don't get in, and the public address doesn't answer at all.

What we tested: on 11 November 2026 we ran these steps with Ubuntu 24.04 and PostgreSQL 16 on three machines sharing a 10.240.0.0/16 network, the same range as Rack4's private network: an app server at 10.240.0.10, the database at 10.240.0.11, and a third server at 10.240.0.12. The machines were containers on one host rather than Rack4 servers, so the private network card itself wasn't part of the test; the private network guide covers checking that. On 12 November 2026 we ran steps 1 to 3 and 5 again in the same way (Ubuntu 24.04 containers, Ubuntu's PostgreSQL 16.15 package, the same addresses) to test the hostssl rules, the refused unencrypted connection, the rule order, \password, and reload and restart. The firewall wasn't part of that second run. Every result quoted below is from these runs.

The examples use these names. Replace them with yours:

What In this tutorial
App server's private address 10.240.0.10
Database server's private address 10.240.0.11
Database appdb
Database user app

Before you start

  1. Both servers are on your private network, and each Private network page lists the other. Order the database server with Join my private network ticked, or join both from their Network tabs.
  2. From the app server, ping -c 3 10.240.0.11 answers. If it doesn't, If it doesn't connect goes through why.
  3. PostgreSQL is installed on the database server: Install PostgreSQL on Ubuntu.

1. Create the database and its user

On the database server:

bash
sudo -u postgres psql -c "CREATE ROLE app LOGIN" -c "CREATE DATABASE appdb OWNER app"

The role is created without a password, so none is typed on the command line. Generate one (openssl rand -base64 24 works), then set it at psql's prompt:

bash
sudo -u postgres psql
text
postgres=# \password app
Enter new password for user "app":
Enter it again:
postgres=# \q

\password reads the password without echoing it and sends PostgreSQL only its SCRAM hash, so the password never appears in your shell history, the process list or the server log. In our test the stored value began SCRAM-SHA-256$, and the log didn't contain the password. Keep it in the app's configuration, not in the repository.

2. Listen on the private address

PostgreSQL listens only on localhost out of the box. Add the private address, and only that:

bash
sudo sed -i "s/^#\?listen_addresses.*/listen_addresses = 'localhost,10.240.0.11'/" /etc/postgresql/16/main/postgresql.conf

(16 is the version Ubuntu 24.04 installs; ls /etc/postgresql shows yours.) Don't use '*': that would also listen on the public address, and only the firewall would stand in the way.

3. Allow only the app server, and only over TLS

Add these two lines to the end of /etc/postgresql/16/main/pg_hba.conf:

text
hostssl    appdb    app    10.240.0.10/32    scram-sha-256
hostnossl  all      all    10.240.0.0/16     reject
  • The hostssl line lets the user app into appdb from the app server's private address only, with a password, and only over TLS. A plain host line would also accept the same login unencrypted.
  • The hostnossl ... reject line refuses every unencrypted connection from the private network, and says so in the error. Without it, an unencrypted connection would still be refused (no line would match), but this line makes that explicit.

PostgreSQL reads pg_hba.conf from the top and uses the first line that matches the connection type, database, user and address; later lines are never consulted. Ubuntu's default lines above yours are local ... peer (admin through sudo -u postgres psql) and host lines for 127.0.0.1 and ::1 only, so they don't match private-network traffic and local administration keeps working. Check the file for any earlier line that is broader, such as host all all 0.0.0.0/0, host all all 10.240.0.0/16 or host all all all. In our test, adding host all all 10.240.0.0/16 scram-sha-256 above the two lines let sslmode=disable back in (ssl was f). Remove such a line, or put your two lines above it. To see the rules PostgreSQL loaded, in order, run sudo -u postgres psql -c "select line_number, type, database, user_name, address, auth_method from pg_hba_file_rules".

Then restart (needed for listen_addresses; later pg_hba.conf edits need only sudo systemctl reload postgresql) and check where PostgreSQL listens:

bash
sudo systemctl restart postgresql
sudo ss -ltn | grep 5432

You should see 127.0.0.1:5432 and 10.240.0.11:5432, and no 0.0.0.0 or public address.

4. Firewall: allow the app server's address

With ufw on the database server (keep SSH open first, or you'll lock yourself out):

bash
sudo ufw allow 22/tcp
sudo ufw allow from 10.240.0.10 to any port 5432 proto tcp
sudo ufw default deny incoming
sudo ufw enable

The panel firewall doesn't cover the private network, so this rule on the server is what limits which of your servers can reach the database.

5. Connect from the app

On the app server (sudo apt install postgresql-client for psql):

bash
psql "host=10.240.0.11 dbname=appdb user=app sslmode=require" -c "select current_user, ssl from pg_stat_ssl where pid = pg_backend_pid();"

In our test this answered app | t: logged in as app, over TLS. Now force an unencrypted connection from the same server, which should fail:

bash
psql "host=10.240.0.11 dbname=appdb user=app sslmode=disable" -c "select 1;"
text
FATAL:  pg_hba.conf rejects connection for host "10.240.0.10", user "app", database "appdb", no encryption

That shows the server requires TLS, whatever the client asks for. The private network itself isn't encrypted, so this matters.

Ubuntu's PostgreSQL turns TLS on with a self-signed certificate (ssl = on and the ssl-cert-snakeoil files in postgresql.conf), and the two client settings differ:

  • sslmode=require encrypts the connection but doesn't check which server answered. It works with the self-signed certificate, and it's what this tutorial uses.
  • sslmode=verify-full also checks that the server's certificate was signed by a CA you trust and that it names the host you connected to. It needs a certificate from your own CA on the server, issued for the name or address the app connects to, and the CA certificate on the app server (sslrootcert=).

Your app's connection string is the same thing in URL form:

text
postgresql://app:<password>@10.240.0.11:5432/appdb?sslmode=require

6. Check each layer

These are the results from our test runs:

  • The app server connects with sslmode=require: connected as app, ssl=true.
  • The app server with sslmode=disable: FATAL: pg_hba.conf rejects connection for host "10.240.0.10", user "app", database "appdb", no encryption.
  • Another of your servers, with no firewall rule: connection to server at "10.240.0.11", port 5432 failed: timeout expired. The firewall dropped it.
  • The same server with a firewall rule but no pg_hba.conf line: FATAL: no pg_hba.conf entry for host "10.240.0.12", user "app", database "appdb", SSL encryption. PostgreSQL's own rules refused it, and with sslmode=disable the reject line refused it.
  • The database server's public address: timeout expired. PostgreSQL doesn't listen there, and the firewall drops it before that. (Without the firewall, the answer was Connection refused.)
  • Local administration: sudo -u postgres psql still logs in as postgres.
  • After systemctl reload postgresql and after a restart: the app connects over TLS, and sslmode=disable is still refused. The settings are in the configuration files, not just in memory.

Run the first four checks from your own servers after any change to the database server's configuration.

Backups and moves

Back the database up from the database server itself: Backups you should run has pg_dump schedules, and Restore a PostgreSQL database covers moving it to a new server. A server restored from a backup comes back without its private network card: join it again, check its private address with ip -br addr, and update the app's connection string and pg_hba.conf if it changed.

What we didn't test

The Rack4 private network card itself (covered in the private network guide), MariaDB or MySQL (the same layers apply: bind-address, user host rules, the firewall), and certificate checking with verify-full.

Ready when you are

Order a server