Databases, object storage and backups

Managed database options, connection pooling from serverless, object storage and signed URLs, backup schedules, and actually testing a restore.

Choosing and connecting

OptionYou getYou give upPick it when
Managed SQL serviceBackups, failover, patchingControl over the engine internalsAlmost always, for a relational workload
Container on the same hostControl and low latencyBackups and failover are yoursA side project, or local development
Serverless SQLScale to zero, per-request billingCold starts and connection limitsLow, spiky traffic
Managed key-valueFast caching and queuesDurability guarantees varySessions and rate limiting
Object storageCheap durable blobsNo queries, no transactionsFiles, images and backups
Connection budget

  app instances        x  pool size per instance  =  connections
  serverless           x  1 per instance           =  unbounded without a pooler

Rules that keep a database alive
  set a small pool per process and a real statement timeout
  use an external pooler in front of a serverless platform
  never open a connection per request
  alert at 70 percent of the connection limit, not at 100
-- a statement timeout protects the database from one bad query
alter role app set statement_timeout = '15s';
alter role app set idle_in_transaction_session_timeout = '30s';

-- and find the queries that are actually costing you
select calls, mean_exec_time, total_exec_time, left(query, 80) as query
from pg_stat_statements
order by total_exec_time desc
limit 10;

Object storage and signed access

import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: process.env.AWS_REGION });

// upload: the client writes directly, the application never proxies the bytes
export async function uploadUrl(key, contentType) {
  return getSignedUrl(s3, new PutObjectCommand({
    Bucket: process.env.BUCKET,
    Key: "uploads/" + key,
    ContentType: contentType
  }), { expiresIn: 300 });
}

// download: a short-lived URL for a private object
export async function downloadUrl(key) {
  return getSignedUrl(s3, new GetObjectCommand({
    Bucket: process.env.BUCKET,
    Key: key,
    ResponseContentDisposition: "attachment"
  }), { expiresIn: 60 });
}
  • Signed URLs let the browser talk directly to storage, which removes the application from the bandwidth path entirely.
  • Keep the bucket private. A public bucket is the most common cause of a data exposure headline.
  • Give uploads a generated key, not the user's filename. A raw filename allows path traversal and collisions and leaks the original name.
  • Set a lifecycle rule to expire temporary uploads. Storage that never deletes costs more every month.

Backups that actually restore

LayerProtects againstDoes not protect against
SnapshotA failed upgrade or a corrupt tableA logical mistake that was snapshotted minutes later
Point-in-time recoveryAn accidental delete an hour agoA mistake made a week ago
Logical dumpCorruption and version migrationLarge datasets within a short window
Object storage versioningOverwrites and ransomwareA compromised account with delete rights
Off-account copyAccount compromise or provider outageNothing - this is the last line
Tested restoreThe belief that your backups workNothing - this is the only real proof
  1. Define a recovery point objective and a recovery time objective in numbers. "We back up daily" is not a plan.
  2. Keep at least one copy in a different account or provider, with credentials the production system does not hold.
  3. Automate a restore into a throwaway environment on a schedule and run a query against it. A backup that has never been read is a hypothesis.
  4. Encrypt backups and store the key separately from the data. An encrypted backup with the key beside it protects nothing.
  5. Alert on backup failure. A silent backup failure is discovered on the day you need the backup.
# a monthly restore drill, scripted so it actually happens
pg_restore --clean --if-exists --no-owner \
  --dbname=postgres://app@restore-host:5432/restore_check latest.dump

psql postgres://app@restore-host:5432/restore_check \
  -c "select count(*) from orders where created_at > now() - interval '30 days';"

# record the outcome - date, duration, row counts, anything unexpected
⚠️
The most dangerous state is a backup you have never restored from. Snapshots and dumps both fail in ways that only appear when you read them back - a truncated file, a missing table, a corrupted index. The restore drill is the only thing that converts a backup into a recovery capability.

FAQ

How often should I back up?
As often as your recovery point objective demands. If losing an hour of orders is unacceptable, hourly snapshots plus point-in-time recovery is the floor, not nightly dumps.
Should the database be in the same region as the application?
Yes for latency, and the same region or a documented pair for a disaster plan. A cross-region database call adds tens of milliseconds to every request.

Scaling: load balancing, autoscaling and stateless design Monitoring, uptime and log management

Last refreshed 2026-09-18.