I’m fairly sure Barack Obama was in his first year as president the first time I fielded this question. Gas averaged about $2.35 a gallon, a gallon of milk was about $3.11, Postgres 8.4 had just shipped, and a lot of serious people were still arguing about whether anyone would run a production database on EC2.

Since then we’ve had the smartphone take over, the move to cloud, everything turning into SaaS, social media making everyone weird (LinkedIn included), a couple of crypto cycles, and now AI agents that write their own SQL. Thank goodness for that last one, because I no longer have to look at JOIN Venn diagrams. Yet in 2026, I still get the same question from long-tenured Oracle DBAs, and it usually comes in three parts:

  1. Postgres has no separation of duties, because it has to be installed with root.
  2. It has to live on the root volume.
  3. It can’t run under a proper directory account.

Each of those claims is wrong, but the people making them aren’t careless. They’re applying a mental model built over years of running Oracle, and Postgres on Linux is built on a different model.

This post walks through what the Postgres packages actually do, the standards they follow, and what Oracle does legitimately well concerning separation of duties.

Installing as root is not running as root

The first criticism treats two separate things as one. Installing software with the package manager requires root. Running the database requires an unprivileged service account. On Linux those are supposed to be different people with different privileges, and that split is separation of duties.

Postgres enforces its half in the server code. If you try to start it as root, it refuses:

"root" execution of the PostgreSQL server is not permitted.
The server must be started under an unprivileged user ID to prevent
possible system security compromise.  See the documentation for
more information on how to properly start the server.

So the real question is what root does during install, and what the service account can touch afterwards.

What the package actually does

The packages in this post come from the PostgreSQL community (PGDG) repositories, and the people behind them get far less credit than they should. Devrim Gündüz maintains the RPMs at yum.postgresql.org, and Christoph Berg leads the Debian and Ubuntu packages at apt.postgresql.org. Between them they ship every supported major version across the major RPM and Debian-based distributions, plus hundreds of extensions, usually with new builds out on the day of each community release. Most of the layout decisions covered below are theirs, and they’ve held up for well over a decade. If Postgres has made your life easier, they are to thank.

Using the PGDG RPMs for Postgres 18 on a RHEL-family system as the example:

What Where Owned by
Server and client binaries /usr/pgsql-18/bin root
Libraries and extensions /usr/pgsql-18/lib, /usr/pgsql-18/share root
Data directory /var/lib/pgsql/18/data postgres
systemd unit /usr/lib/systemd/system/postgresql-18.service root
Runtime socket dir /run/postgresql postgres

Root installs the package, runs the one-time postgresql-18-setup initdb, and enables the service. The package drops files into place, creates the postgres system account through systemd-sysusers if one doesn’t already exist, and registers the unit. From then on the server runs as postgres (via User=postgres in the unit), which owns the database state it manages and none of the installed software.

The postgres account itself follows a long-standing Unix convention: every daemon gets its own unprivileged system account, the same way apache, named and sshd do. System accounts sit in a reserved UID range below normal users (SYS_UID_MAX in /etc/login.defs, 999 on current RHEL), so they’re easy to tell apart from people and easy to audit. On RHEL-family systems postgres is UID and GID 26, a static reservation in Red Hat’s base setup package, so it’s normally the same on every RHEL host unless your site has pre-allocated something different. Debian allocates its system UIDs dynamically, so don’t assume 26 there.

The detail Oracle DBAs tend to miss is the first row. The service account can execute the binaries, but it can’t modify them. You can check this yourself:

rpm -qlv postgresql18 postgresql18-server | grep /bin/
rpm -V postgresql18 postgresql18-server

The second command checks every installed file against the local RPM database: size, digest, owner, permissions. If someone changes a binary, rpm -V tells you. It’s not proof against an attacker who already has root, since they can tamper with the RPM database too, but as routine hygiene it comes free with every package on the box, and your OS patching process already covers Postgres because it arrives through the same channel.

How Oracle does it

Oracle’s Optimal Flexible Architecture (OFA) is a real standard, and it was well thought out for its time. It was designed so multiple versions of Oracle, owned by different users, can live on the same server. The software goes in an Oracle home such as /u01/app/oracle/product/19.0.0/dbhome_1, under an Oracle base owned by the oracle user.

Oracle also needs root to install. Every DBA has run orainstRoot.sh and root.sh at the end of an install. Those scripts set inventory permissions, write /etc/oratab, and on Grid Infrastructure they start the clusterware stack. So “requires elevated privileges to install” applies to both.

The difference is who owns the binaries afterwards. In a conventional OFA install, the oracle account owns the Oracle home. Unless you add controls on top, the account that runs the database can modify the software it runs. In addition, a handful of helpers in the Oracle home are setuid root, such as extjob for external jobs and jssu on 11g and later. The exact set varies by version.

During a Grid Infrastructure install, the Grid home is changed to root:oinstall with 755 permissions. That’s much closer to the ownership model an RPM gives Postgres by default.

A well-run Oracle shop locks down its homes and audits those setuid bits. The point is that “Oracle owns everything” isn’t the same as “the service account can’t touch the binaries”, and the Linux package model gives you the second one without any extra work.

The Filesystem Hierarchy Standard

The Linux side of this is the Filesystem Hierarchy Standard (FHS), now maintained under freedesktop.org. It’s the cross-vendor agreement on where things go: static, shareable software in /usr, configuration in /etc, variable state in /var/lib, logs in /var/log, runtime files in /run. Distribution packages generally follow it, which matters to whoever has to audit or harden the whole server rather than one product.

The security-relevant parts of FHS are followed strictly by the Postgres packages. /usr is treated as read-only and root-owned. Mutable data goes under /var/lib. The service account owns only the state it needs to write.

The one place the RPM layout differs from FHS is minor. FHS 3.0 says large software packages must not use a direct subdirectory under /usr, which /usr/pgsql-18 does, and Debian’s lintian checker has a non-standard-dir-in-usr warning for the same pattern.

The reason for the deviation is the same reason OFA exists. Postgres major versions change the system catalogs, so pg_upgrade needs the old and new binaries installed side by side, 17 and 18 for example. Versioned directories make that trivial. The choice is deliberate, and it costs nothing on the ownership and permission side.

The Debian packages from apt.postgresql.org solve the same problem differently. Each major version installs to /usr/lib/postgresql/18, and /usr/bin/psql, pg_dump and friends are symlinks to pg_wrapper, which picks the binary for the cluster you’re talking to. (psql and a couple of other client tools always use the newest installed version, since they’re backward compatible.) pg_lsclusters, pg_createcluster and pg_upgradecluster give you multi-version, multi-cluster management.

So there are two layouts, both defensible, and neither is wrong. If you work across distributions you’ll notice the difference pretty quickly.

Putting PGDATA on your own storage

Nothing about Postgres requires data on the root volume. /var/lib/pgsql/18/data is a default, not a requirement. Point PGDATA at whatever mount your storage team gives you.

On a RHEL-family system with the PGDG packages, that’s a systemd drop-in rather than an edit to the shipped unit file:

# systemctl edit postgresql-18
[Unit]
RequiresMountsFor=/pgdata/18

[Service]
Environment=PGDATA=/pgdata/18/data

With the mount defined in fstab or as a mount unit, RequiresMountsFor keeps the service from starting against an empty directory if the mount fails. postgresql-18-setup reads PGDATA from the unit, so the override is all it needs.

Then confirm the mount, create the directory, label it for SELinux, and initialize:

findmnt /pgdata
mkdir -p /pgdata/18/data
chown postgres:postgres /pgdata/18 /pgdata/18/data
chmod 700 /pgdata/18/data
semanage fcontext -a -t postgresql_db_t "/pgdata(/.*)?"
restorecon -Rv /pgdata
/usr/pgsql-18/bin/postgresql-18-setup initdb

Do the same labeling for a separate WAL mount or a tablespace location.

Leave SELinux on, and check what it’s confining

A lot of long-tenured admins, Postgres admins certainly included, still turn SELinux off as step one of a database build. The habit dates back to the RHEL 4 era, when policies were incomplete, denials showed up as a generic “permission denied”, and vendor install guides said to disable it. The tooling is much better now. ausearch -m avc shows what was denied, audit2why tells you why, and most Postgres problems turn out to be a missing file context.

What you get for leaving it on is a control that doesn’t depend on file ownership. File permissions say what the postgres user can touch. SELinux says what the Postgres process can touch, including a shell launched through COPY ... TO PROGRAM.

There’s a catch with the PGDG packages. The policy that ships with RHEL labels /usr/bin/postgres, where Red Hat’s own package puts it, but has no rule for /usr/pgsql-18/bin. So a stock PGDG server typically runs as unconfined_service_t rather than the confined postgresql_t domain. Check yours:

ps -eZ | grep postgres

If it’s unconfined, label the binaries the way the policy does for Red Hat’s package and restart:

semanage fcontext -a -t postgresql_exec_t "/usr/pgsql-18/bin/(postgres|initdb|pg_ctl|postgresql-18-check-db-dir)"
restorecon -v /usr/pgsql-18/bin/*
systemctl restart postgresql-18

Do this on a test host first. Once the server is confined, a mislabeled data directory or a non-default port will start producing denials that the unconfined server never hit. The DISA STIG and CIS benchmarks for RHEL both check SELinux mode, so it’s worth getting right either way.

Running under a directory account

This is the criticism I’d push back on hardest, because the Linux packaging rules were written with this exact case in mind.

The Fedora packaging guidelines, which RHEL-family packages follow, say that if the username a package wants to create already exists, the package uses the existing account instead of creating a new one. The guidelines specifically mention sites that use LDAP or other network directories to pre-allocate system accounts sitewide. The PGDG RPMs create the postgres account with systemd-sysusers, which skips any account the system can already resolve.

So the enterprise recipe is:

  1. Create the service account in your directory (AD or LDAP), with a UID that’s consistent across your fleet.
  2. Make sure SSSD resolves it on the host (getent passwd postgres and getent group postgres return the directory entries).
  3. Install the package, then run getent passwd postgres again. If SSSD couldn’t resolve the account at install time, you’ll get a local postgres user instead, and it takes precedence over the directory entry.

If your standards require a different account name than postgres, that’s possible, but it’s more than overriding User= and Group= in the drop-in. The setup script, the tmpfiles entry for /run/postgresql, and the ownership of /var/lib/pgsql all assume postgres. Postgres itself doesn’t care what the account is called, only that it owns the data directory and isn’t root.

One caveat the Fedora guidelines call out, and I’d repeat it: make sure SSSD caches the account locally. If the host loses its connection to the directory and can’t resolve the service account, the database won’t start. Test that failure mode before production does it for you. Also test the package install against your own directory setup before rolling it out; packaging scriptlets change over time and you want to know what yours actually does.

Beyond packaging, Oracle is strong on separation of duties

Oracle has administrative privileges split by job role: SYSBACKUP for RMAN, SYSDG for Data Guard, SYSKM for key management, SYSASM for storage, each mapped to its own OS group. Postgres has one superuser that can do everything. The list of predefined roles keeps growing (pg_monitor, pg_read_all_data in 14, pg_checkpoint in 15, pg_maintain in 17), and you should use them so day-to-day work doesn’t need superuser. Physical backups don’t need superuser either; a role with REPLICATION can run pg_basebackup. That role can still get at everything through the backup it takes, though. Oracle’s SYSBACKUP is a purpose-built backup and recovery privilege that can’t query application tables, even though the backup files contain the same data. Either way, the backups themselves need protecting.

A Postgres superuser can also reach the operating system. COPY ... TO PROGRAM runs a shell command as the service account, and so can several extensions. That’s documented behavior, and it’s exactly why an unprivileged service account, root-owned binaries and a confined SELinux domain matter so much. Treat superuser like you’d treat root, and grant pg_execute_server_program, pg_read_server_files and pg_write_server_files to nobody unless you have a specific reason.

Managed cloud services have to take this seriously. If one tenant could reach the host OS through SQL, a malicious actor could use that foothold for lateral movement across the provider’s infrastructure. So they don’t typically hand out superuser at all. You get an admin role with a subset of superuser privileges, and the real superuser stays with the provider. Depending on the provider, the engine may be community Postgres with provider patches or something more heavily modified. On your own servers that boundary is your job.

Finally, layouts differ. RHEL’s own PostgreSQL packages put binaries in /usr/bin, the PGDG RPMs use /usr/pgsql-18, Debian uses /usr/lib/postgresql/18, and commercial distributions have their own paths again. Each one is internally consistent, but a team used to one Oracle layout on every platform will need to document which one they’ve standardized on.

What I’d check on your own servers

If you’re evaluating Postgres against your security standards, these are the things worth verifying on a real host rather than taking anyone’s word for it, mine included:

  1. ps -o user= -p "$(systemctl show -p MainPID --value postgresql-18)" shows the service account, not root.
  2. rpm -qlv postgresql18 postgresql18-server (or dpkg -L plus ls -l) shows the binaries owned by root and not writable by the service account.
  3. find /usr/pgsql-18 -type f -perm -4000 returns nothing. The PGDG packages ship no setuid binaries.
  4. rpm -V postgresql18 postgresql18-server comes back clean after install and after every patch.
  5. The service account resolves from your directory, and the database still starts when the directory is unreachable.
  6. PGDATA, WAL and tablespaces sit on the mounts your storage standards call for, with SELinux enforcing.
  7. ps -eZ | grep postgres shows postgresql_t, not unconfined_service_t.

If any of those come back wrong, look at how the host was built and how the package behaves before assuming it’s a Postgres limitation. Sometimes it’s a packaging bug worth reporting.

I fully expect to answer this question again. Sometime after conscious AI agents have disrupted the entire economy, quantum computers have gone mainstream, and we’ve surely got our politics together and are living in an age of abundance paid for by all those leaps forward, a long-tenured DBA will ask me why Postgres has to run as root. It doesn’t.