Install PostgreSQL 16 on Ubuntu and Bind It Right
Install PostgreSQL 16 on Ubuntu from PGDG, bind listen_addresses to a private IP, and prove that port 5432 is closed to the public internet.
Netbay Engineering
Netbay Engineering
On this page
A PostgreSQL install is not finished when SELECT version() works. It is finished when the postmaster is listening on the address you chose, and nowhere else. Ubuntu packages start a cluster bound to localhost, which is safe and useless for a two-tier app. The usual fix is worse: listen_addresses set to a star, port 5432 on a public IP, and scanners knocking within minutes. This guide installs PostgreSQL 16 from the PGDG repository on Ubuntu 22.04 or 24.04, then binds the cluster to a private IPv4 and proves the public interface cannot reach it.
Work on a fresh VPS in Lucknow DC01. Intel Xeon Platinum and High-Speed SSD are enough for this install; the risk is the bind, not the CPU.
Add PGDG and install PostgreSQL 16
Ubuntu 24.04 already ships 16, but the PGDG archive pins the same major on 22.04 and gives you a predictable upgrade path. Install as root, enable the systemd cluster, and confirm the version before you touch postgresql.conf.
sudo apt-get update
sudo apt-get install -y postgresql-common curl ca-certificates
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt-get install -y postgresql-16 postgresql-contrib-16
sudo systemctl enable --now postgresql
sudo -u postgres psql -c "SELECT version();"
sudo ss -lntp | grep 5432Config lives in /etc/postgresql/16/main. Data lives in /var/lib/postgresql/16/main. The unit name is postgresql@16-main. ss should show 127.0.0.1:5432 only. If you already see 0.0.0.0:5432, stop and fix the bind before opening any firewall rule.
Bind listen_addresses to a private IP
Find the private IPv4 this VPS uses to talk to the application host. That is the only address PostgreSQL should accept. Do not set listen_addresses to a star. Do not set it to 0.0.0.0. Both put 5432 on the public NIC.
ip -4 addr show
sudo python3 - <<'PY'
from pathlib import Path
p = Path("/etc/postgresql/16/main/postgresql.conf")
text = p.read_text()
lines = []
for line in text.splitlines():
if line.startswith("listen_addresses") or line.startswith("#listen_addresses"):
lines.append("listen_addresses = '10.0.0.4'")
else:
lines.append(line)
p.write_text("\n".join(lines) + "\n")
PY
sudo grep -n "^listen_addresses" /etc/postgresql/16/main/postgresql.conf
sudo systemctl restart postgresql@16-main
sudo ss -lntp | grep 5432Replace 10.0.0.4 with the private address of this machine. After restart, ss must list 10.0.0.4:5432 and must not list 0.0.0.0:5432. A bind of star means you rolled the wrong value; revert before you continue.
postgresql.conf is the listen socket. pg_hba.conf is who may authenticate once they hit that socket. You need both. Binding correctly does not replace host-based authentication, and host-based authentication does not hide a socket that is already public.
Firewall the port; do not trust the bind alone
UFW on the VPS should allow 5432 only from the application host. L3/L4 DDoS filtering at the Netbay edge drops volumetric noise; it does not replace a host allowlist. If the app is on 10.0.0.12, the rule is that source and nothing else.
sudo ufw allow OpenSSH
sudo ufw allow from 10.0.0.12 to any port 5432 proto tcp
sudo ufw deny 5432/tcp
sudo ufw enable
sudo ufw status numbered
sudo -u postgres psql -c "SHOW listen_addresses;"
nc -vz 10.0.0.4 5432From the application host, nc should succeed. From a laptop on the public IP, it should time out. If the public check connects, you bound too wide or UFW is not filtering that interface. Fix that before you create roles or load data.
Keep the postgres OS user for local admin only. Remote application traffic should use a dedicated role over the private address, with scram-sha-256 in pg_hba.conf. The next posts in this series cover roles and pg_hba in detail. For this install, the definition of done is: cluster running, listen_addresses equal to the private IP, ss agreeing, UFW allowing only the app host, public 5432 closed.
A bind of 127.0.0.1 is correct for a co-located app on the same VPS, including a local PgBouncer. A bind of the private IPv4 is correct when the app lives on a second instance. A bind of the public IPv4 is almost never correct. If you think you need it, put an SSH tunnel or a VPN in front and keep 5432 off the internet. Scanners enumerate 5432 as routinely as 22, and a default password is enough for them.
Verify after reboot
A restart is not a reboot. Reboot once so systemd brings the cluster back on the same bind. Then repeat ss and the two nc checks. If listen_addresses reverted, you edited the wrong file or a drop-in in /etc/postgresql/16/main/conf.d overwrote it. Put the bind in a small conf.d file if you want the main file untouched, but keep a single source of truth.
Takeaway
Install 16, leave the data directory on High-Speed SSD, and treat listen_addresses as a security control. Bind the private IP, allow 5432 from the app host only, and confirm the public address times out. You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and follow this bind checklist on Lucknow DC01 — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance