
Every sysadmin has that one server that “just needs PostgreSQL installed quickly” — and every sysadmin knows how that story usually ends. A rushed install, default settings left untouched, and three months later you’re staring at a slow query log at 2 AM wondering why shared_buffers was never adjusted. Installing PostgreSQL on Ubuntu 26.04 LTS is technically a five-minute job. Doing it right, in a way that survives a traffic spike or a security audit, is a different conversation entirely.
Ubuntu 26.04 LTS (codenamed Resolute Raccoon) ships with PostgreSQL 18 baked directly into its default repositories. That’s a meaningful shift from older LTS releases, where you’d often need the PGDG repository just to get a current major version. PostgreSQL 18 itself brings genuinely useful changes — an asynchronous I/O subsystem, OAuth 2.0 authentication support, and checksums enabled by default on new clusters. None of that matters much if the install is sloppy, though.
This guide walks through the installation the way it would actually happen on a real production box: package install, service verification, user and database creation, remote access configuration, firewall rules, and the performance tuning that separates a database that “works” from one that holds up under real traffic. It also covers the troubleshooting scenarios that come up often enough to be worth documenting rather than relearning every time.
Whether the target is a WordPress backend, a Node.js API, or a data pipeline feeding into an analytics dashboard, the fundamentals here apply. The steps are accurate for Ubuntu 26.04 LTS, and largely transferable to Debian 13 and CentOS/AlmaLinux with minor package-manager differences noted along the way.
Why PostgreSQL 18 on Ubuntu 26.04 Matters
Before touching the terminal, it helps to understand what’s actually different this time around. PostgreSQL 18’s headline feature is the asynchronous I/O engine, controlled through io_method, io_workers, and io_max_concurrency. On kernels with io_uring support — which Ubuntu 26.04’s default kernel has — this can meaningfully cut I/O wait during large sequential scans and bulk writes. That’s not marketing fluff; it’s a real architectural change to how PostgreSQL talks to disk.
There’s also self-join elimination, DISTINCT key reordering for smarter query plans, and an idle replication slot timeout that stops abandoned replication slots from silently bloating your WAL directory. That last one alone has saved more than one production disk from filling up unexpectedly. Data page checksums are now on by default for new clusters too, which catches storage-level corruption before it becomes a 3 AM incident.
Prerequisites
Before installing anything, confirm the basics are in place:
- A server running Ubuntu 26.04 LTS, fresh or existing, with
sudoaccess. - At least 512 MB RAM, though 1 GB or more is the realistic floor for anything beyond a toy project.
- SSD-backed storage if this is a production workload — spinning disks will bottleneck you fast.
- A clear idea of who needs to connect to this database and from where (this shapes the firewall and
pg_hba.confdecisions later).
Step 1: Update the System and Install PostgreSQL
Start with a clean package index. Skipping this step is how you end up installing an outdated cached version and wondering why features are missing.
sudo apt update
sudo apt install -y postgresql postgresql-client
This pulls in PostgreSQL 18.3, the client tools, and JIT compilation support via LLVM — the total download is modest, around 18 MB. The installer does more than just drop binaries on disk. It automatically creates a cluster named 18/main, initializes the data directory, and starts the service immediately. No manual initdb step required, which is a nice quality-of-life improvement over compiling from source.
Confirm the install succeeded:
psql --version
Expected output:
psql (PostgreSQL) 18.3 (Ubuntu 18.3-1)
Then check the cluster status directly:
pg_lsclusters
You should see something like:
Ver Cluster Port Status Owner Data directory Log file
18 main 5432 online postgres /var/lib/postgresql/18/main /var/log/postgresql/postgresql-18-main.log
If the status column says anything other than “online,” stop here and check the logs before proceeding — chasing configuration issues on a cluster that never fully started is a waste of time.
Step 2: Verify the Service Is Running Correctly
sudo systemctl status postgresql
A healthy service shows active (exited) with enabled in the load line, meaning it will survive a reboot. That “exited” status looks alarming to anyone new to PostgreSQL’s systemd setup, but it’s normal — the parent postgresql.service unit hands off to per-cluster units like postgresql@18-main.service.
Confirm it’s actually listening on the expected port:
ss -tlnp | grep 5432
Fresh installs bind only to localhost by default:
LISTEN 0 200 127.0.0.1:5432 0.0.0.0:*
LISTEN 0 200 [::1]:5432 [::]:*
That’s intentional and correct for a first boot. Opening it up comes later, deliberately, not by accident.
Step 3: Connect and Explore the Shell
The install creates a Linux system user called postgres, which owns the entire database cluster. Use it to drop into the interactive shell:
sudo -u postgres psql
Check the version from inside the shell to be thorough:
SELECT version();
PostgreSQL 18.3 (Ubuntu 18.3-1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 15.2.0-14ubuntu1) 15.2.0, 64-bit
Exit with \q when done. This peer-authenticated shortcut works because Ubuntu’s default pg_hba.conf trusts the local Unix socket for the postgres OS user — a convenience, not a security hole, since it requires actual shell access to the server first.
Step 4: Create a Dedicated Database and User
Running an application against the postgres superuser account is a habit worth breaking early. It’s the database equivalent of running your web app as root — it works, right up until it catastrophically doesn’t.
sudo -u postgres psql -c "CREATE USER appuser WITH PASSWORD 'StrongPassword123';"
CREATE ROLE
Create a database owned specifically by that user:
sudo -u postgres psql -c "CREATE DATABASE appdb OWNER appuser;"
CREATE DATABASE
Test the new credentials before wiring anything into your application config:
PGPASSWORD=StrongPassword123 psql -h localhost -U appuser -d appdb -c "SELECT current_user, current_database();"
current_user | current_database
--------------+------------------
appuser | appdb
(1 row)
That connection authenticates using scram-sha-256, which is the modern default for TCP/IP connections in PostgreSQL 18 — a meaningful upgrade over the older md5 method, since it never transmits a password hash over the wire.
For applications needing least-privilege access rather than full ownership, scope permissions more tightly:
GRANT CONNECT ON DATABASE appdb TO appuser;
GRANT USAGE ON SCHEMA public TO appuser;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO appuser;
Step 5: Configure Remote Access (When Actually Needed)
Plenty of setups never need this — a Django app on the same box as its database has no business exposing port 5432 externally. But when the database sits on separate infrastructure from the application tier, two files need edits.
First, the main configuration file:
sudo vi /etc/postgresql/18/main/postgresql.conf
Find listen_addresses and change it:
listen_addresses = '*'
Then edit the client authentication file to whitelist your actual network range — never use 0.0.0.0/0 here unless you enjoy explaining data breaches to management:
sudo vi /etc/postgresql/18/main/pg_hba.conf
Append:
# Allow remote connections from the local network
host all all 10.0.1.0/24 scram-sha-256
Restart to apply:
sudo systemctl restart postgresql
Confirm it’s now listening broadly:
ss -tlnp | grep 5432
LISTEN 0 200 0.0.0.0:5432 0.0.0.0:*
LISTEN 0 200 [::]:5432 [::]:*
Step 6: Open the Firewall Properly
If UFW is active — and on a production Ubuntu server, it should be — allow the port explicitly rather than disabling the firewall entirely (a surprisingly common shortcut that shouldn’t exist):
sudo ufw allow 5432/tcp comment 'PostgreSQL'
sudo ufw reload
Tighter and generally preferable: restrict access to a known subnet or specific application server IP.
sudo ufw allow from 10.0.1.0/24 to any port 5432 proto tcp comment 'PostgreSQL from local network'
If the database only ever talks to an app server on the same host, skip opening the port altogether and rely on the Unix socket or localhost connections. Fewer open ports means fewer things to worry about during an audit.
Performance Tuning That Actually Matters
Default PostgreSQL configuration is intentionally conservative — it’s built to run on machines with 512 MB of RAM, not a dedicated 16-core database server. Leaving defaults untouched on production hardware is one of the most common performance mistakes out there.
sudo vi /etc/postgresql/18/main/postgresql.conf
Adjust these based on actual available RAM:
# Memory
shared_buffers = 1GB # ~25% of total RAM
effective_cache_size = 3GB # ~75% of total RAM
work_mem = 16MB # Per-operation sort/hash memory
maintenance_work_mem = 256MB # For VACUUM, CREATE INDEX
# Write-Ahead Log
wal_buffers = 64MB
checkpoint_completion_target = 0.9
# Query Planner (SSD storage)
random_page_cost = 1.1
effective_io_concurrency = 200
# Async I/O (new in PG18)
io_method = worker # or 'io_uring' on supported kernels
io_workers = 3
io_max_concurrency = 64
# Connections
max_connections = 200
shared_buffers at roughly 25% of system RAM is the standard starting point. Going much higher rarely helps beyond 8–10 GB, because PostgreSQL leans on the OS page cache for the rest. work_mem, though, deserves extra caution — it’s allocated per operation, not per query, so a query with three sorts can consume it three times over. Set it too aggressively with many concurrent connections and you’ll exhaust RAM fast.
The io_method setting is genuinely new territory in PostgreSQL 18. On kernels supporting io_uring (5.1+), switching from the default worker mode can noticeably reduce I/O latency on heavy sequential workloads. Verify what mode is active with:
SHOW io_method;
Restart to apply configuration changes:
sudo systemctl restart postgresql
Indexing and Query Optimization
Slow databases are rarely a mystery once you look. Use EXPLAIN ANALYZE on suspect queries before assuming the hardware is the problem:
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
A Seq Scan where an Index Scan was expected usually means a missing index, or a table small enough that PostgreSQL’s planner decided scanning was cheaper. Different index types suit different data shapes:
| Index type | Best for | Typical use case |
|---|---|---|
| B-tree | Equality and range queries | Default choice, most WHERE/JOIN/ORDER BY clauses |
| GIN | Full-text search, JSONB, arrays | Searching inside complex column types |
| GiST | Geometric and spatial data | Location-based queries |
| BRIN | Very large, naturally ordered tables | Time-series or append-only logs |
Avoid wrapping indexed columns in functions inside WHERE clauses — EXTRACT(YEAR FROM created_at) = 2026 blocks index usage entirely, while a plain range comparison (created_at >= '2026-01-01' AND created_at < '2027-01-01') doesn’t.
Connection Pooling
Every new PostgreSQL connection carries real overhead — process forking, memory allocation, authentication handshake. Applications that open a fresh connection per request will feel this under load, especially during traffic spikes.
PgBouncer solves this by maintaining a reusable connection pool between the app and the database. Transaction pooling mode — releasing the connection after each transaction rather than holding it for a session — works best for typical web applications. A reasonable starting pool size is 2–3 connections per CPU core on the database server.
Autovacuum Tuning
PostgreSQL’s MVCC model means updates and deletes leave behind dead row versions rather than removing data immediately. Autovacuum cleans these up automatically, but the defaults aren’t always aggressive enough for write-heavy tables.
autovacuum_vacuum_scale_factor = 0.1
autovacuum_analyze_scale_factor = 0.05
autovacuum_naptime = 30s
For particularly busy tables, override at the table level:
ALTER TABLE your_busy_table SET (autovacuum_vacuum_scale_factor = 0.05);
Check pg_stat_user_tables for high n_dead_tup counts — that’s the tell that autovacuum is falling behind. On modern SSD-backed hardware, it’s also reasonable to loosen the cost-based vacuum throttling:
autovacuum_vacuum_cost_delay = 2ms
autovacuum_vacuum_cost_limit = 2000
Security Considerations
Security on a database server isn’t a single checkbox — it’s layered, and skipping any one layer eventually costs you.
- Never expose port 5432 to the public internet without restricting source IPs via UFW or a cloud security group.
- Rotate the
postgressuperuser password immediately after install rather than leaving it unset or default. - Use
scram-sha-256exclusively — it’s the Ubuntu 26.04 default, and there’s no good reason to fall back tomd5. - Apply the principle of least privilege: application users should get
SELECT/INSERT/UPDATE/DELETE, not superuser rights. - Keep the system patched with
sudo apt update && sudo apt upgrade -y postgresqlon a regular schedule. - Consider PostgreSQL 18’s new OAuth 2.0 authentication method for environments already using an identity provider.
- Enable and monitor
log_min_duration_statement— it doubles as both a performance and a security audit trail.
Backups: The Step That Gets Skipped Too Often
A database without a tested backup strategy is a liability wearing the disguise of infrastructure. At minimum, schedule pg_dump via cron:
sudo -u postgres crontab -e
0 2 * * * pg_dump appdb | gzip > /var/lib/postgresql/backups/appdb_$(date +\%Y\%m\%d).sql.gz
Create the target directory first:
sudo mkdir -p /var/lib/postgresql/backups
sudo chown postgres:postgres /var/lib/postgresql/backups
For anything mission-critical, layer in WAL archiving for point-in-time recovery. A nightly dump alone won’t help if the server dies at 4 PM and the last backup is fourteen hours stale.
Troubleshooting Common Issues
“Peer authentication failed for user postgres” — This happens when connecting as a Linux user that doesn’t match the PostgreSQL role name. Fix by switching to the correct system user first: sudo -u postgres psql, rather than running psql directly as your own login.
Connection refused from a remote host — Almost always one of two causes: listen_addresses still set to localhost in postgresql.conf, or the firewall blocking port 5432. Check both, in that order, before assuming pg_hba.conf is the problem.
“FATAL: no pg_hba.conf entry for host” — The client IP or subnet isn’t listed in pg_hba.conf, or the auth method specified doesn’t match what the client is sending. Add the correct CIDR range and reload with sudo systemctl reload postgresql (a full restart isn’t needed for pg_hba.conf changes).
Autovacuum can’t keep up, table bloat growing — Check pg_stat_user_tables.n_dead_tup. Lower autovacuum_vacuum_scale_factor for the affected table, or manually run VACUUM ANALYZE tablename; as an immediate fix while the config change takes effect.
Disk filling up unexpectedly with WAL files — Check for abandoned or lagging replication slots with SELECT * FROM pg_replication_slots;. PostgreSQL 18’s idle replication slot timeout helps here going forward, but existing stale slots from older setups still need manual cleanup.
High CPU with seemingly simple queries — Run EXPLAIN ANALYZE and look for sequential scans on large tables. Missing indexes are the usual culprit, followed closely by outdated table statistics — run ANALYZE tablename; to refresh the planner’s information.