To install PostgreSQL on Ubuntu 24.04, first decide which major version you need. Ubuntu 24.04’s standard postgresql package installs PostgreSQL 16, while the official PostgreSQL Apt repository (PGDG) provides newer supported majors. This tutorial uses PGDG to install PostgreSQL 18, whose current maintenance release is 18.6 as of September 7, 2026.
On Raff Technologies, this setup is for teams that want a self-hosted PostgreSQL server with operating-system and database control on a Linux VM. If you would rather offload provisioning, patching, backups, and routine database operations, compare this path with Raff Managed PostgreSQL before you deploy.
The shortest PostgreSQL 18 install path on Ubuntu 24.04 is:
sudo apt update sudo apt install -y postgresql-common sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh sudo apt install -y postgresql-18 postgresql-client-18
This tutorial goes further: you will verify the repository and running version, create an application database and role, confirm SCRAM authentication, keep PostgreSQL private to the VM, run an authenticated CRUD test, and perform a logical backup/restore smoke test.
Ubuntu repository vs PGDG:
sudo apt install postgresqlis valid and installs Ubuntu’s maintained PostgreSQL 16 package on Ubuntu 24.04. Use the PGDG steps below when you specifically want PostgreSQL 18 and its PostgreSQL-project maintenance stream.
Prerequisites
- An Ubuntu 24.04 LTS server; this workflow was originally tested on a Raff 2 vCPU / 4 GB RAM Linux VM
- SSH access with a sudo-capable user
- A recovery or console path before making firewall changes
- At least 2 GB RAM for development/light workloads; production sizing should be based on measured connections, working set, query load, storage, and recovery requirements
- A unique application database password stored outside source code
- A backup plan before modifying an existing PostgreSQL installation
This revision keeps PostgreSQL bound to loopback. Applications on the same VM connect through 127.0.0.1; port 5432 is not opened to the public internet.
Step 1 — Update Ubuntu 24.04 and verify the release
Update installed packages:
sudo apt update sudo apt upgrade -y
Install the packages used by the official PostgreSQL repository workflow and later checks:
sudo apt install -y curl ca-certificates postgresql-common
Verify the Ubuntu release:
. /etc/os-release printf '%s\n%s\n' "$PRETTY_NAME" "$VERSION_CODENAME"
Expected values include:
Ubuntu 24.04 LTS noble
If /var/run/reboot-required exists after the upgrade, reboot before installing PostgreSQL:
if [ -f /var/run/reboot-required ]; then echo "Reboot required before continuing" fi
Verify: the server should be Ubuntu 24.04 with codename noble, packages should be current, and any required reboot should be handled before the database installation.
Step 2 — Add the official PostgreSQL Apt repository
The PostgreSQL project documents an automated PGDG repository setup through postgresql-common:
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
This adds the PostgreSQL Apt repository for the detected Ubuntu release and refreshes package metadata.
Verify the configured source:
grep -R "apt.postgresql.org" \ /etc/apt/sources.list.d/pgdg.list \ /etc/apt/sources.list.d/pgdg.sources 2>/dev/null
Expected output should reference both:
apt.postgresql.org noble-pgdg
Confirm that PostgreSQL 18 is available before installing it:
apt-cache policy postgresql-18 | sed -n '1,14p'
You should see a non-empty Candidate version from the PGDG repository.
Verify: the repository must resolve to apt.postgresql.org, target noble-pgdg, and expose a PostgreSQL 18 candidate package.
Step 3 — Install PostgreSQL 18 and verify the maintenance release
Install the PostgreSQL 18 server and matching client:
sudo apt install -y postgresql-18 postgresql-client-18
Verify the client major version:
psql --version
Expected format:
psql (PostgreSQL) 18.x
List database clusters managed by Ubuntu’s PostgreSQL tooling:
pg_lsclusters
A new installation normally includes an 18/main cluster on port 5432:
Ver Cluster Port Status Owner Data directory 18 main 5432 online postgres /var/lib/postgresql/18/main
Query the actual server version rather than assuming it from the package name:
sudo -u postgres psql -tAc "SHOW server_version;"
As of September 7, 2026, the current stable PostgreSQL 18 maintenance release is 18.6. PostgreSQL 18.5 was never released; 18.6 followed 18.4. A future maintenance update may therefore report a later 18.x version when you run these commands.
If another major PostgreSQL version already exists, stop here and review pg_lsclusters before changing ports, packages, or data directories.
Verify: psql and the running server should both be PostgreSQL 18, and pg_lsclusters should show the intended cluster online.
Step 4 — Verify service health and local readiness
Check the cluster-specific systemd unit:
systemctl is-active postgresql@18-main systemctl is-enabled postgresql@18-main
Expected state:
active enabled
Check whether PostgreSQL accepts connections on its local TCP endpoint:
sudo -u postgres pg_isready -h 127.0.0.1 -p 5432
Expected output:
127.0.0.1:5432 - accepting connections
Get the data directory and active configuration file from the server itself:
sudo -u postgres psql -tAc "SHOW data_directory;" sudo -u postgres psql -tAc "SHOW config_file;"
This is safer than hard-coding configuration paths when multiple PostgreSQL majors or clusters may exist.
Verify: the 18/main service should be active and enabled, pg_isready should succeed, and PostgreSQL should report the expected 18/main paths.
Step 5 — Create a dedicated application role and database
Open psql as the administrative postgres role:
sudo -u postgres psql
Create a login role and an application database owned by that role:
CREATE ROLE raffappuser WITH LOGIN; CREATE DATABASE raffapp OWNER raffappuser; \password raffappuser
When \password prompts you, enter a unique password twice. The password is not placed in shell history.
Exit:
\q
Verify the role:
sudo -u postgres psql -tAc \ "SELECT rolname, rolcanlogin FROM pg_roles WHERE rolname = 'raffappuser';"
Expected output includes:
raffappuser|t
Verify the database owner:
sudo -u postgres psql -tAc \ "SELECT datname || '|' || pg_get_userbyid(datdba) FROM pg_database WHERE datname = 'raffapp';"
Expected output:
raffapp|raffappuser
Do not reuse the postgres superuser from an application. A dedicated login role limits application privileges and makes credential rotation clearer.
Verify: raffappuser should be login-enabled and should own only the tutorial application database you created.
Step 6 — Verify SCRAM authentication instead of assuming it
Find the active host-based authentication file:
HBA_FILE="$(sudo -u postgres psql -tAc 'SHOW hba_file;' | xargs)" echo "$HBA_FILE"
Inspect the active rules without editing them:
sudo -u postgres psql -P pager=off -x -c \ "SELECT line_number,type,database,user_name,address,auth_method,error FROM pg_hba_file_rules ORDER BY line_number;"
For localhost TCP connections, current Ubuntu/PostgreSQL defaults normally use scram-sha-256.
Verify the password-encryption setting:
sudo -u postgres psql -tAc "SHOW password_encryption;"
Expected output:
scram-sha-256
Now test the application role over TCP:
psql -h 127.0.0.1 -U raffappuser -d raffapp -W \ -c "SELECT current_user, current_database();"
Enter the application password when prompted.
Expected output includes:
current_user | current_database --------------+----------------- raffappuser | raffapp
Do not weaken authentication to trust to make a failed connection work. If authentication differs from the expected SCRAM path, first identify which pg_hba.conf rule matched and why.
Verify: password_encryption should be scram-sha-256, the localhost HBA rule should require authentication, and raffappuser should authenticate successfully to raffapp.
Step 7 — Keep PostgreSQL off the public network
Check the configured listen addresses:
sudo -u postgres psql -tAc "SHOW listen_addresses;"
For this tutorial, expected output is:
localhost
Inspect the actual listening sockets:
sudo ss -lntp | grep ':5432'
Expected listeners are loopback only, such as:
127.0.0.1:5432 [::1]:5432
If you see 0.0.0.0:5432, [::]:5432, or a public interface address, do not proceed as though the database is private. Find the active configuration file:
CONFIG_FILE="$(sudo -u postgres psql -tAc 'SHOW config_file;' | xargs)" echo "$CONFIG_FILE"
Set the database back to loopback-only unless your architecture intentionally requires private-network access:
listen_addresses = 'localhost'
Then restart the cluster and verify the sockets again:
sudo systemctl restart postgresql@18-main sudo ss -lntp | grep ':5432'
Do not enable UFW blindly from a remote shell
The PostgreSQL bind address is the primary control in this single-VM design. Check UFW state separately:
sudo ufw status verbose
If UFW is already active, confirm there is no broad ALLOW rule exposing 5432/tcp. An explicit deny can be added as defense in depth when it does not conflict with your private-network design:
sudo ufw deny 5432/tcp sudo ufw status numbered
If UFW is inactive, do not enable it solely to complete this PostgreSQL tutorial. First preserve the real SSH administration path and follow the lockout-safe UFW setup guide.
For an application on another VM, prefer a private network, bind PostgreSQL only to the required private interface, use restrictive pg_hba.conf source ranges, and do not expose port 5432 to the public internet.
Verify: PostgreSQL should listen only on loopback for this tutorial, and no firewall rule should deliberately expose TCP 5432 to the public internet.
Step 8 — Run an authenticated CRUD test
Create a temporary PGPASSFILE so the remaining automated checks can authenticate without embedding the password in commands or scripts:
read -rsp "PostgreSQL password: " DB_PASSWORD echo PGPASSFILE="$(mktemp)" chmod 600 "$PGPASSFILE" printf '127.0.0.1:5432:*:raffappuser:%s\n' "$DB_PASSWORD" > "$PGPASSFILE" unset DB_PASSWORD export PGPASSFILE
Run create, insert, update, and select operations as the application role:
psql -h 127.0.0.1 -U raffappuser -d raffapp <<'SQL' CREATE TABLE tutorial_check ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); INSERT INTO tutorial_check (name, status) VALUES ('raff-postgresql-test', 'created'); UPDATE tutorial_check SET status = 'verified' WHERE name = 'raff-postgresql-test'; SELECT name, status FROM tutorial_check WHERE name = 'raff-postgresql-test'; SQL
Expected result includes:
name | status ----------------------+---------- raff-postgresql-test | verified
Confirm that the application role owns the table it created:
psql -h 127.0.0.1 -U raffappuser -d raffapp -tAc \ "SELECT tableowner FROM pg_tables WHERE schemaname='public' AND tablename='tutorial_check';"
Expected output:
raffappuser
Leave this one-row table in place temporarily; Step 9 uses it to prove the logical backup can be restored and queried.
Verify: the application role should create, insert, update, and read its own table successfully over authenticated TCP.
Step 9 — Prove a logical backup can be restored
A backup command succeeding is not the same as knowing the backup is usable. Create a small custom-format pg_dump backup from the tutorial database:
BACKUP_DIR="$HOME/postgresql-tutorial-backup" mkdir -p "$BACKUP_DIR" chmod 700 "$BACKUP_DIR" pg_dump \ -h 127.0.0.1 \ -U raffappuser \ -d raffapp \ -F c \ -f "$BACKUP_DIR/raffapp.dump"
Confirm the dump is non-empty and readable by pg_restore:
test -s "$BACKUP_DIR/raffapp.dump" && echo "Backup file is non-empty" pg_restore -l "$BACKUP_DIR/raffapp.dump" | head -n 20
Create an isolated restore-test database owned by the application role:
sudo -u postgres createdb -O raffappuser raffapp_restore_test
Restore without replaying original ownership or privilege metadata:
pg_restore \ -h 127.0.0.1 \ -U raffappuser \ -d raffapp_restore_test \ --no-owner \ --no-privileges \ "$BACKUP_DIR/raffapp.dump"
Query the restored row:
psql -h 127.0.0.1 -U raffappuser -d raffapp_restore_test -c \ "SELECT name, status FROM tutorial_check WHERE name='raff-postgresql-test';"
Expected result:
raff-postgresql-test | verified
Remove only the disposable restore database and tutorial table:
sudo -u postgres dropdb --if-exists raffapp_restore_test psql -h 127.0.0.1 -U raffappuser -d raffapp -c \ "DROP TABLE tutorial_check;"
Remove the temporary password file:
rm -f "$PGPASSFILE" unset PGPASSFILE
You can keep the small dump as tutorial evidence or remove it after inspection:
ls -lh "$BACKUP_DIR/raffapp.dump"
For production, a single pg_dump is not a complete recovery strategy. Define retention, off-server storage, encryption, monitoring, and—when required—WAL archiving and point-in-time recovery. See the PostgreSQL backup strategy guide and PostgreSQL backup to S3 with Restic tutorial.
Verify: the custom-format dump must be non-empty, listable by pg_restore, restore into an isolated database, and return the expected row before you call the smoke test successful.
Step 10 — Verify the complete PostgreSQL installation
Run the final server checks:
systemctl is-active postgresql@18-main pg_lsclusters sudo -u postgres pg_isready -h 127.0.0.1 -p 5432 sudo -u postgres psql -tAc "SHOW server_version;" sudo -u postgres psql -tAc "SHOW listen_addresses;" sudo ss -lntp | grep ':5432'
Verify the role and database still exist:
sudo -u postgres psql -tAc \ "SELECT rolname FROM pg_roles WHERE rolname='raffappuser';" sudo -u postgres psql -tAc \ "SELECT datname FROM pg_database WHERE datname='raffapp';"
The installation is complete when all of these are true:
- The
18/maincluster is online and its systemd service is active SHOW server_versionreports a supported PostgreSQL 18 maintenance releasepg_isreadyaccepts local connections on port 5432raffappuserauthenticates toraffappusing the intended password-authentication path- Authenticated create/read/update operations succeeded
- The logical backup restored successfully into
raffapp_restore_testand returned the expected row - PostgreSQL listens only on loopback addresses for this single-VM design
- No public firewall rule intentionally exposes port 5432