postgresql connection best practices for secure, scalable applications
why postgresql connection best practices matter
a postgresql connection is the communication channel between an application and a postgresql database. every time a full stack application reads user data, creates an order, or updates a profile, it usually sends a request through this connection.
connection management affects three important areas:
- security: poorly protected credentials or unencrypted connections can expose sensitive data.
- scalability: creating too many connections can overload postgresql and cause slow response times.
- reliability: correct timeout, retry, and pooling settings help applications recover from temporary failures.
these practices are useful for beginners learning coding, engineers building full stack systems, and devops teams deploying applications in production.
use a dedicated database user
applications should not connect to postgresql using the database superuser, such as postgres. a superuser can modify or delete almost anything, so a stolen credential could cause serious damage.
create a separate user with only the permissions required by the application:
create role app_user
login
password 'replace-with-a-strong-secret';
grant connect on database shop_db to app_user;
grant usage on schema public to app_user;
grant select, insert, update, delete
on all tables in schema public
to app_user;
grant usage, select
on all sequences in schema public
to app_user;
in production, avoid placing a real password directly in sql files stored in source control. store secrets in a secure secret manager and rotate them regularly.
apply the principle of least privilege
give each service only the permissions it needs. for example, a reporting service might need read-only access:
create role reporting_user
login
password 'replace-with-a-strong-secret';
grant connect on database shop_db to reporting_user;
grant usage on schema public to reporting_user;
grant select on all tables in schema public to reporting_user;
separating users for the web application, background workers, reporting tools, and database migrations makes it easier to audit activity and limit damage if one credential is compromised.
keep credentials out of source code
database passwords should not be hard-coded in application files, git repositories, docker images, or public configuration examples. use environment variables or a dedicated secrets management system.
a connection url commonly looks like this:
postgresql://app_user:password@db.example.com:5432/shop_db
however, a complete connection string should be treated as sensitive because it may contain the username, password, host, and database name. a safer application configuration reads the value from the environment:
database_url=postgresql://app_user:strong-password@postgres:5432/shop_db
example node.js configuration:
import pg from 'pg';
const { pool } = pg;
const pool = new pool({
connectionstring: process.env.database_url,
max: 10,
idletimeoutmillis: 30000,
connectiontimeoutmillis: 5000,
ssl: process.env.node_env === 'production'
? { rejectunauthorized: true }
: false
});
export default pool;
for production systems, inject database_url through a platform secret, such as a cloud secret manager, kubernetes secret, or protected ci/cd variable.
encrypt postgresql connections with tls
without encryption, credentials and database traffic may be visible to someone monitoring the network. use tls, also called ssl, when the application connects to postgresql over a network.
a secure connection can be configured with the sslmode parameter:
postgresql://app_user:password@db.example.com:5432/shop_db?sslmode=verify-full
the strongest common setting is verify-full. it encrypts the connection and verifies that the server certificate matches the expected hostname.
disablemeans encryption is not used.requireencrypts the connection but may not verify the server identity.verify-caverifies the certificate authority.verify-fullverifies both the certificate authority and the server hostname.
do not blindly disable certificate verification to solve connection errors. instead, install the correct certificate authority file and configure the client properly.
const pool = new pool({
connectionstring: process.env.database_url,
ssl: {
rejectunauthorized: true,
ca: process.env.postgres_ca_cert
}
});
use connection pooling
opening a new postgresql connection for every request is inefficient. establishing a connection requires network communication, authentication, and server resources. a connection pool keeps a controlled number of reusable connections.
when a request needs the database, it temporarily borrows a connection from the pool. after the query completes, the connection returns to the pool for another request.
example with node.js and node-postgres
import pg from 'pg';
const { pool } = pg;
const pool = new pool({
connectionstring: process.env.database_url,
max: 20,
min: 2,
idletimeoutmillis: 30000,
connectiontimeoutmillis: 5000
});
const result = await pool.query(
'select id, email from users where id = $1',
[userid]
);
console.log(result.rows);
the exact pool size depends on the database server, application workload, and number of application instances. a larger pool is not always better. too many active connections can increase memory usage, cause lock contention, and make queries slower.
always release checked-out connections
when using pool.connect(), release the connection in a finally block. failing to release it can create a connection leak and eventually make the application unable to serve new requests.
const client = await pool.connect();
try {
await client.query('begin');
await client.query(
'update accounts set balance = balance - $1 where id = $2',
[amount, senderid]
);
await client.query(
'update accounts set balance = balance + $1 where id = $2',
[amount, receiverid]
);
await client.query('commit');
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
use a single checked-out connection for all statements in a transaction. a transaction cannot safely move between different pooled connections.
choose a sensible pool size
postgresql has a maximum connection limit controlled by the max_connections setting. your application must share that limit with database administrators, monitoring tools, migration jobs, and other services.
for example, if an application runs five instances and each instance has a pool size of 20, it could create up to 100 application connections:
total_possible_connections =
application_instances * pool_size
5 * 20 = 100
a simple starting point is to use a small pool and measure performance. consider:
- the number of application instances.
- the available cpu and memory on the postgresql server.
- the average query duration.
- the number of concurrent requests.
- connections used by migrations, dashboards, background jobs, and administrators.
for serverless applications or systems with many short-lived instances, use an external pooler such as pgbouncer or a managed database connection pool. this prevents every short-lived function from opening several direct connections.
use parameterized queries
never build sql statements by concatenating untrusted user input. this can lead to sql injection, where an attacker changes the meaning of a query.
unsafe example:
const email = req.body.email;
const query =
"select id from users where email = '" + email + "'";
const result = await pool.query(query);
safe example using parameters:
const email = req.body.email;
const result = await pool.query(
'select id from users where email = $1',
[email]
);
parameterized queries send values separately from the sql command. postgresql can then treat the input as data instead of executable sql.
use parameters for values such as:
- usernames and email addresses.
- product ids and account ids.
- dates, search terms, and numeric values.
- pagination limits and offsets, after validating their range.
table names and column names cannot normally be passed as regular query parameters. if dynamic identifiers are required, validate them against a fixed allowlist instead of accepting arbitrary user input.
set connection and query timeouts
timeouts prevent an application from waiting forever when postgresql is unavailable or a query takes too long. at minimum, configure a connection timeout and an application request timeout.
const pool = new pool({
connectionstring: process.env.database_url,
connectiontimeoutmillis: 5000,
statement_timeout: 10000,
idletimeoutmillis: 30000
});
common timeout settings include:
- connection timeout: the maximum time allowed to establish a database connection.
- statement timeout: the maximum time a sql statement may run.
- idle timeout: the time before an unused pool connection is closed.
- transaction timeout: the maximum time a transaction may remain open.
timeouts should match the application’s requirements. a reporting query may need more time than a login query, but long-running operations should usually run in a separate worker or reporting system rather than blocking web requests.
handle errors and retries carefully
database errors are not all the same. some errors are temporary, such as a short network interruption. others indicate a permanent problem, such as invalid sql, missing permissions, or a constraint violation.
retry only errors that are safe to retry. retrying every failed operation can make an outage worse and may duplicate writes.
async function querywithretry(text, values, attempts = 3) {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await pool.query(text, values);
} catch (error) {
const istemporary =
['econnreset', 'etimedout', '57p01'].includes(error.code);
if (!istemporary || attempt === attempts) {
throw error;
}
const delay = attempt * 250;
await new promise(resolve => settimeout(resolve, delay));
}
}
}
use exponential backoff with jitter in larger systems. also make write operations idempotent where possible, meaning repeating the same operation does not create unintended duplicate results.
use transactions for related operations
a transaction groups several database operations into one logical unit. either all operations succeed, or postgresql rolls them back.
const client = await pool.connect();
try {
await client.query('begin');
const order = await client.query(
`insert into orders (user_id, total)
values ($1, $2)
returning id`,
[userid, total]
);
await client.query(
`insert into order_items (order_id, product_id, quantity)
values ($1, $2, $3)`,
[order.rows[0].id, productid, quantity]
);
await client.query('commit');
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
keep transactions short. long transactions can hold locks, prevent cleanup of old row versions, and reduce overall scalability. do not wait for external apis, user input, or long file operations while a database transaction is open.
configure postgresql network access
postgresql should not be exposed to the entire public internet unless there is a very specific and well-protected reason. restrict access through private networks, firewalls, security groups, or network policies.
the pg_hba.conf file controls which clients can connect, which users they may use, and how they authenticate. a restrictive rule is safer than allowing every address:
# example: allow app_user only from the private application network
hostssl shop_db app_user 10.20.0.0/16 scram-sha-256
important security practices include:
- allow connections only from known application networks.
- prefer
hostsslwhen tls is required. - use scram authentication with
scram-sha-256. - block direct public access with firewall rules.
- use private dns and private subnets for cloud databases.
- review access rules whenever infrastructure changes.
use health checks and graceful shutdown
devops teams need to know whether an application can reach postgresql. a health check can run a lightweight query such as select 1.
app.get('/health/database', async (req, res) => {
try {
await pool.query('select 1');
res.status(200).json({ database: 'ok' });
} catch (error) {
res.status(503).json({ database: 'unavailable' });
}
});
applications should also close their pools during shutdown. this allows existing requests to finish and prevents abrupt connection termination during deployments.
async function shutdown(signal) {
console.log(`${signal} received. closing database pool...`);
await pool.end();
process.exit(0);
}
process.on('sigterm', () => shutdown('sigterm'));
process.on('sigint', () => shutdown('sigint'));
monitor connections and query performance
monitoring helps identify connection leaks, slow queries, and capacity problems before users experience an outage. useful postgresql statistics can be viewed through pg_stat_activity:
select
pid,
usename,
application_name,
client_addr,
state,
query_start,
wait_event_type,
query
from pg_stat_activity
where datname = 'shop_db';
track metrics such as:
- active, idle, and waiting database connections.
- connection pool usage and pool wait time.
- query latency and error rates.
- transaction duration and deadlocks.
- cpu, memory, disk, and replication lag.
- number of timeout and authentication failures.
enable slow-query logging or use tools such as pg_stat_statements to find queries that need indexes or better sql design. avoid logging passwords, access tokens, or complete connection strings.
manage schema changes safely
use version-controlled database migrations rather than changing production tables manually. a migration tool helps the team track which changes have been applied in each environment.
for large tables, design migrations to avoid long locks. a safer approach may include:
- add a nullable column first.
- deploy application code that can read both old and new formats.
- backfill data in small batches.
- add constraints or indexes after the backfill is complete.
- remove old columns only after all application instances are updated.
this expand-and-contract approach is useful for zero-downtime deployments and rolling releases.
separate read and write workloads when needed
as an application grows, read traffic may become much larger than write traffic. postgresql streaming replication can provide read replicas for reporting and read-heavy operations.
applications must understand replica lag. a newly written record may not be immediately available on a replica. user-facing requests that require read-after-write consistency should usually read from the primary database.
- send inserts, updates, and deletes to the primary database.
- send suitable read-only queries to replicas.
- do not use replicas for data that must be immediately consistent.
- monitor replication lag before routing traffic.
secure backups and disaster recovery
connection security is only one part of database security. backups may contain the same sensitive data as the live database, so they must also be protected.
- encrypt backups both at rest and during transfer.
- restrict access to backup storage.
- set retention policies that match business requirements.
- test restoring backups regularly.
- document recovery time and recovery point objectives.
a backup that has never been restored is not a complete disaster recovery plan. test the entire process, including credentials, network access, migration compatibility, and application configuration.
postgresql connection checklist
- security: use a dedicated least-privilege database user.
- secrets: store credentials in environment variables or a secret manager.
- encryption: use tls and verify the server certificate in production.
- pooling: reuse connections and calculate pool sizes across all application instances.
- queries: use parameterized sql to prevent injection attacks.
- timeouts: configure connection, statement, idle, and request timeouts.
- transactions: use one client for each transaction and always release it.
- networking: restrict postgresql access to trusted private networks.
- operations: add health checks, graceful shutdown, logging, and monitoring.
- scalability: consider pgbouncer, read replicas, and workload separation as traffic grows.
- recovery: encrypt backups and regularly test restoration.
final recommendations
secure and scalable postgresql connections come from several small, consistent decisions. start with encrypted connections, protected credentials, least-privilege users, parameterized queries, and a correctly configured connection pool.
as your full stack application grows, add monitoring, migration automation, health checks, replicas, and connection pooling infrastructure. these practices make coding projects safer and help production systems remain responsive under increasing traffic.
for devops teams, documenting these settings and checking them in ci/cd can prevent configuration mistakes. clear technical documentation also improves maintainability and supports seo by making the application’s architecture and best practices easier for developers to understand and discover.
Comments
Share your thoughts and join the conversation
Loading comments...
Please log in to share your thoughts and engage with the community.