PostgreSQL 17 + PgBouncer on AWS User Guide
Overview
This guide covers launching and verifying PostgreSQL 17 + PgBouncer on Ubuntu 24.04 from cloudimg on Amazon EC2. It is one version of the cloudimg Hardened Adminer & Database Admin and Backup Tools AMIs listing on AWS Marketplace; you pick it as the software version when you subscribe and launch. The other tools in the listing are described on the listing's guide hub.
PgBouncer is a lightweight connection pooler for PostgreSQL. Applications connect to PgBouncer instead of to PostgreSQL directly, and PgBouncer multiplexes many short-lived client connections onto a small pool of PostgreSQL server sessions. In transaction pooling mode a server session is lent to a client only for the length of one transaction, so a connection-heavy application (web workers, serverless functions, many microservices) stays fast without one PostgreSQL backend process per client.
The image installs PostgreSQL 17.11 and PgBouncer 1.26.0 from the official PostgreSQL (PGDG) apt repository. The packages are not held, so apt upgrade keeps delivering PGDG minor (security) releases, and unattended security upgrades stay on for the operating system.
What is on the image:
- PostgreSQL 17.11 (
postgresql@17-main.service) on127.0.0.1:5432 - PgBouncer 1.26.0 (
pgbouncer.service) on127.0.0.1:6432in transaction pooling mode,scram-sha-256authentication, TLS required for every client - A database
appdbowned by the non-superuser roleappuser, which is the account your application uses through the pooler - A PgBouncer admin console user
pgbadminforSHOW POOLS,SHOW STATSand the other admin commands - A TLS certificate generated on your instance's first boot, used by PgBouncer and PostgreSQL (TLS 1.2 minimum)
Default user: ubuntu · Architecture: x86_64 · Root volume: 8 GiB gp3
Security posture
- No default password. The image ships with no password on any database role and no PgBouncer user list, and PgBouncer cannot start until first boot has run. First boot generates unique passwords for
appuserandpgbadminand writes them to/root/postgresql-pgbouncer-credentials.txt, readable by root only. Thepostgressuperuser has no password and is reachable only as the localpostgressystem user (peer authentication); it is not in PgBouncer's user list, so it cannot log in through the pooler. - Per-instance TLS certificate. First boot creates this instance's own private key and self-signed certificate. No key ships in the image, so no two instances share one.
- TLS required on the pooler. PgBouncer refuses any client that does not use TLS (
client_tls_sslmode = require). - Localhost only. PostgreSQL and PgBouncer listen on
127.0.0.1, and the listing's security group opens SSH (22) only. Reach the pooler through an SSH tunnel, or open it to your VPC as described in Step 7. - Remote PostgreSQL connections require TLS.
pg_hba.confaccepts non-loopback clients only overhostsslwithscram-sha-256; there is no plain-text remote rule and notrustrule. - No metadata access for the database or the pooler. A database user can make PostgreSQL open outbound connections (for example
COPY ... FROM PROGRAMas a superuser, ordblink), and PgBouncer connects to whatever hosts its[databases]section names, so PostgreSQL, PgBouncer and the first-boot service are all blocked from reaching the instance metadata service (169.254.169.254 and fd00:ec2::254). Amazon Time Sync (169.254.169.123) still works. - IMDSv2 required. The AMI is registered with
ImdsSupport v2.0, so instances launched from it refuse token-less (IMDSv1) metadata requests. - Fails closed. If first boot cannot finish (certificate, passwords or the pooler login check), it stops PgBouncer and PostgreSQL rather than leave them half configured, and tries again on the next boot.
- No secrets in logs. Passwords are never passed on a command line, so they do not appear in
/var/log/auth.logor the journal. - Per-instance identity. SSH host keys and the machine ID are generated fresh on every instance.
Prerequisites
- An AWS account and permission to launch EC2 instances and create security groups
- An EC2 key pair in the Region you launch in
- A VPC subnet, with a public IP on the instance if you connect over the internet
Recommended instance type: m5.large. PgBouncer itself needs very little CPU or memory; size the instance for your PostgreSQL workload.
Connecting to your instance
| Version | Operating system | SSH user |
|---|---|---|
| PostgreSQL 17 + PgBouncer on Ubuntu 24.04 | Ubuntu 24.04 LTS | ubuntu |
Step 1: Launch the instance
Option A: AWS Marketplace console
- Open the cloudimg Hardened Adminer & Database Admin and Backup Tools AMIs listing on AWS Marketplace and choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- Once the subscription is active, choose Launch your software.
- Choose One-click launch from AWS Marketplace (or Launch from EC2 Console for the full launch wizard). In Version, choose PostgreSQL 17 + PgBouncer on Ubuntu 24.04, then choose your Region.
- Pick an instance type (
m5.largerecommended; the software charge is $0.04 per vCPU per hour) and your key pair. - Security group: the listing's default group allows SSH (22) only. Restrict it to your own IP address. Do not open 6432 or 5432 to the internet.
- Choose your VPC and subnet, leave the metadata defaults (the image already requires IMDSv2), then choose Launch.
Option B: AWS CLI
Run this on your own workstation. Replace the AMI ID with the one shown for your Region on the Marketplace launch page, and the VPC, subnet, key pair and CIDR with your own:
SG=$(aws ec2 create-security-group --group-name pgbouncer \
--description "PostgreSQL + PgBouncer" --vpc-id vpc-0123456789abcdef0 \
--query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr <your-mgmt-cidr>
aws ec2 run-instances \
--image-id ami-0123456789abcdef0 \
--instance-type m5.large \
--key-name my-key-pair \
--security-group-ids "$SG" \
--subnet-id subnet-0123456789abcdef0 \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=pgbouncer}]'
You do not need --metadata-options: the AMI already makes IMDSv2 required.
Step 2: Connect and read your credentials
Connect as ubuntu with the key pair you chose:
ssh -i ~/.ssh/my-key-pair.pem ubuntu@<public-ip>
First boot finishes within a minute of launch. It creates this instance's TLS key and certificate, turns TLS on, sets the appuser and pgbadmin passwords, starts PgBouncer, checks that it can log in through it and then writes the credentials file. Show the connection details (the two password lines are left out here; read them with sudo cat /root/postgresql-pgbouncer-credentials.txt and keep them in your secrets manager):
sudo grep -vE '^(APP_PASSWORD|PGBOUNCER_ADMIN_PASSWORD)=' /root/postgresql-pgbouncer-credentials.txt
# PostgreSQL 17 + PgBouncer - generated on first boot by postgresql-pgbouncer-firstboot.service
# These passwords are unique to this instance. Store them somewhere safe.
APP_USERNAME=appuser
APP_DATABASE=appdb
PGBOUNCER_HOST=127.0.0.1
PGBOUNCER_PORT=6432
PGBOUNCER_ADMIN_USER=pgbadmin
POSTGRES_PORT=5432
PRIVATE_IP=<private-ip>
TLS_CERTIFICATE=/etc/postgresql/17/main/cloudimg-server.crt
# Connect THROUGH PgBouncer (transaction pooling, TLS required) on the instance:
# psql "host=127.0.0.1 port=6432 dbname=appdb user=appuser sslmode=require"
...
The commands below read the passwords from that file at run time, so they never appear on screen. In your own sessions you can instead put them in ~/.pgpass (mode 0600), for example 127.0.0.1:6432:appdb:appuser:<APP_PASSWORD>.
Step 3: Confirm PostgreSQL and PgBouncer are running
psql --version
pgbouncer --version | head -1
systemctl is-active postgresql@17-main.service pgbouncer.service
sudo ss -Hltn
psql (PostgreSQL) 17.11 (Ubuntu 17.11-1.pgdg24.04+2)
PgBouncer 1.26.0
active
active
LISTEN 0 200 127.0.0.1:5432 0.0.0.0:*
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:*
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:*
LISTEN 0 128 127.0.0.1:6432 0.0.0.0:*
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:*
LISTEN 0 4096 [::]:22 [::]:*
PgBouncer listens on 127.0.0.1:6432 and PostgreSQL on 127.0.0.1:5432; port 53 on 127.0.0.53/127.0.0.54 is the operating system's local DNS stub.
Step 4: Connect through the pooler
Connect as appuser through PgBouncer over TLS and check who you are and how you are connected:
export PGPASSWORD="$(sudo sed -n 's/^APP_PASSWORD=//p' /root/postgresql-pgbouncer-credentials.txt)"
psql "host=127.0.0.1 port=6432 dbname=appdb user=appuser sslmode=require" \
-c "SELECT current_user, current_database(), current_setting('server_version') AS server_version;" \
-c '\conninfo'
current_user | current_database | server_version
--------------+------------------+------------------------------------
appuser | appdb | 17.11 (Ubuntu 17.11-1.pgdg24.04+2)
(1 row)
You are connected to database "appdb" as user "appuser" on host "127.0.0.1" at port "6432".
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off, ALPN: postgresql)
Step 5: Run transactions through the pool
Create a table, write to it in a transaction, read it back and drop it again. Each psql call is a separate client connection; PgBouncer lends each one a server session for the length of its transaction:
export PGPASSWORD="$(sudo sed -n 's/^APP_PASSWORD=//p' /root/postgresql-pgbouncer-credentials.txt)"
P="host=127.0.0.1 port=6432 dbname=appdb user=appuser sslmode=require"
psql "$P" -c "CREATE TABLE IF NOT EXISTS orders (id serial PRIMARY KEY, item text NOT NULL, created timestamptz DEFAULT now());"
psql "$P" -c "BEGIN; INSERT INTO orders (item) VALUES ('widget'), ('gadget'); COMMIT;"
psql "$P" -c "SELECT id, item FROM orders ORDER BY id;"
psql "$P" -c "DROP TABLE orders;"
CREATE TABLE
BEGIN
INSERT 0 2
COMMIT
id | item
----+--------
1 | widget
2 | gadget
(2 rows)
DROP TABLE
Transaction pooling and your application. Because a client gets a server session only for one transaction, session state does not carry over between transactions: use SET LOCAL instead of SET, avoid session-level advisory locks, LISTEN and WITH HOLD cursors, and keep temporary tables inside one transaction. Protocol-level prepared statements work (PgBouncer 1.26 tracks them; max_prepared_statements defaults to 200). If an application needs full session semantics, change pool_mode to session in /etc/pgbouncer/pgbouncer.ini (see Maintenance).
Step 6: Watch the pooler from the admin console
pgbadmin connects to PgBouncer's virtual pgbouncer database, which accepts admin commands such as SHOW DATABASES, SHOW POOLS, SHOW STATS, SHOW CLIENTS and RELOAD:
export PGPASSWORD="$(sudo sed -n 's/^PGBOUNCER_ADMIN_PASSWORD=//p' /root/postgresql-pgbouncer-credentials.txt)"
A="host=127.0.0.1 port=6432 dbname=pgbouncer user=pgbadmin sslmode=require"
psql "$A" -c "SHOW POOLS;"
psql "$A" -tAc "SHOW CONFIG;" | grep -E '^(pool_mode|auth_type|client_tls_sslmode|listen_addr|listen_port|max_client_conn|default_pool_size)\|' | cut -d'|' -f1,2
database | user | cl_active | cl_waiting | cl_active_cancel_req | cl_waiting_cancel_req | sv_active | sv_active_cancel | sv_being_canceled | sv_idle | sv_used | sv_tested | sv_login | maxwait | maxwait_us | pool_mode | load_balance_hosts
-----------+-----------+-----------+------------+----------------------+-----------------------+-----------+------------------+-------------------+---------+---------+-----------+----------+---------+------------+-------------+--------------------
appdb | appuser | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | transaction |
pgbouncer | pgbouncer | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | statement |
(2 rows)
auth_type|scram-sha-256
client_tls_sslmode|require
default_pool_size|20
listen_addr|127.0.0.1
listen_port|6432
max_client_conn|200
pool_mode|transaction
sv_idle is the number of PostgreSQL server sessions PgBouncer holds open for reuse; cl_waiting above zero for long means the pool is too small for your load (raise default_pool_size).
Step 7: Connect from your application
PgBouncer and PostgreSQL accept connections on localhost only. Two ways to reach the pooler:
- SSH tunnel (no change to the instance). On your workstation run
ssh -N -L 6432:127.0.0.1:6432 ubuntu@<public-ip>, then connect your client to127.0.0.1:6432asappuser, databaseappdb, withsslmode=require. - Open it to your VPC. Set PgBouncer's listener to all interfaces (or to this instance's private IP), restart it, then add an inbound rule for 6432 to the instance's security group from your application subnet or VPC CIDR only (never 0.0.0.0/0). Clients must use TLS (
sslmode=require, orverify-fullwith this instance's certificate/etc/postgresql/17/main/cloudimg-server.crt) and theappuserpassword. PostgreSQL itself stays on localhost behind the pooler.
sudo sed -i 's/^listen_addr = 127.0.0.1$/listen_addr = */' /etc/pgbouncer/pgbouncer.ini
sudo systemctl restart pgbouncer.service
An application connection string through the pooler looks like this:
postgresql://appuser:<APP_PASSWORD>@<private-ip>:6432/appdb?sslmode=require
PgBouncer refuses a client that does not use TLS, and a wrong password is refused by scram-sha-256; the postgres superuser cannot log in through the pooler at all:
for args in "dbname=appdb user=appuser sslmode=disable" "dbname=appdb user=appuser sslmode=require" "dbname=postgres user=postgres sslmode=require"; do
OUT="$(PGPASSWORD=wrong-password psql "host=127.0.0.1 port=6432 $args" -c "SELECT 1" 2>&1 || true)"
echo "${OUT#*failed: }"
done
FATAL: SSL required
FATAL: SASL authentication failed
FATAL: SASL authentication failed
Step 8: Check the security controls
Every PostgreSQL and PgBouncer unit carries the metadata block, appuser is not a superuser, and the postgres superuser has no password:
systemctl show -p IPAddressDeny postgresql@17-main.service pgbouncer.service postgresql-pgbouncer-firstboot.service
sudo -u postgres psql -tAc "SELECT rolname, rolsuper, rolpassword IS NOT NULL AS has_password FROM pg_authid WHERE rolname IN ('appuser','postgres') ORDER BY 1;"
IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128
IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128
IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128
appuser|f|t
postgres|t|f
Maintenance
- Change the
appuserpassword. Runsudo -u postgres psqland type\password appuserat the prompt (it asks twice and never puts the password on a command line). Then put the same password in/etc/pgbouncer/userlist.txt(the line"appuser" "<password>"), runsudo systemctl reload pgbouncer.service, and update/root/postgresql-pgbouncer-credentials.txtand your secrets manager. - Change the
pgbadminpassword. Edit its line in/etc/pgbouncer/userlist.txt, thensudo systemctl reload pgbouncer.service.pgbadminexists only in PgBouncer, not in PostgreSQL. - Add an application role. Create it in PostgreSQL (
CREATE ROLE ... LOGIN, then\passwordit), add a matching"role" "password"line to/etc/pgbouncer/userlist.txtand reload PgBouncer. The* = host=127.0.0.1 port=5432entry in[databases]routes any database name you create. - Pool settings.
/etc/pgbouncer/pgbouncer.ini:pool_mode,default_pool_size(server sessions per database/user pair),max_client_conn(client connections PgBouncer accepts). Keepdefault_pool_sizetimes the number of database/user pairs below PostgreSQL'smax_connections(100 by default). Apply withsudo systemctl reload pgbouncer.service(a change tolisten_addrorlisten_portneeds a restart). - PostgreSQL configuration. cloudimg's settings are in
/etc/postgresql/17/main/conf.d/10-cloudimg.conf(listener, password encryption) and20-cloudimg-tls.conf(this instance's certificate). Apply changes withsudo systemctl restart postgresql@17-main.service. - Backups. Take EBS snapshots or
pg_dumpbackups regularly (sudo -u postgres pg_dump appdb > appdb.sql). - Updates.
sudo apt update && sudo apt upgradeinstalls PostgreSQL 17 minor releases, PgBouncer releases from PGDG and Ubuntu security updates. - Logs.
journalctl -u pgbouncer.service,/var/log/postgresql/postgresql-17-main.logandjournalctl -u postgresql@17-main.service; first boot logs tojournalctl -u postgresql-pgbouncer-firstboot.service.
Support
cloudimg supports this image by email at support@cloudimg.co.uk. Include the instance ID, the version (PostgreSQL 17 + PgBouncer on Ubuntu 24.04) and what you were doing.
PostgreSQL is a trademark of the PostgreSQL Community Association of Canada. PgBouncer is developed by the PgBouncer project. Ubuntu is a trademark of Canonical Ltd. cloudimg is not affiliated with or endorsed by these organisations.