A production-grade .NET 10 REST API for secure PDF financial statement management and delivery. Built with Clean Architecture, DDD, CQRS, and JWT-based authentication. Designed for fintech and banking workloads.
- Secure PDF Management — upload, list, retrieve, and revoke financial statements
- Signed Download Links — time-limited, single-use JWT tokens with IP binding and full audit trail
- JWT Authentication — access tokens (60 min) with refresh token rotation (30 days, SHA-256 hashed)
- Role-Based Authorization — Admin (upload, manage, audit) and Customer (read own, download) roles with permission-level granularity
- Redis Distributed Cache — permission caching with in-memory fallback when Redis is unavailable
- Rate Limiting — fixed-window limiters at both the Nginx and ASP.NET Core layers
- Audit Logging — every download attempt (success or failure) is persisted with IP address, user agent, and outcome
- Structured Logging — Serilog → Seq with request context enrichment
- Metrics & Dashboards — OpenTelemetry (HTTP RED, runtime, and domain counters) on a Prometheus
/metricsendpoint + OTLP; self-contained Prometheus + Grafana in k8s/monitoring - Health Checks — PostgreSQL and Redis readiness exposed at
/health - Security Headers — CSP, HSTS, X-Content-Type-Options, X-Frame-Options via middleware
See docs/architecture.md for full diagrams including Clean Architecture layers, Kubernetes infrastructure, the download request flow, and the security model.
Web.Api (Endpoints, Middleware, Rate Limiting)
└── Application (Commands, Queries, Handlers, Validators, Decorators)
├── Domain (Aggregates: User, Statement, DownloadToken, AuditLog)
└── Infrastructure (EF Core, Redis, JWT, File Storage, Serilog)
SharedKernel (Result Pattern, Entity, Domain Events)
Internet ──HTTPS──► Nginx Ingress (TLS + rate limit)
│
ClusterIP :8080
┌─────┬─────┐
Pod Pod Pod ◄── HPA (2–10 replicas)
│ │ │
┌─────┼─────┼─────┐
PostgreSQL Redis Seq PVC(statements)
StatefulSet Dep Dep RWX 20Gi
- .NET 10 SDK
- Docker Desktop
- PostgreSQL 17, Redis 7, Seq 2024.3 (provided via Docker Compose)
.NET Aspire orchestrates PostgreSQL, Redis, Seq and Keycloak as containers and runs the Web API as a local process — one command starts everything, wires the connection strings, and gives you a dashboard with logs, traces and metrics.
# Requires Docker Desktop running.
dotnet run --project aspire/SecureStatementDelivery.AppHost- The Aspire dashboard opens automatically (its URL is printed in the console). From there you get the Web API's public URL, live logs, distributed traces, and metrics for every resource.
- Database migrations run automatically on startup; the Keycloak realm (
keycloak/realm-export.json) is imported on first run. - Dev secrets live in
aspire/SecureStatementDelivery.AppHost/appsettings.json(Parameterssection). For anything beyond local dev, override them with user-secrets, e.g.dotnet user-secrets set "Parameters:download-token-secret" "<32+ char value>"from the AppHost project. PostgreSQL and Redis passwords are auto-generated by Aspire.
The AppHost injects the same configuration keys the app already uses (
ConnectionStrings:Database,ConnectionStrings:Redis,Keycloak:*,DownloadToken:Secret, the Serilog→Seq URL), so the application code is unchanged and still runs under Docker Compose or Kubernetes without Aspire.
# Start dependencies
docker compose up -d postgres redis seq keycloak
# Apply database migrations (runs automatically in Development on startup)
cd src/Web.Api
dotnet run
# API: http://localhost:5000
# Keycloak: http://localhost:8080
# Seq: http://localhost:8081dotnet test SecureStatementDelivery.slnxAll architecture tests verify that Clean Architecture layer boundaries are respected.
CI (.github/workflows/build.yml) builds, tests, and validates the Docker build on
every push/PR to main. Releases are cut by pushing a v* tag:
.github/workflows/release.yml builds and pushes the image to ECR (GitHub OIDC, no
static keys), pins the tag in the manifests, and Argo CD
(k8s/argocd/application.yaml) pull-syncs the cluster — running the web-api-migrate
Job as a PreSync hook before the Deployment rolls. Full setup, rollback, and a
no-Argo (push-based kubectl) alternative are in
docs/runbook-cicd.md.
The steps below describe the manual first-time / no-CD deployment.
- A Kubernetes cluster (AKS, EKS, GKE, or local k3s/minikube)
- kubectl
- nginx ingress controller
- cert-manager
- A container registry (Docker Hub, ACR, ECR, GCR)
docker build -t your-registry/secure-statement-delivery/web-api:1.0.0 \
-f src/Web.Api/Dockerfile .
docker push your-registry/secure-statement-delivery/web-api:1.0.0Update image: in k8s/web-api/deployment.yaml to match the pushed tag.
Edit k8s/secret.yaml and replace every CHANGE_ME placeholder with real values:
| Key | Description |
|---|---|
ConnectionStrings__Database |
Full PostgreSQL connection string |
ConnectionStrings__Redis |
Redis host:port |
Jwt__Secret |
Minimum 32-character signing key |
DownloadToken__Secret |
Minimum 32-character signing key |
POSTGRES_PASSWORD |
PostgreSQL superuser password |
Production recommendation: Do not commit real secrets to Git. Use Sealed Secrets, HashiCorp Vault, or the External Secrets Operator to inject secrets at deploy time.
Update the domain in k8s/ingress/ingress.yaml:
- host: api.your-domain.com
# and in tls.hosts:
- api.your-domain.comUpdate the storageClassName in k8s/web-api/pvc.yaml (RWX required for multiple replicas) and k8s/postgres/statefulset.yaml. Common choices:
| Cloud | RWO Class | RWX Class |
|---|---|---|
| AKS | managed-csi |
azurefile-csi |
| EKS | gp2 |
efs-sc |
| GKE | standard-rwo |
filestore-sc |
kubectl apply -f k8s/ingress/cert-issuer.yamlUpdate email: in the file first.
Apply in dependency order:
# Cluster-wide resources
kubectl apply -f k8s/namespace.yaml
# Shared config and security
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secret.yaml
kubectl apply -f k8s/rbac.yaml
kubectl apply -f k8s/network-policy.yaml
# Data layer
kubectl apply -f k8s/postgres/
kubectl apply -f k8s/redis/
# Observability — logs (Seq)
kubectl apply -f k8s/seq/
# Application
kubectl apply -f k8s/web-api/
# Ingress
kubectl apply -f k8s/ingress/ingress.yaml
# Metrics + dashboards (optional) — self-contained Prometheus + Grafana.
# Skip if the cluster already runs kube-prometheus-stack (use the operator path instead).
# See k8s/monitoring/README.md for both options.
kubectl apply -f k8s/monitoring/namespace.yaml
kubectl apply -f k8s/monitoring/networkpolicy-allow-scrape.yaml
kubectl apply -f k8s/monitoring/prometheus.yaml
kubectl apply -f k8s/monitoring/grafana.yaml# All pods running
kubectl get pods -n secure-statements
# Health check
kubectl port-forward svc/web-api 8080:8080 -n secure-statements
curl http://localhost:8080/health
# Logs
kubectl logs -l app.kubernetes.io/name=web-api -n secure-statements --tail=50
# HPA status
kubectl get hpa -n secure-statements| Method | Path | Description | Auth |
|---|---|---|---|
POST |
/auth/register |
Create customer account | — |
POST |
/auth/login |
Dev/test only (ROPC). Login, returns JWT + refresh token | — |
POST |
/auth/refresh |
Dev/test only (ROPC). Rotate the refresh token | — |
/auth/loginand/auth/refreshare development conveniences and are not mapped in Production (they implementIDevelopmentOnlyEndpoint). In production the API is a pure OAuth2 resource server — it only validates access tokens. Clients obtain tokens via Authorization Code + PKCE and refresh directly against Keycloak's token endpoint (grant_type=refresh_token); the API is never in the credential or refresh path. Refresh-token rotation + reuse detection are enforced by Keycloak (revokeRefreshToken: true,refreshTokenMaxReuse: 0in the realm). For browser clients, front the flow with a BFF so the refresh token lives in anhttpOnlycookie, never in JS.
| Method | Path | Description | Permission |
|---|---|---|---|
GET |
/statements |
List statements (paginated) | StatementsReadOwn |
GET |
/statements/{id} |
Get statement details | StatementsReadOwn |
POST |
/customers/{customerId}/statements |
Admin manual upload of a PDF for a customer (multipart) | StatementsUpload |
POST |
/statements/ingest |
Machine-to-machine ingestion (multipart) — the statement-generation pipeline pushes statements | (service account: statement-ingest role) |
GET |
/statements/{id}/content |
In-app authenticated download (JWT is the credential) | StatementsDownload |
POST |
/statements/{id}/download-tokens |
Generate signed time-limited download link | StatementsDownload |
GET |
/statements/download?token=... |
Download via signed link (Range / resume supported) | (token is credential) |
There are two delivery channels, both audited:
- In-app (primary):
GET /statements/{id}/content— the customer's JWT is the credential. This is the recommended channel ("sign in to view your statement") and the one notifications should point to. It streams the file or returns a short-lived presigned S3 URL. - Signed link (opt-in share): generate a single-use, IP-bound, time-limited token and download with it anonymously. Use for explicit out-of-band sharing, not routine delivery.
In a real bank no human uploads a statement. Statements are a byproduct of a batch cycle: the
core-banking / ledger system closes a period and a statement-rendering job emits one PDF per
account per cycle — potentially millions, on a schedule. Customers never generate statements;
they only view and download their own. "Different customers" is simply the customerId carried on
each ingested statement — a single service identity ingests all of them, and the generator (the
bank's backend) owns the account → customer mapping.
At the top level there are two ways a statement gets in — a human admin or a machine — and each has two transports. All four feed the same funnel; they differ only in who authenticates and which actor is recorded:
| Who | How (transport) | Auth | When to use |
|---|---|---|---|
| Human admin (actor = admin's user id) | POST /customers/{customerId}/statements |
StatementsUpload permission |
Corrections, one-offs, the demo |
POST /statements/upload/resumable (TUS) |
StatementsUpload permission |
Same, but for large / unreliable uploads | |
Machine (actor = StatementIngestionService) |
POST /statements/ingest — push |
statement-ingest service-account role |
Generator calls the API directly |
| S3 event → SQS → worker — pull | statement-ingest service account |
Generator drops files in a bucket |
The extra rows are just transports within each option — resumable is a big-file variant of admin upload; pull is a queue-driven variant of push. In production the machine path is the real one (millions of statements per cycle); the admin path exists for corrections and one-offs.
So the manual admin upload (POST /customers/{customerId}/statements) is a convenience adapter, not the
production path. Ingestion is machine-driven and idempotent (any real pipeline is at-least-once),
and all adapters funnel through the exact same domain path — Statement.Create → validate PDF
→ malware scan → AES-encrypt with the customer's SA ID → store (WORM) → Statement + audit row:
Core banking / statement generator ──(monthly batch, renders PDFs)──┐
│
(1) PUSH ── POST /statements/ingest ───────────────────┐ │
(Keycloak service account, client_credentials) ▼ ▼
(2) PULL ── landing bucket ─► S3 event ─► SQS ─► StatementIngestionWorker
│
▼
shared funnel: UploadStatementCommand ──► Statement + audit
(UploadedByAdminId = StatementIngestionService principal)
(1) Push — authenticated M2M endpoint. POST /statements/ingest (multipart, Document-Id
required — the source system's stable identifier for the document, unique per customer). The generator authenticates to Keycloak with the OAuth2 client_credentials
grant using the statement-generator confidential client (see keycloak/realm-export.json), whose
service account holds the realm role statement-ingest. The endpoint is authorized by a
dedicated policy that checks that role directly off the token — it deliberately bypasses the
per-user "is this customer active?" check, because a service account has no domain User row. The
statement is attributed to a reserved StatementIngestionService principal in the audit trail.
(2) Pull — object-storage event. The generator drops PDFs into a landing bucket; an
s3:ObjectCreated:* notification lands on an SQS queue that StatementIngestionWorker long-polls.
Statement metadata (customerid, period, documentid, filename) travels as S3 object
user-metadata, so the queue message stays a thin pointer and the bytes are streamed only when the
funnel is ready. A message is acknowledged only after the statement is durably created; a failed
message is left for redelivery and ultimately the dead-letter queue — safe because the funnel
deduplicates on the DocumentId (per customer), so a redelivered-after-success message is a no-op. This path
decouples the bank's batch from the API's availability and is usually the better fit at bank scale.
It is off by default (Ingestion:Enabled=false) and requires Storage:Provider=S3.
Authentication: yes, the same Keycloak that authenticates customers and admins also authenticates the generator — as a service account via
client_credentials, not a human login. Customer/admin JWTs and the machine token share one issuer and JWKS; only the authorization rule differs (a realm role vs. the per-user permission model).
For large or unreliable connections, uploads can use the TUS resumable upload protocol.
The same StatementsUpload permission and rate limit apply.
| Method | Path | Description | Permission |
|---|---|---|---|
POST/PATCH/HEAD |
/statements/upload/resumable |
TUS endpoint — create, append chunks, resume | StatementsUpload |
GET |
/statements/upload/resumable/{fileId}/progress |
Server-Sent Events stream of upload progress | StatementsUpload |
GET |
/statements/upload/resumable/{fileId}/result |
Poll for created statement id (202 until done) | StatementsUpload |
Flow:
- A TUS client (
tus-js-client,TusDotNetClient, etc.) creates the upload with metadatacustomerId,period,filename,contentType, and optionaldescription/documentId, then streams the file in chunks — interrupted transfers resume from the last acknowledged byte. - Optionally open the
/progressSSE stream to receive{ uploaded, total, percent }events. - On completion the server validates the PDF, promotes it to permanent storage, creates the
Statement, and publishes the result. Poll/resultto retrieve the new statement id.
Limits & storage
- Max upload size is configured once via
Storage:MaxUploadBytes(default 50 MB) and enforced by Kestrel, the multipart pipeline, the upload validator, and the TUS limit. - Resumable chunks are buffered under
Storage:ResumableUploadTempPath. In Kubernetes this is a shared RWX volume (k8s/web-api/uploads-temp-pvc.yaml) so chunks, resume, and progress work on any replica. S3 uploads use multipart transfer automatically for files above 16 MB.
Customers can view several months in a single PDF — the "1 / 2 / 3 months" experience of a banking
app — via GET /statements/consolidated?from=YYYY-MM&to=YYYY-MM (the client maps each button to a
range, e.g. last 3 months → from = current-2, to = current). Admins may pass &customerId= to
consolidate on a customer's behalf; a customer always gets their own.
- The handler loads the caller's Active statements in the range, merges them in chronological
order into one PDF (
PdfSharpPdfConsolidator), and streams it. The range cap, cache TTL, and max cacheable size are configurable via theConsolidationsection (MaxMonths,CacheTtlMinutes,MaxCacheableBytes; validated at startup), defaulting to 12 months. - Each source statement is AES-encrypted with the customer's SA ID, so the server opens each with
that ID, merges, and re-encrypts the combined PDF with the same ID — it opens exactly like a
single statement. Missing months are skipped; an empty range returns
Statements.NoStatementsInRange. - Same security model as the in-app download:
Statements.Downloadpermission, server-side ownership, and every included statement is audited as a download (on cache hit and miss alike). Unlike single-file downloads it always streams (never a presigned redirect). - Result caching — the finished (already-encrypted) PDF is cached (
ICacheService→ Redis) forConsolidation:CacheTtlMinutes. The key is a SHA-256 content fingerprint over the customer's SA ID + the included statement ids, so any change — a new statement in the range, a revoke, or an ID correction — produces a new key and the cache can never serve a stale or wrongly-encrypted result. The expensive retrieve-and-merge runs only on a miss; merges aboveConsolidation:MaxCacheableBytesare streamed but not cached.
Every statement carries a period in canonical YYYY-MM form (e.g. 2024-01). The format is a
domain invariant enforced in Statement.Create, so all write paths are covered identically:
- Multipart upload — rejected up front by
UploadStatementCommandValidator. - Resumable (TUS) upload — rejected before any chunk transfers (
OnBeforeCreateAsync), and again at finalisation via the same domain guard. - Domain layer —
Statement.Createtrims and validates the value; an invalid period returnsStatements.InvalidPeriodFormatand the just-stored file is deleted so nothing is orphaned.
Listing supports period filtering via an optional range:
- Preset windows —
GET /statements?range=LastMonth(alsoLast3Months,Last6Months,Last12Months). Each resolves server-side to an inclusiveYYYY-MMwindow of the last N completed months, ending with the previous month — the current, incomplete month is excluded. - Custom range —
GET /statements?range=Custom&periodFrom=2024-01&periodTo=2024-03(either bound optional; equal bounds give a single exact month). Ifrangeis omitted, any suppliedperiodFrom/periodToare used directly.
Because stored periods are canonical YYYY-MM, which sorts lexically, the range is a valid
chronological comparison; each custom bound is validated against the same domain invariant, so a
malformed value returns Statements.InvalidPeriodFormat rather than a confusing empty page.
Because stored values are always canonical, a correctly-formatted query can never miss a statement.
Omit all period parameters to list every statement the caller owns — they are purely optional
filters, never a requirement. Customers only ever see their own statements; the CustomerId == caller
ownership filter is applied server-side regardless of query parameters.
See docs/architecture.md for the period-invariant and in-app download flow diagrams.
Every statement is AES-encrypted at upload time with the customer's South African ID number as the open password — so the customer must enter their ID number to open the downloaded file, the way banks distribute password-protected statements. Password protection is mandatory; the caller never supplies a password.
- The customer's SA ID number is captured at registration and validated (13 digits, a valid
date of birth, and a correct Luhn check digit — see
SouthAfricanIdValidator.IsValid), stored encrypted at rest (AES-256-GCM,FieldEncryption:Key), and looked up server-side at upload time. It is never accepted on the upload request and is redacted from all logs/diagnostics. - The SA ID number is mandatory at registration and stored in a non-nullable column, so every customer always has one on file. It is treated as an identity anchor: there is deliberately no admin "set/change ID" endpoint — a genuine correction would belong to a controlled, audited KYC process, not a routine mutation.
- Encrypting the PDF also opens it, so a structurally invalid PDF is rejected at upload.
- The stored file is already encrypted, so the secure download path is unchanged — the S3 presigned
redirect serves the encrypted bytes directly, and
isPasswordProtectedis alwaystrue. - So the customer knows how to open it, statement list and detail responses include a
passwordHint("Use your 13-digit South African ID number as the password…"), and the same hint is passed to the "new statement available" notification. The ID number itself is never sent — only the fact that it is the password.
Note: an SA ID number is low-entropy and partly public, so this protects against casual mis-delivery, not a determined attacker. It is a delivery convention, not the primary access control — the authenticated in-app download (JWT + ownership) remains the real security boundary.
Statements are stored in S3 with two independent, layered protections configured under Storage:S3:
- Encryption at rest — SSE-KMS. Set
KmsKeyIdto a customer-managed KMS key; every object is encrypted with it (s3:x-amz-server-side-encryption: aws:kms). Combined with the optional per-statement PDF password, sensitive documents are protected both at the storage layer and at the document layer. The pod's IAM role needskms:GenerateDataKey/kms:Decrypton the key. - Immutability — S3 Object Lock (WORM). With
UseObjectLock=true, each statement is written with a retention date (RetentionDays, default ~7 years) inObjectLockModeGovernanceorCompliance. In Compliance mode the object cannot be overwritten or deleted by anyone — including the account root — until retention elapses, satisfying regulatory archival (e.g. SEC 17a-4). The bucket must be created with Object Lock enabled — it cannot be turned on afterwards.
Revocation stays compatible with WORM:
DELETE /statements/{id}is a logical revoke (a status change in the database), not a file delete. The immutable object remains in S3 for its retention period, but the revoked statement is no longer served by any download path.
| Method | Path | Description | Permission |
|---|---|---|---|
GET |
/admin/audit-logs |
Download audit trail | AdminAuditLogs |
| Permission | Admin | Customer |
|---|---|---|
StatementsUpload |
✅ | — |
StatementsReadAny |
✅ | — |
StatementsReadOwn |
✅ | ✅ |
StatementsDownload |
✅ | ✅ |
StatementsRevoke |
✅ | — |
AdminAuditLogs |
✅ | — |
All settings can be overridden with environment variables using the __ separator (e.g., Jwt__Secret).
| Section | Key | Default | Description |
|---|---|---|---|
ConnectionStrings |
Database |
— | PostgreSQL connection string |
ConnectionStrings |
Redis |
— | Redis connection string (optional) |
Jwt |
Secret |
— | HMAC-SHA256 signing key (min 32 chars) |
Jwt |
Issuer |
secure-statement-delivery |
Token issuer claim |
Jwt |
Audience |
secure-statement-delivery-clients |
Token audience claim |
Jwt |
ExpirationInMinutes |
60 |
Access token lifetime |
Jwt |
RefreshTokenExpirationDays |
30 |
Refresh token lifetime |
DownloadToken |
Secret |
— | Separate signing key for download JWTs |
DownloadToken |
Issuer |
statement-download |
Download token issuer |
DownloadToken |
Audience |
statement-download-clients |
Download token audience |
Storage |
Provider |
Local |
Storage provider (Local) |
Storage |
LocalBasePath |
storage/statements |
Root path for PDF files |
Ingestion |
Enabled |
false |
Enable the pull (S3 event → SQS) ingestion worker. Requires Storage:Provider=S3 |
Ingestion |
QueueUrl |
— | SQS queue subscribed to the landing bucket's s3:ObjectCreated:* events |
Ingestion |
WaitTimeSeconds |
20 |
SQS long-poll wait |
Ingestion |
MaxMessagesPerPoll |
10 |
SQS receive batch size |
SecureStatementDelivery/
├── src/
│ ├── Domain/ # Aggregates, domain events, value objects
│ │ ├── Users/ # User, Role, RefreshToken
│ │ ├── Statements/ # Statement, StatementStatus
│ │ ├── DownloadTokens/ # DownloadToken
│ │ └── AuditLogs/ # DownloadAuditLog, AuditAction
│ ├── Application/ # CQRS handlers, validators, abstractions
│ │ ├── Users/ # Login, Register, RefreshToken
│ │ ├── Statements/ # List, GetById, Download, GenerateDownloadLink
│ │ └── Admin/ # GetAuditLogs
│ ├── Infrastructure/ # EF Core, Redis, JWT, file storage, Serilog
│ ├── SharedKernel/ # Result<T>, Entity, IDomainEvent
│ └── Web.Api/ # Minimal API endpoints, middleware, DI
├── tests/
│ └── ArchitectureTests/ # NetArchTest layer boundary enforcement
├── k8s/ # Kubernetes manifests (production-ready)
│ ├── postgres/
│ ├── redis/
│ ├── seq/
│ ├── web-api/
│ └── ingress/
├── docs/
│ └── architecture.md # Mermaid architecture diagrams
├── docker-compose.yml # Local development dependencies
└── SecureStatementDelivery.slnx
- Replace all
CHANGE_MEvalues ink8s/secret.yaml - Migrate secrets to Sealed Secrets / Vault / External Secrets Operator
- Set
storageClassNameink8s/web-api/pvc.yamlandk8s/postgres/statefulset.yaml - Update
k8s/ingress/ingress.yamlandk8s/ingress/cert-issuer.yamlwith real domain and email - Push a versioned Docker image and update
k8s/web-api/deployment.yaml - Storage is S3 in production (
Storage:Provider=S3) —LocalFileStorageServiceis dev-only - Create the S3 bucket with Object Lock ENABLED (cannot be enabled after creation) for WORM retention
- Provision a customer-managed KMS key and set
Storage:S3:KmsKeyId; default the bucket to SSE-KMS - Grant the pod's IRSA role
s3:PutObject/GetObject,s3:PutObjectRetention, andkms:GenerateDataKey/Decrypton that key - Set
Storage:S3:ObjectLockMode=ComplianceandRetentionDaysto your jurisdiction's statement-retention requirement - Wire the statement-available notification (StatementUploadedDomainEventHandler) to your outbox/notification service — no document or link in the message
- Production ingestion is machine-driven, not manual. Provision the
statement-generatorKeycloak client (rotateKEYCLOAK_INGEST_CLIENT_SECRET) for the push path, and/or setIngestion:Enabled=truewith an SQS queue + landing bucket for the pull path. Give both a dead-letter queue and monitor its depth - Restrict the
statement-ingestrole to the generator service account only — it is not a customer/admin role - Add ASP.NET Core Data Protection key persistence (Redis or a dedicated PVC) to support sticky-session-free deployments
- Configure PostgreSQL connection pooling (PgBouncer) for high-concurrency workloads
- Set up a PostgreSQL backup schedule (pgBackRest, Velero, or managed DB snapshots)
- Configure separate liveness (
/health/live) and readiness (/health/ready) endpoints to prevent pod restarts when a dependency is temporarily unavailable