Skip to content

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 Address
  • non_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 ​

ParameterValue
Regionin-maa (Chennai)
OSDebian (Debian 13 as of 22-Feb-2026)
PlanLinode 4 GB (Shared CPU, 2 cores)
LabelGive your preferred label (Label can't have spaces)
Root PasswordCreate a Strong Password and store it somewhere safe
SSH KeysYou can add an existing SSH key or add this later when you deploy a new server
Disk EncryptionEnable
VPCSelect the VPC your other servers use (or create one if this is your first server)
SubnetSelect a private subnet since Postgres Server can't be accessed outside the VPC
Auto-assign a VPC IPv4Enable
Allow public IPv4 accessDisable
Network Interface TypeLinode Interfaces
VPC Interface FirewallCreate and assign a Firewall (that allows all outbound and no inbound - configured later in this guide)
BackupsEnable

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:

shell
sudo apt update && sudo apt upgrade -y

Set Timezone ​

Install all locales first to disable locale warnings:

shell
sudo apt install locales-all

All new Linode servers are set to UTC time by default. To change it to IST, use:

shell
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:

shell
adduser non_root
# You'll be prompted to provide a password

Add the new user to the sudo group for administrative privileges:

shell
adduser non_root sudo

Exit the session and log back into the server as your new user (using LISH Console - Since local Mac Machine can't access private server):

shell
exit
# Log into the server using LISH Console
# Prompted for `localhost login` and `password` in the console -> use non_root credentials

Create an SSH directory and add the public key from the other Linode server in your VPC to the authorized keys file:

shell
mkdir ~/.ssh && chmod 700 ~/.ssh && vi ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys

TIP

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:

shell
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:

shell
sudo sshd -t

Confirm 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:

shell
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|addressfamily'
# Expected: permitrootlogin no, passwordauthentication no, addressfamily inet

Finally, restart the SSH service to apply the changes:

shell
sudo systemctl restart ssh

TIP

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 PurposeLabelProtocolPortsIP / NetmaskAction
Allow ICMP (ping) traffic from other serversChoose a labelICMPLeave blankSubnet IP range of other servers (Ex: 10.0.1.0/24)Accept
Allow SSH connections from other Linode ServersChoose a labelTCPSSH (22)Other Linode IP address (use /32)Accept
Allow PostgreSQL connectionsChoose a labelTCP5432Other 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:

shell
sudo apt install -y postgresql postgresql-contrib

Start the Postgres service:

shell
sudo systemctl start postgresql

Enable 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:

FileWhat it isWhere it goesValid for
ca.keyThe CA's private key. Whoever holds it can sign certificates that your application will trust.Offline storage only - never on any server10 years
ca.crtThe CA's public certificate. Clients use it to check that a server certificate was signed by your CA.Application server + offline with ca.key10 years
server.keyThe database server's private key, used by PostgreSQL during the TLS handshake.Database server-
server.crtThe database server's public certificate, carrying its private IP and signed by ca.key.Database server825 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.

shell
# 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.crt

WARNING

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 saved ca.key and ca.crt, replace server.crt and server.key on the database server, and run sudo systemctl reload postgresql. The application's ca.crt does 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 new server.crt, and replace ca.crt on every application server. Check the dates with openssl 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:

shell
# 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.srl

On 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:

shell
# 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:

shell
# If you generated the files elsewhere, scp ca.crt to the application server first
install -D -m 644 ca.crt ~/.postgresql/root.crt

Finally, 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:

shell
cd ~ && rm -rf ~/pg-certs

NOTE

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 postgres role uses peer authentication for local connections. It is safest to keep it this way for the main postgres user and require passwords for others. Changing it to trust is dangerous, as it allows anyone logged into the server to access the database as a superuser without a password.

shell
# 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-256

WARNING

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 to 256MB at most)
  • max_connections = 25 (up to 50 at most)
  • work_mem = 4MB
  • maintenance_work_mem = 64MB
  • effective_cache_size = 512MB
shell
# 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 disks

TIP

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:

shell
sudo systemctl restart postgresql

NOTE

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:

shell
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";
EOF

Updates 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:

shell
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 -k

If 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:

shell
# 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 -e

Add 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:

shell
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 -delete

IMPORTANT

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.