PostgreSQL on Debian 13 (Linode)
This guide explains how to configure PostgreSQL on a Linode (Debian) server.
NOTE
This setup uses a Private Subnet to restrict database access exclusively within the VPC. This significantly reduces the risk of unauthorized access to the database.
NOTE
You can follow the steps in this guide as written, but replace the following placeholders with your own names:
<PostgreSQL Server Private IP Address>: Your Linode's Private IP Address<Other Linode Private IP>: Your other Linode's Private IP Addressnon_root: Your non-root username<your-database-name>: Your database name<your-db-username>: Your database username
You should also update the IP addresses and subnet IP ranges, to match your VPC settings.
Setup Linode (Debian) for PostgreSQL
Launch a Linode
| Parameter | Value |
|---|---|
| Region | in-maa (Chennai) |
| OS | Debian (Debian 13 as of 22-Feb-2026) |
| Plan | Linode 4 GB (Shared CPU, 2 cores) |
| Label | Give your preferred label (Label can't have spaces) |
| Root Password | Create a Strong Password and store it somewhere safe |
| SSH Keys | You can add an existing SSH key or add this later when you deploy a new server |
| Disk Encryption | Enable |
| VPC | Select the VPC your other servers use (or create one if this is your first server) |
| Subnet | Select a private subnet since Postgres Server can't be accessed outside the VPC |
| Auto-assign a VPC IPv4 | Enable |
| Allow public IPv4 access | Disable |
| Network Interface Type | Linode Interfaces |
| VPC Interface Firewall | Create and assign a Firewall (that allows all outbound and no inbound - configured later in this guide) |
| Backups | Enable |
NOTE
The Linode dashboard may display a Public IP address for this server. This IP is merely reserved for your account, it is not bound to the server's network interface and cannot receive public internet traffic. Your server remains completely private.
Forward Proxy
WARNING
The region of the Forward Proxy server must match the region of the Postgres server.
Since this private server can't access the internet directly, you need a forward proxy to download the required packages. Refer to this guide to configure a forward proxy server.
Once the server is set up, test connectivity through the forward proxy as explained in that document.
Upgrade Packages
TIP
Use the LISH Console to connect to the Linode server. It has no public IP, so you can't SSH into it from your local machine.
Upgrade the packages on the server:
sudo apt update && sudo apt upgrade -ySet Timezone
Install all locales first to disable locale warnings:
sudo apt install locales-allAll new Linode servers are set to UTC time by default. To change it to IST, use:
timedatectl set-timezone 'Asia/Kolkata'Confirm the date by running the date command in the terminal.
Disable Root Login
IMPORTANT
The LISH Console does not use SSH, so PermitRootLogin no and PasswordAuthentication no do not apply to it. Anyone with access to your Linode account can reach a root login prompt through LISH using the root password. Your Linode account credentials and 2FA are therefore the real security perimeter for this server - enable 2FA and store the root password in a password manager.
First, create a limited user account:
adduser non_root
# You'll be prompted to provide a passwordAdd the new user to the sudo group for administrative privileges:
adduser non_root sudoExit the session and log back into the server as your new user (using LISH Console - Since local Mac Machine can't access private server):
exit
# Log into the server using LISH Console
# Prompted for `localhost login` and `password` in the console -> use non_root credentialsCreate an SSH directory and add the public key from the other Linode server in your VPC to the authorized keys file:
mkdir ~/.ssh && chmod 700 ~/.ssh && vi ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keysTIP
If the other Linode servers aren't created at this point, continue to work in the LISH console and perform this step later when the servers are created. However, you'll need at least one server for this step to generate CA certificates.
Disable Root login and Password Authentication:
sudo vi /etc/ssh/sshd_config
# Set `PermitRootLogin` to `no`
# Set `PasswordAuthentication` to `no`
# Set `AddressFamily` to `inet` (to disable IPv6 connections)Validate the configuration before restarting, and keep your current session open in case something is wrong:
sudo sshd -tConfirm the settings sshd will actually use. Files in /etc/ssh/sshd_config.d/ are included at the top of sshd_config, and the first value read wins, so a drop-in file can silently override your edits:
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|addressfamily'
# Expected: permitrootlogin no, passwordauthentication no, addressfamily inetFinally, restart the SSH service to apply the changes:
sudo systemctl restart sshTIP
On Debian 13, SSH is socket-activated and the unit is named ssh. If you changed the listening address or port, also restart ssh.socket.
Configure Firewall
Add the following inbound rules to the PostgreSQL Firewall to explicitly allow the necessary connections:
| Rule Purpose | Label | Protocol | Ports | IP / Netmask | Action |
|---|---|---|---|---|---|
| Allow ICMP (ping) traffic from other servers | Choose a label | ICMP | Leave blank | Subnet IP range of other servers (Ex: 10.0.1.0/24) | Accept |
| Allow SSH connections from other Linode Servers | Choose a label | TCP | SSH (22) | Other Linode IP address (use /32) | Accept |
| Allow PostgreSQL connections | Choose a label | TCP | 5432 | Other Linode IP address (use /32) | Accept |
NOTE
The SSH rule above allows access from your other server. If that other server is ever compromised, the attacker can reach this database host. The rule is required at least until you have copied the TLS certificates (see Install and Configure PostgreSQL). Afterwards, if you do not need it, delete the rule and use the LISH Console for administration. If you keep it, use a dedicated key that is not reused elsewhere, and keep a strong password on non_root - since it is in the sudo group, that password is the only thing standing between a stolen key and root access.
Install and Configure PostgreSQL
Install the Postgres packages:
sudo apt install -y postgresql postgresql-contribStart the Postgres service:
sudo systemctl start postgresqlEnable TLS:
Debian ships PostgreSQL with ssl = on using the self-signed snakeoil certificate, which clients cannot verify, so sslmode=verify-full will fail against it. You need a certificate that your application server can validate.
Since this server has no public DNS name, create a small internal Certificate Authority (CA) and use it to issue a certificate whose SAN matches the private IP the application connects to. This produces two pairs of files that do different jobs:
| File | What it is | Where it goes | Valid for |
|---|---|---|---|
ca.key | The CA's private key. Whoever holds it can sign certificates that your application will trust. | Offline storage only - never on any server | 10 years |
ca.crt | The CA's public certificate. Clients use it to check that a server certificate was signed by your CA. | Application server + offline with ca.key | 10 years |
server.key | The database server's private key, used by PostgreSQL during the TLS handshake. | Database server | - |
server.crt | The database server's public certificate, carrying its private IP and signed by ca.key. | Database server | 825 days |
In short: the CA pair is the authority that vouches for identities; the server pair is the identity being vouched for. The application trusts ca.crt, and therefore trusts any certificate signed by ca.key.
Run these commands on a trusted machine, not on the database server. The point of verify-full is that the database has to prove its identity using a certificate signed by someone else. If ca.key lived on the database server, anyone who compromised that server could sign a fresh certificate for any IP or name and have your application accept it - the database would be vouching for itself, and verification would prove nothing. The database server only ever needs server.key and server.crt.
TIP
This server has no public IP, so the simplest place to run these commands is another server in the VPC that can SSH into it - usually the application server itself. Run them in a temporary folder (mkdir ~/pg-certs && cd ~/pg-certs) so the files are easy to find and clean up afterwards.
Also, make sure to replace the <PostgreSQL Server Private IP Address> before running the commands below.
# Create the CA
openssl req -new -x509 -nodes -days 3650 \
-newkey rsa:4096 \
-keyout ca.key -out ca.crt \
-subj "/CN=Internal VPC CA"
# Create the server key and a CSR carrying the private IP as a Subject Alternative Name
openssl req -new -nodes \
-newkey rsa:2048 \
-keyout server.key -out server.csr \
-subj "/CN=<PostgreSQL Server Private IP Address>"
cat > server.ext <<EOF
subjectAltName = IP:<PostgreSQL Server Private IP Address>
EOF
# Sign it with the CA
openssl x509 -req -in server.csr -days 825 \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-extfile server.ext -out server.crtWARNING
Set calendar reminders for both expiry dates, a few weeks ahead of each. With verify-full on the client, an expired certificate is a hard outage, not a warning - the application will stop connecting entirely.
server.crt(825 days): Repeat the CSR and signing steps above using the savedca.keyandca.crt, replaceserver.crtandserver.keyon the database server, and runsudo systemctl reload postgresql. The application'sca.crtdoes not change.ca.crt(10 years): When the CA expires, every certificate it signed stops validating too, even if its own date is still fine. Renewal means repeating all the steps: create a new CA, issue a newserver.crt, and replaceca.crton every application server. Check the dates withopenssl x509 -enddate -noout -in <file>.crt.
IMPORTANT
The SAN must match exactly what the client puts in its connection string. If the application connects to 10.0.0.5, the SAN must be IP:10.0.0.5 - a certificate issued for a hostname will not validate against an IP, and vice versa. To use a name instead, add a matching /etc/hosts entry on the application server and issue the certificate with subjectAltName = DNS:....
Copy server.crt and server.key from the machine where you generated them to your non_root user's home folder on the database server:
# Run on the machine where you generated the files.
# The local copies are deleted only if the copy succeeds - otherwise the key would be lost.
# server.crt is public, so leaving it is harmless, but renewal issues a new one - no reason to keep it.
scp server.crt server.key non_root@<PostgreSQL Server Private IP Address>:/home/non_root \
&& rm -f server.crt server.key server.csr server.ext ca.srlOn the database server, install them into the PostgreSQL config folder. install sets the owner and permissions as it copies, so the private key is never left readable by other users, even briefly:
# Run as non_root (not after `sudo -i`/`su`, where ~ would be /root).
# Check your version with `pg_lsclusters` and change 17 in the paths if needed.
# The copies in your home folder are removed only if both installs succeed.
sudo install -o postgres -g postgres -m 644 ~/server.crt /etc/postgresql/17/main/server.crt \
&& sudo install -o postgres -g postgres -m 600 ~/server.key /etc/postgresql/17/main/server.key \
&& rm ~/server.crt ~/server.key
# Confirm both files are in place
sudo ls -l /etc/postgresql/17/main/server.*NOTE
PostgreSQL refuses to start if server.key is readable by group or others, so the 600 mode is required, not just recommended.
Install ca.crt on the application server, as the user your application runs as. ~/.postgresql/root.crt is where PostgreSQL clients look for a trusted CA by default - it is the same file as ca.crt, just renamed:
# If you generated the files elsewhere, scp ca.crt to the application server first
install -D -m 644 ca.crt ~/.postgresql/root.crtFinally, move ca.key off the server. ca.crt only lets clients check certificates; ca.key is what signs them, and your application trusts anything it signs. You need both files again every time you renew server.crt. If ca.key is stolen, an attacker in your VPC can issue a certificate for the database's IP and impersonate the database to your application - reading the data it sends and returns - without verify-full noticing.
Download ca.key and a copy of ca.crt to secure offline storage (e.g. as attachments in a password manager), then delete the working folder:
cd ~ && rm -rf ~/pg-certsNOTE
On the application server, connect with sslmode=verify-full and point sslrootcert at the installed root.crt, so the client verifies both the encryption and the server's identity. For example: postgresql://<your-db-username>@<PostgreSQL Server Private IP Address>/<your-database-name>?sslmode=verify-full&sslrootcert=/home/<app user>/.postgresql/root.crt. Clients built on libpq (such as psql and psycopg) find ~/.postgresql/root.crt on their own, but others (such as node-postgres and JDBC) do not, so pass the path explicitly.
Update Authentication method:
By default, the
postgresrole usespeerauthentication for local connections. It is safest to keep it this way for the main postgres user and require passwords for others. Changing it totrustis dangerous, as it allows anyone logged into the server to access the database as a superuser without a password.
# Change version 17 in the path if applicable
sudo vi /etc/postgresql/17/main/pg_hba.conf
# Keep postgres local connections as peer
# Change all other local connections from peer to scram-sha-256
# Also, add the Private IP of the other Linode that requires Postgres access and set the authentication method to scram-sha-256, using hostssl to require encryption:
# hostssl <your-database-name> <your-db-username> <Other Linode Private IP>/32 scram-sha-256WARNING
pg_hba.conf is evaluated top-down and stops at the first matching rule. Make sure no broader line (such as host all all 0.0.0.0/0 ...) appears above your specific rule, or your restriction will never be reached.
Update PostgreSQL Config:
WARNING
The values below are sized for the 4 GB plan. If you change plans, scale shared_buffers (about 25% of RAM) and effective_cache_size (about 75% of RAM) to match. Check that a swap file exists by running sudo swapon --show. Memory use grows with max_connections × work_mem, so raising either without more RAM can run the server out of memory and crash the database.
If you chose the Nanode 1 GB plan instead, use these values - the usual 25% rule leaves too little memory for the OS and connections on such a small host:
shared_buffers = 128MB(up to256MBat most)max_connections = 25(up to50at most)work_mem = 4MBmaintenance_work_mem = 64MBeffective_cache_size = 512MB
# Change version 17 in the path if applicable
sudo vi /etc/postgresql/17/main/postgresql.conf
# Change listen_addresses to 'localhost,<PostgreSQL Server Private IP Address>'
# Set password_encryption = 'scram-sha-256'
# Set ssl = on
# ssl_cert_file = '/etc/postgresql/17/main/server.crt'
# ssl_key_file = '/etc/postgresql/17/main/server.key'
# shared_buffers = 1GB # ~25% of RAM
# max_connections = 100 # each backend costs memory - keep it modest
# work_mem = 8MB # multiplied per sort/hash, per connection
# maintenance_work_mem = 256MB # used by VACUUM and CREATE INDEX
# effective_cache_size = 3GB # ~75% of RAM; a hint to the planner, not an allocation
# random_page_cost = 1.1 # Linode disks are SSDs; the default 4.0 assumes spinning disksTIP
If your application needs more than 100 connections, add a connection pooler such as PgBouncer on the application server rather than raising this value - pooling is far cheaper than additional backends.
Restart the PostgreSQL Service:
sudo systemctl restart postgresqlNOTE
Because listen_addresses names the private IP, PostgreSQL can only listen on it if that address is already up when the service starts. If it isn't (for example, during a reboot), PostgreSQL logs a warning and listens on localhost only, so the application can't connect. After every restart or reboot, confirm with sudo ss -ltnp | grep 5432 - you should see <PostgreSQL Server Private IP Address>:5432. If not, run sudo systemctl restart postgresql.
TIP
Your application's database user must not be a superuser. Grant only what the schema requires — see the database and user guide linked below.
Keep the Server Patched
This server has no internet access of its own, so it can only receive security updates (OpenSSH, PostgreSQL, the kernel) through the forward proxy. Skipping updates leaves known vulnerabilities in place, so patch it regularly - monthly, or whenever a Debian security advisory affects you.
Make the proxy setting permanent so apt always uses it. This replaces the proxy.conf file created while testing the proxy:
sudo rm -f /etc/apt/apt.conf.d/proxy.conf
sudo tee /etc/apt/apt.conf.d/00proxy > /dev/null <<EOF
Acquire::http::Proxy "http://<Proxy Private IP>:8080";
Acquire::https::Proxy "http://<Proxy Private IP>:8080";
EOFUpdates require the proxy to be running, which costs money. Choose one:
- Always-on proxy - keep the proxy Linode running. Simplest, if the cost of an extra Nanode is not a concern.
- On-demand proxy - delete the proxy Linode now. Before each maintenance window, create a new one using the forward proxy guide, and delete it again when done. Delete it rather than powering it off, since a powered-off Linode is still billed. If the new proxy gets a different private IP, update it in
/etc/apt/apt.conf.d/00proxy.
Maintenance Steps
With the proxy running, run these on the database server:
sudo apt update && sudo apt upgrade -y
# First time only - needrestart is not installed by default
sudo apt install -y needrestart
# Check whether the running kernel is outdated
sudo needrestart -kIf it reports a newer kernel, or libc6 was among the upgraded packages, reboot with sudo reboot, then confirm PostgreSQL is listening on the private IP (using sudo ss -ltnp | grep 5432).
Put the maintenance window in your calendar - this only works if you actually do it.
NOTE
Do not enable 1:1 NAT or attach a public interface to the database server as a shortcut for egress. That gives the server a public IP that can receive inbound traffic, which removes the network isolation this entire guide is built around.
Create Database and User
Refer to this guide to set up your specific database and user credentials.
Backups
WARNING
Linode's automated backups capture a snapshot of a running system. This means they capture the server exactly as it is at that second, which can sometimes corrupt active database files during a restore.
Schedule your own manual database dumps in addition to the Linode backups:
# Create a dedicated backups directory and set appropriate permissions
sudo install -d -m 700 -o postgres -g postgres /var/backups/postgresql
# Open the postgres user's crontab
sudo crontab -u postgres -eAdd these lines to the crontab. The first takes a dump daily at 2 AM; the second deletes dumps older than 14 days so they don't fill the disk:
0 2 * * * pg_dump -d <your-database-name> -Fc -f /var/backups/postgresql/<your-database-name>-$(date +\%F).dump
30 2 * * * find /var/backups/postgresql -name '*.dump' -mtime +14 -deleteIMPORTANT
Keep the backslash in \%F. Cron treats an unescaped % as a newline, which would cut the command short and silently produce no backup.
Ship the dumps off-host, encrypt them at rest, and test a restore - an untested backup is a hypothesis. If you need point-in-time recovery, use pg_basebackup with WAL archiving instead.
Migrating from an Existing Database
This is applicable only if you need to migrate data from an existing database hosted on another server to the new database.
Refer to this guide for instructions on transferring your data.
You should now have a working PostgreSQL database server securely isolated within your VPC.
