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
| Option | You get | You give up | Pick it when |
|---|---|---|---|
| Managed SQL service | Backups, failover, patching | Control over the engine internals | Almost always, for a relational workload |
| Container on the same host | Control and low latency | Backups and failover are yours | A side project, or local development |
| Serverless SQL | Scale to zero, per-request billing | Cold starts and connection limits | Low, spiky traffic |
| Managed key-value | Fast caching and queues | Durability guarantees vary | Sessions and rate limiting |
| Object storage | Cheap durable blobs | No queries, no transactions | Files, 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
| Layer | Protects against | Does not protect against |
|---|---|---|
| Snapshot | A failed upgrade or a corrupt table | A logical mistake that was snapshotted minutes later |
| Point-in-time recovery | An accidental delete an hour ago | A mistake made a week ago |
| Logical dump | Corruption and version migration | Large datasets within a short window |
| Object storage versioning | Overwrites and ransomware | A compromised account with delete rights |
| Off-account copy | Account compromise or provider outage | Nothing - this is the last line |
| Tested restore | The belief that your backups work | Nothing - this is the only real proof |
- Define a recovery point objective and a recovery time objective in numbers. "We back up daily" is not a plan.
- Keep at least one copy in a different account or provider, with credentials the production system does not hold.
- 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.
- Encrypt backups and store the key separately from the data. An encrypted backup with the key beside it protects nothing.
- 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.
Related
Scaling: load balancing, autoscaling and stateless design Monitoring, uptime and log management
Last refreshed 2026-09-18.