Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Secure Statement Delivery Platform

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.

Features

  • 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 /metrics endpoint + 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

Architecture

See docs/architecture.md for full diagrams including Clean Architecture layers, Kubernetes infrastructure, the download request flow, and the security model.

Clean Architecture Layers

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)

Kubernetes Infrastructure

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

Getting Started

Prerequisites

Run with .NET Aspire (recommended)

.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 (Parameters section). 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.

Local Development (Docker Compose, 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:8081

Run Tests

dotnet test SecureStatementDelivery.slnx

All architecture tests verify that Clean Architecture layer boundaries are respected.

CI/CD

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.

Kubernetes Deployment

Prerequisites

1. Build and Push the Image

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.0

Update image: in k8s/web-api/deployment.yaml to match the pushed tag.

2. Configure Secrets

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.

3. Configure the Domain and Storage Class

Update the domain in k8s/ingress/ingress.yaml:

- host: api.your-domain.com
  # and in tls.hosts:
  - api.your-domain.com

Update 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

4. Install cert-manager Issuers

kubectl apply -f k8s/ingress/cert-issuer.yaml

Update email: in the file first.

5. Apply Manifests

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

6. Verify the Deployment

# 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

API Reference

Authentication

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/login and /auth/refresh are development conveniences and are not mapped in Production (they implement IDevelopmentOnlyEndpoint). 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: 0 in the realm). For browser clients, front the flow with a BFF so the refresh token lives in an httpOnly cookie, never in JS.

Statements

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.

Statement Ingestion — How Statements Get In (Production)

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).

Resumable Uploads (large files)

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:

  1. A TUS client (tus-js-client, TusDotNetClient, etc.) creates the upload with metadata customerId, period, filename, contentType, and optional description / documentId, then streams the file in chunks — interrupted transfers resume from the last acknowledged byte.
  2. Optionally open the /progress SSE stream to receive { uploaded, total, percent } events.
  3. On completion the server validates the PDF, promotes it to permanent storage, creates the Statement, and publishes the result. Poll /result to 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.

Consolidated (Multi-Month) Statements

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 the Consolidation section (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.Download permission, 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) for Consolidation: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 above Consolidation:MaxCacheableBytes are streamed but not cached.

Statement Period & Filtering

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.Create trims and validates the value; an invalid period returns Statements.InvalidPeriodFormat and the just-stored file is deleted so nothing is orphaned.

Listing supports period filtering via an optional range:

  • Preset windows — GET /statements?range=LastMonth (also Last3Months, Last6Months, Last12Months). Each resolves server-side to an inclusive YYYY-MM window 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). If range is omitted, any supplied periodFrom/periodTo are 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.

Password-Protected PDFs

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 isPasswordProtected is always true.
  • 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.

Data at Rest & Retention (production / S3)

Statements are stored in S3 with two independent, layered protections configured under Storage:S3:

  • Encryption at rest — SSE-KMS. Set KmsKeyId to 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 needs kms:GenerateDataKey / kms:Decrypt on the key.
  • Immutability — S3 Object Lock (WORM). With UseObjectLock=true, each statement is written with a retention date (RetentionDays, default ~7 years) in ObjectLockMode Governance or Compliance. 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.

Admin

Method Path Description Permission
GET /admin/audit-logs Download audit trail AdminAuditLogs

Roles and Permissions

Permission Admin Customer
StatementsUpload ✅ —
StatementsReadAny ✅ —
StatementsReadOwn ✅ ✅
StatementsDownload ✅ ✅
StatementsRevoke ✅ —
AdminAuditLogs ✅ —

Configuration Reference

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

Project Structure

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

Production Checklist

  • Replace all CHANGE_ME values in k8s/secret.yaml
  • Migrate secrets to Sealed Secrets / Vault / External Secrets Operator
  • Set storageClassName in k8s/web-api/pvc.yaml and k8s/postgres/statefulset.yaml
  • Update k8s/ingress/ingress.yaml and k8s/ingress/cert-issuer.yaml with real domain and email
  • Push a versioned Docker image and update k8s/web-api/deployment.yaml
  • Storage is S3 in production (Storage:Provider=S3) — LocalFileStorageService is 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, and kms:GenerateDataKey/Decrypt on that key
  • Set Storage:S3:ObjectLockMode=Compliance and RetentionDays to 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-generator Keycloak client (rotate KEYCLOAK_INGEST_CLIENT_SECRET) for the push path, and/or set Ingestion:Enabled=true with an SQS queue + landing bucket for the pull path. Give both a dead-letter queue and monitor its depth
  • Restrict the statement-ingest role 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

About

Secure statement Delivery

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages