Skip to content

    System Design

    QuickCart Stage 10: Secure, Observable and Ready to Run

    Secure QuickCart with OAuth 2.0 sign-in, encryption and secrets, then run it with SLO alerts, Kubernetes, infrastructure as code and canary releases.

    By Published 24 min read

    Share

    This lesson is part of Backend System Design Project: Build QuickCart. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to secure QuickCart with sign-in, authorization, encryption and secrets, defend against injection, page a human only for symptoms users feel, explain how Kubernetes, infrastructure as code and canary releases make change routine, and turn the project's traffic numbers into a node count and a cost model.

    A restaurant can have a wonderful kitchen and still not be ready to open. Someone decides who holds the till keys, cameras show what happened overnight, and the owner has a plan for the Friday rush.

    QuickCart, the online grocery marketplace of this course, already scales and survives failures. This stage makes it safe to expose, visible while it runs and routine to change. It is an educational reference design, and every number is an illustrative assumption.

    Where QuickCart is now

    After Stage 9, QuickCart is seven services behind an API gateway, handling about 2,300 browsing requests and 58 checkouts per second at peak (illustrative; the arithmetic is in Stage 1). The weak points are now operational:

    • Sign-in is home-grown: tokens have no scopes, last a long time and cannot be revoked per device.
    • Database passwords sit in config files, and logs live on each server.
    • Releases are hand-run scripts, and a bad one is noticed when a store owner phones.
    • Nobody has planned how many servers a peak needs, or what they cost.

    Figure 1. QuickCart at the start of Stage 10: sound design, weak operation.

    QuickCart before Stage 10: home-grown tokens, secrets in files, hand-run deploys, logs per serverClients to API gateway; API gateway to Seven services; Seven services to Data stores; Hand-run deploys to Seven services (manual push); Config files to Seven services (leak risk); Seven services to Logs per server (scattered)Clients: apps, dashboardsClientsapps, dashboardsAPI gateway: home-grown tokensAPI gatewayhome-grown tokensSeven services: from Stage 8Seven servicesfrom Stage 8Data stores: shared passwordData storesshared passwordHand-run deploys: no rollback planHand-rundeploysno rollback planConfig files: hold secretsConfig fileshold secretsLogs per server: no shared viewLogs perserverno shared viewmanual pushleak riskscattered

    How to read this: the top row is the Stage 8 request path. The red dashed arrows are the gaps this stage closes.

    The core idea

    Assume every request, credential and deploy can go wrong, and make the wrongness limited, visible and reversible. Limited: a stolen token reaches little, thanks to least privilege and short lifetimes. Visible: telemetry tells you before customers do. Reversible: a release can be undone fast.

    Sign in and decide who may do what

    Authentication and authorization

    Authentication asks "who are you?"; authorization asks "what may you do?". Perfect sign-in with no authorization lets any shopper read another's order by changing an id in the URL, the flaw OWASP ranks first in its 2023 API list, broken object-level authorization. After sign-in, shoppers, store staff, couriers and other services each need a way to prove identity on later requests.

    Approach How it works Strength Cost
    Server-side session Server stores state; browser holds a random id in a cookie Instant revocation Every request needs the shared session store (Stage 2)
    Signed token (JWT) Identity provider signs claims; any service verifies with a public key Fast, stateless Cannot be recalled before expiry; claims go stale
    Opaque token Random string; services ask the provider what it means (RFC 7662) Instant revocation One lookup per request

    Figure 2. Choosing a credential for each QuickCart client.

    flowchart TD accTitle: Choosing a credential type for a QuickCart client accDescr: A decision tree. A browser dashboard with one backend uses a server-side session cookie. Otherwise, if revocation within seconds is required, use opaque tokens. If many services verify independently, use short-lived signed tokens with revocable refresh tokens. Q1{"Browser app with one
    backend of its own?"} -->|yes| S["Server-side session
    HttpOnly cookie"] Q1 -->|no| Q2{"Revocable in seconds?"} Q2 -->|yes| O["Opaque token
    with introspection"] Q2 -->|no| Q3{"Many services verify
    it independently?"} Q3 -->|yes| J["Short-lived signed token
    plus refresh token"] Q3 -->|no| S

    How to read this: QuickCart ends with a mix: a session cookie for the store dashboard, short-lived signed tokens for the mobile apps, opaque tokens for refunds.

    OAuth 2.0, PKCE and OpenID Connect

    QuickCart should not store passwords or build its own login screen. It delegates sign-in to an identity provider (IdP) and receives tokens. OAuth 2.0 (RFC 6749) is delegated authorization: an app gets limited API access as an access token with scopes such as orders:write. OpenID Connect (OIDC Core) adds authentication: with the scope openid the IdP also returns an ID token, a JWT saying who signed in (sub) and who issued it (iss).

    QuickCart's apps use the authorization code flow with PKCE (Proof Key for Code Exchange, RFC 7636), because a mobile app cannot keep a secret and another app could steal the one-time code. The mechanism:

    1. The app creates a random code verifier and keeps it.
    2. It sends a code challenge, the base64url SHA-256 hash of the verifier, with code_challenge_method=S256.
    3. The IdP issues a one-time code and remembers the challenge.
    4. To redeem the code, the app sends the verifier. The IdP hashes and compares.

    A thief with the code lacks the verifier. The request below uses the example pair from RFC 7636 Appendix B:

    GET /authorize?response_type=code&client_id=quickcart-mobile&redirect_uri=com.quickcart.app%3A%2Fcallback&scope=openid%20profile&state=af0ifjsldkj&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256 HTTP/1.1
    Host: idp.quickcart.example

    The random state value is checked on return to stop forged redirects. At the time of writing, the OAuth 2.0 Security Best Current Practice (RFC 9700) requires PKCE for public clients, recommends it for confidential ones, says clients should not use the implicit grant and forbids the password grant. Services calling services use the client credentials grant (RFC 6749 section 4.4).

    Figure 3. Signing in to QuickCart from the mobile app.

    sequenceDiagram accTitle: Authorization code flow with PKCE for a QuickCart shopper accDescr: The shopper app sends a code challenge to the identity provider, the shopper authenticates, the provider returns a one-time code, the app redeems it with the code verifier, and then calls the gateway with the access token. participant S as Shopper app participant I as Identity provider participant G as API gateway S->>S: create verifier, hash it to a challenge S->>I: authorize request with challenge and state I->>S: login and consent screen S->>I: shopper authenticates I-->>S: redirect with one-time code S->>I: token request with code and verifier I-->>S: access token, ID token, refresh token S->>G: GET orders with access token G-->>S: orders

    How to read this: the code crosses the phone's redirect and could be intercepted, but is useless without the verifier only the starting app holds.

    JWTs, and the pitfalls of long-lived ones

    A JSON Web Token (RFC 7519) is three base64url parts joined by dots: a header (algorithm, key id), a payload of claims, and a signature. A QuickCart access token payload might decode to:

    {
      "iss": "https://idp.quickcart.example",
      "sub": "shopper_8f3a",
      "aud": "quickcart-api",
      "iat": 1794000000,
      "exp": 1794000600,
      "scope": "orders:read orders:write"
    }

    The payload is encoded, not encrypted, so it never carries secrets. The gateway verifies the signature with the IdP's cached public key, then the claims. RFC 8725, the JWT best current practices, is the checklist:

    • Fix the allowed algorithms. An attacker can set the header to none, or relabel an RS256 token HS256 so the public key acts as an HMAC secret (key confusion). Each key must be used with exactly one algorithm.
    • Validate iss, aud and exp every time.

    The main design pitfall is the long-lived JWT: nobody can recall it, so a stolen courier phone keeps working until expiry. Use a short access-token lifetime (10 minutes above, a teaching value) plus a revocable refresh token held by the IdP. RFC 9700 requires refresh tokens for public clients to be sender-constrained or rotated, so a replayed stolen token is detected. Claims also go stale: a demoted manager's token still says store_manager until it expires, so check high-risk actions at the source.

    RBAC and ABAC for shoppers, couriers and stores

    Role-based access control (RBAC), per the NIST RBAC model, grants permissions to roles (shopper, store_staff, courier, support_agent). It is easy to audit but cannot say which orders. Attribute-based access control (ABAC) (NIST SP 800-162) decides from attributes of subject, resource, action and context.

    Caller RBAC says ABAC adds
    Shopper May read and cancel orders Only where order.shopperId == subject.id
    Store staff May update stock Only where resource.storeId == subject.storeId (Stage 8 tenant isolation)
    Courier May read a delivery Only the assigned order, until it is delivered
    Support agent May view and refund Only with an open case on that order
    // Pseudocode: the object-level check, run in the Orders service on every read
    function canReadOrder(subject, order) {
      if (subject.role === "shopper") return order.shopperId === subject.id;
      if (subject.role === "store_staff") return order.storeId === subject.storeId;
      if (subject.role === "courier") {
        return order.courierId === subject.id && order.status !== "DELIVERED";
      }
      return subject.role === "support_agent" && subject.hasOpenCaseFor(order.id);
    }

    Run the check in the service that owns the data, with fresh data. The gateway can reject bad tokens and coarse scopes, but it cannot know who is assigned to an order now.

    Protect the data

    Encryption in transit and at rest

    Encryption in transit stops anyone on the path reading or altering traffic, using TLS (TLS 1.3 is RFC 8446): the server presents a certificate, the client validates it, and both derive session keys. Inside the cluster QuickCart uses mutual TLS (mTLS), where both sides present certificates, so Payments knows the caller really is Orders. A service mesh (Stage 8) can issue and rotate those certificates, a task that is error-prone by hand.

    Encryption at rest protects stored bytes against a stolen disk or leaked snapshot, not against a compromised application holding a valid key. The question is where keys live. Envelope encryption, offered by the major cloud key-management services (KMS), answers it:

    1. A key-encryption key (KEK) lives inside the KMS and never leaves it.
    2. The service asks the KMS for a fresh data key and receives it in plaintext and wrapped (encrypted) under the KEK.
    3. It encrypts the data locally, stores ciphertext plus the wrapped key, and discards the plaintext key.
    4. To read, it sends the wrapped key to the KMS, which unwraps it only for an authorised caller.

    Bulk encryption stays local and fast. Rotating the KEK means re-wrapping small data keys, not re-encrypting terabytes, and each unwrap is auditable. The ledger and address table get separate keys.

    Secrets management

    A secret is a credential a service needs: database passwords, SMS and email provider keys, signing keys.

    • Not in images or repositories. An image layer keeps what was written to it even if a later step deletes the file, and Git history keeps a committed password forever. Scan commits.
    • Injected at runtime from a secrets manager. Kubernetes Secret objects are, by default, stored unencrypted in etcd (documentation), so enable encryption at rest, restrict access with RBAC, or use an external store.
    • Short-lived where possible. A manager can mint a database user valid for an hour, and workload identity lets a Pod call cloud APIs with no stored key.
    • Rotated and rehearsed. Accept two versions during a switch. After a leak, revoke first and investigate second.

    Input validation and injection prevention

    Treat every input as hostile until validated against an allow-list of what is permitted.

    SQL injection pastes input into query text so that data becomes code. Store staff look up a product by name:

    // VULNERABLE: input is concatenated into the SQL text
    const sql = "SELECT id, name FROM products WHERE store_id = " + storeId +
                " AND name = '" + name + "'";

    If name is x' OR '1'='1, the database receives:

    SELECT id, name FROM products WHERE store_id = 7 AND name = 'x' OR '1'='1'

    AND binds tighter than OR, so the condition is (store_id = 7 AND name = 'x') OR '1'='1', true for every row: one text box has broken tenant isolation. The fix is a parameterised query: SQL text and values travel separately, so the database never parses a value as SQL.

    // SAFE: node-postgres sends the values separately from the SQL text
    const result = await client.query(
      "SELECT id, name FROM products WHERE store_id = $1 AND name = $2",
      [storeId, name],
    );

    Parameters cannot replace identifiers such as column names, so map those from a fixed allow-list (OWASP).

    Two relatives matter. Server-side request forgery (SSRF): a feature that imports a store's product photo from a web address makes a server fetch whatever it is given. An attacker supplies an internal address such as the cloud metadata endpoint 169.254.169.254 and gets credentials back. Defend in layers: allow only expected hosts, refuse private and link-local addresses after name resolution and redirects, and block the fetcher's path to internal ranges (OWASP). Insecure deserialisation: in some formats (Java native serialisation, Python pickle) turning outside bytes into objects can run attacker-chosen code, so accept data-only formats such as JSON, validated against a schema. Stage 5 queue messages deserve the same suspicion.

    Zero trust and least privilege

    The older model trusts the inside of the network. Zero trust (NIST SP 800-207) grants no implicit trust from network location: each request is authenticated and authorised on its own, assuming some part is already compromised. Least privilege gives each identity only what its job needs. For QuickCart:

    • Each service has its own identity, mTLS certificate and database user with only the grants it uses. Notifications has no path to the ledger.
    • A network policy denies traffic by default and allows named flows. In Kubernetes a NetworkPolicy does this if the network plugin enforces it:
    apiVersion: networking.k8s.io/v1
    kind: NetworkPolicy
    metadata:
      name: payments-from-orders-only
      namespace: quickcart
    spec:
      podSelector:
        matchLabels:
          app: payments
      policyTypes:
        - Ingress
      ingress:
        - from:
            - podSelector:
                matchLabels:
                  app: orders
          ports:
            - protocol: TCP
              port: 8080

    Figure 4. Trust boundaries in QuickCart after this stage.

    QuickCart trust boundaries: TLS at the edge, mTLS inside, per-service secrets and keysShopper app to API gateway (TLS); Identity provider to API gateway (keys); API gateway to Orders service (mTLS); Orders service to Payments service (mTLS); Orders service to Orders database (TLS); Payments service to Ledger database (TLS); Notification service to Payments service (denied); Secrets and keys to Ledger database (wraps data keys)PUBLIC INTERNETQUICKCART NETWORKDATA TIERShopper appShopper appIdentity provider: OIDCIdentityproviderOIDCAPI gateway: verifies tokensAPI gatewayverifies tokensOrders serviceOrders servicePayments servicePaymentsserviceNotification serviceNotificationserviceOrders database: encrypted at restOrdersdatabaseencrypted at restLedger database: its own keyLedgerdatabaseits own keySecrets and keys: rotatedSecrets andkeysrotatedTLSkeysmTLSmTLSTLSTLSdeniedwraps data keys

    How to read this: solid arrows are allowed, encrypted calls. The red dashed arrow is a call that a network policy and a missing grant both refuse, so a compromised Notification service cannot reach the ledger.

    Compliance and privacy

    NOTE

    This section explains concepts and is not legal advice. Which laws apply depends on where QuickCart operates and where its shoppers live, so ask a qualified lawyer.

    QuickCart holds personal data: names, addresses, order history, courier locations. In the EU the General Data Protection Regulation (GDPR) governs this, and many places have similar laws. The design-relevant ideas:

    • Lawful basis (Article 6): each use needs a legal reason, such as a contract (delivering the order), legal obligation (tax records) or consent (marketing).
    • Minimisation and storage limitation (Article 5): collect what the purpose needs and keep it no longer, so courier GPS gets short retention.
    • Access and erasure (Articles 15 and 17): deleting a person is not one DELETE, because data lives in the orders database, search index, caches, warehouse and backups, and ledger records may have to stay. Keep ledger entries without personal fields, and run a tracked deletion job across every store. Backups age out.
    • Residency and transfers (Chapter V): transfers outside the EU need safeguards. The multi-region plan from Stage 9 must state which regions hold personal data, and some national laws are stricter.

    Log ids, not names; breaches may need reporting within a fixed time (Article 33). For cards (Stage 6), let the payment provider hold card numbers so QuickCart never stores them, which also narrows its PCI DSS scope.

    See what the system is doing

    Logs, metrics and traces

    You cannot fix what you cannot see. The three signals answer different questions (observability and SLOs goes deeper):

    Signal What it is Answers Watch out for
    Metrics Numbers over time "Is something wrong, since when?" High-cardinality labels: an order_id label creates a series per order
    Logs Timestamped event records "What happened in this request?" Volume, personal data
    Traces One request's path across services as timed spans "Where did the time or error come from?" Sampling; instrumentation gaps

    The glue is a correlation id copied through every hop into every log line; a trace id does this job. The W3C Trace Context Recommendation (2021) defines a traceparent header of four dash-separated fields (version, trace id, parent span id, flags), such as 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. The gateway creates it and each service forwards it.

    OpenTelemetry is the vendor-neutral standard: an API and SDK in each service, the Collector, a separate process that receives telemetry (usually over OTLP) and batches, filters and redacts it, and exporters to your chosen backend, so changing backends needs no re-instrumenting. Tail-based sampling keeps every slow or failed trace and a share of healthy ones.

    Figure 5. One QuickCart checkout, followed by its trace id.

    sequenceDiagram accTitle: One checkout request carrying a trace id across services accDescr: The shopper app calls the gateway, which verifies the token and creates a trace id. Orders checks the cart owner and calls Payments with the same trace id. Every service sends spans to the collector. participant S as Shopper app participant G as API gateway participant O as Orders service participant P as Payments service participant C as Collector S->>G: POST checkout with access token G->>G: verify token, create trace id G->>O: request plus traceparent O->>O: authorise, log with trace id O->>P: charge, same traceparent P-->>O: payment accepted O-->>G: 201 order created G-->>S: 201 order created G-)C: spans for the hop O-)C: spans and logs P-)C: spans and logs

    How to read this: the trace id is made once at the edge and copied downstream, so one search returns the whole story of one checkout. Telemetry goes to the Collector off the request path.

    A structured log line from Orders makes that search possible:

    {"ts":"2026-11-07T21:14:03.412Z","level":"error","service":"orders","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","event":"payment_timeout","order_ref":"o_91c2","timeout_ms":2000}

    RED (Rate, Errors, Duration) suits request-driven services, such as QuickCart's checkout dashboard. USE (Utilization, Saturation, Errors, from Brendan Gregg) suits resources: the node pool, connection pools and Stage 5 queue lag.

    Figure 6. The telemetry pipeline.

    Telemetry pipeline: services emit through OpenTelemetry to stores, dashboards and alertsQuickCart services to OpenTelemetry Collector (OTLP); OpenTelemetry Collector to Metrics store; OpenTelemetry Collector to Log store; OpenTelemetry Collector to Trace store; Metrics store to Alert rules; Log store to Dashboards; Trace store to DashboardsQuickCart services: SDK in eachQuickCartservicesSDK in eachOpenTelemetry Collector: batch, sampleOpenTelemetryCollectorbatch, sampleMetrics store: time seriesMetrics storetime seriesLog store: searchableLog storesearchableTrace store: sampledTrace storesampledDashboards: RED and USEDashboardsRED and USEAlert rules: SLO burn rateAlert rulesSLO burn rateOTLP

    How to read this: flow is one-way and off the request path. Alert rules read metrics; humans open dashboards, logs and traces after an alert says where to look.

    Alerting and on-call

    An alert is a demand on a person's sleep. Page on symptoms users feel, not causes: "checkout fails for 1.5 percent of shoppers" is a symptom, while "CPU on one node is 85 percent" is a cause, often harmless, that belongs on a dashboard.

    Symptoms come from SLOs. Suppose checkout's SLO is 99.9 percent success over 30 days (illustrative). At 500,000 orders a day that is 15 million attempts a month, so the error budget is 0.1 percent: 15,000 failures. Burn rate is how fast the budget is spent; 1 uses it exactly over the window. The SRE Workbook chapter Alerting on SLOs pairs a long and a short window:

    Severity Long window Short window Burn rate Budget spent
    Page 1 hour 5 minutes 14.4 2%
    Page 6 hours 30 minutes 6 5%
    Ticket 3 days 6 hours 1 10%

    Check the first row: 30 days is 720 hours, so 14.4 for one hour spends 14.4 / 720 = 2 percent, and for a 99.9 percent SLO it means a 1.44 percent error ratio. As a Prometheus rule (metric name illustrative):

    groups:
      - name: quickcart-checkout-slo
        rules:
          - alert: CheckoutFastBurn
            expr: |
              (
                sum(rate(checkout_requests_total{outcome="error"}[1h]))
                / sum(rate(checkout_requests_total[1h]))
              ) > (14.4 * 0.001)
              and
              (
                sum(rate(checkout_requests_total{outcome="error"}[5m]))
                / sum(rate(checkout_requests_total[5m]))
              ) > (14.4 * 0.001)
            labels:
              severity: page
            annotations:
              summary: "Checkout is burning its error budget 14.4 times too fast"
              runbook_url: "https://runbooks.quickcart.example/checkout-fast-burn"

    Both windows must exceed the threshold, so a finished spike does not page. Every page links a runbook: what the alert means, the first three checks, how to mitigate (roll back, shed load, fail over) and who to escalate to. Write it for a tired person at 02:00.

    On-call is a rota with a primary and a secondary, clear escalation and a cap on pages per shift, plus a blameless postmortem after serious incidents. A page needing no action should be deleted, because alert fatigue is a reliability risk.

    Run it

    Containers and orchestration

    A container image is a read-only, layered package of an application and its dependencies that runs the same everywhere. Build once, promote the same image, and tag by immutable version, never latest. Keep it small, staged and non-root:

    FROM node:22-slim AS build
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci
    COPY . .
    RUN npm run build
    
    FROM node:22-slim
    WORKDIR /app
    ENV NODE_ENV=production
    COPY --from=build /app/package*.json ./
    RUN npm ci --omit=dev
    COPY --from=build /app/dist ./dist
    USER node
    EXPOSE 8080
    CMD ["node", "dist/server.js"]

    The second stage copies only what runs. Pin the base image and scan images.

    An orchestrator runs containers across machines and replaces failed ones. In Kubernetes, a Pod is one or more containers scheduled together, a Deployment keeps N copies of a Pod template running and manages updates, and a Service gives Pods one stable address and balances across the ready ones. This Deployment for the Catalogue service carries the settings that matter:

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: catalogue
      namespace: quickcart
    spec:
      replicas: 6
      selector:
        matchLabels:
          app: catalogue
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxSurge: 25%
          maxUnavailable: 0
      template:
        metadata:
          labels:
            app: catalogue
        spec:
          topologySpreadConstraints:
            - maxSkew: 1
              topologyKey: topology.kubernetes.io/zone
              whenUnsatisfiable: DoNotSchedule
              labelSelector:
                matchLabels:
                  app: catalogue
          containers:
            - name: catalogue
              image: registry.quickcart.example/catalogue:1.42.0
              ports:
                - containerPort: 8080
              resources:
                requests:
                  cpu: "1"
                  memory: 1Gi
                limits:
                  memory: 1Gi
              readinessProbe:
                httpGet:
                  path: /ready
                  port: 8080
                periodSeconds: 5
                failureThreshold: 3
              livenessProbe:
                httpGet:
                  path: /healthz
                  port: 8080
                initialDelaySeconds: 10
                periodSeconds: 10
    • Probes. Readiness decides whether a Pod receives traffic: while it fails the Pod leaves the Service but is not restarted. Liveness detects a stuck container, and failure restarts it. A startup probe holds both off for slow starters. Keep liveness local: if it tests the database, one blip restarts every Pod.
    • Requests and limits. A request is what the scheduler reserves; a limit is a ceiling: over its CPU limit a container is throttled, over its memory limit it is killed.
    • Zone spread places replicas evenly across zones, making Stage 9's multi-AZ promise real. Rolling update with maxUnavailable: 0 starts new Pods before removing old ones.

    A HorizontalPodAutoscaler (HPA) adds and removes replicas by the documented formula desiredReplicas = ceil[currentReplicas x (currentMetricValue / desiredMetricValue)]: 10 Pods at 90 percent average CPU against a 60 percent target gives ceil(10 x 90 / 60) = 15. Utilisation is measured against CPU requests, so Pods without one give it nothing to act on. It reacts only after metrics arrive and new Pods start, so it follows ramps well and spikes badly, which is why the capacity plan keeps headroom.

    apiVersion: autoscaling/v2
    kind: HorizontalPodAutoscaler
    metadata:
      name: catalogue
      namespace: quickcart
    spec:
      scaleTargetRef:
        apiVersion: apps/v1
        kind: Deployment
        name: catalogue
      minReplicas: 6
      maxReplicas: 30
      metrics:
        - type: Resource
          resource:
            name: cpu
            target:
              type: Utilization
              averageUtilization: 60

    Infrastructure as code

    Infrastructure as code (IaC) describes networks, databases, clusters and permissions in version-controlled files instead of console clicks. The tools are declarative: the file states the desired end state and the tool works out the steps. The gains:

    • Review. A change is a pull request, and a plan shows what will be created, changed or destroyed first.
    • Repeatability. Staging matches production, and a lost region can be recreated, as Stage 9's recovery plan needs.
    • Drift detection. Drift is reality diverging from the files, usually after a manual incident fix. A scheduled plan reports it.

    The Terraform loop is two commands:

    terraform plan -out=tfplan   # compare files to real state, print the proposed changes
    terraform apply tfplan       # execute exactly the plan that was reviewed

    A plan ends with a summary such as Plan: 1 to add, 1 to change, 0 to destroy.; reviewers look hardest at the destroy count. terraform plan -detailed-exitcode exits 2 when changes are pending, which a scheduled job can use to flag drift. Keep state in a remote, encrypted, locked store, since it can contain secrets. GitOps extends the idea to the cluster: manifests live in Git and a controller reconciles them.

    Deployment strategies

    A deployment puts new code on servers; a release exposes it to users. Separating them makes change safe.

    Strategy How it works Gain Cost Wrong when
    Rolling Replace Pods a few at a time Built into Kubernetes Old and new coexist Two versions cannot run together
    Blue-green Run a full new copy, switch traffic at once Instant switch and rollback Double capacity; everyone exposed at once Capacity is tight or the schema is incompatible
    Canary Send a small traffic share to the new version, compare, widen Bad releases hurt few users Needs good metrics and traffic splitting Too little traffic to compare

    QuickCart uses rolling updates for low-risk services and canary releases with automatic rollback for Orders and Payments. The Kubernetes Deployment controller does not roll back by itself (past its progress deadline it only reports a condition), so automatic analysis comes from a progressive-delivery controller such as Argo Rollouts or Flagger, or from traffic splitting in a mesh.

    Figure 7. The delivery pipeline.

    Delivery pipeline: Git to CI to registry to cluster, with infrastructure code reviewed separatelyDeveloper to Git repository (push); Git repository to CI pipeline (on merge); CI pipeline to Image registry (push image); Git repository to Deploy controller (manifests); Image registry to Kubernetes cluster (pull image); Deploy controller to Kubernetes cluster (apply); Git repository to Infra code (infra change); Infra code to Cloud resources (apply)DeveloperDeveloperGit repository: pull requestGit repositorypull requestCI pipeline: test, scan, buildCI pipelinetest, scan, buildImage registry: immutable tagsImage registryimmutable tagsDeploy controller: pulls from GitDeploycontrollerpulls from GitKubernetes cluster: canary, then fullKubernetesclustercanary, then fullInfra code: plan, applyInfra codeplan, applyCloud resources: network, nodesCloudresourcesnetwork, nodespushon mergepush imagemanifestspull imageapplyinfra changeapply

    How to read this: application changes flow along the top into the cluster. Infrastructure changes take the lower path with their own plan and review. Nothing reaches production except through Git and the pipeline.

    Figure 8. How a canary decides.

    flowchart TD accTitle: Canary release with automatic rollback accDescr: A new version receives a small share of traffic. Its error ratio and latency are compared with the old version. If healthy the share grows in steps to 100 percent. If not, traffic returns to the old version automatically and the team is notified. A["Deploy new version
    0 percent of traffic"] --> B["Send 1 percent"] B --> C{"Error ratio and latency
    within limits for 10 minutes?"} C -->|no| R["Automatic rollback
    old version takes 100 percent"] C -->|yes| D["Raise to 5, 25, 50 percent
    checking at each step"] D --> E{"Healthy at every step?"} E -->|no| R E -->|yes| F["100 percent
    keep old version ready"] R --> N["Notify the team
    keep the evidence"]

    How to read this: the percentages and ten-minute hold are illustrative. The shape matters: small first, compare, widen, return automatically on failure.

    Database migrations during deploys are where releases hurt, because old and new code share one database mid-rollout. The rule is expand, then contract. To replace orders.legacy_notes with delivery_notes: (1) expand by adding the nullable column, which both versions tolerate; (2) release code that writes both and reads the new one; (3) backfill old rows in small batches, per shard (Stage 4); (4) contract by dropping the old column once no running version reads it.

    -- Expand: safe while old and new code run together
    ALTER TABLE orders ADD COLUMN delivery_notes text;
    
    -- Backfill in small batches (repeat with the next id range)
    UPDATE orders SET delivery_notes = legacy_notes
    WHERE id BETWEEN 1 AND 10000 AND delivery_notes IS NULL;
    
    -- Build any new index without blocking writes
    CREATE INDEX CONCURRENTLY idx_orders_delivery_notes ON orders (delivery_notes);
    
    -- Contract: only after no running version reads legacy_notes
    ALTER TABLE orders DROP COLUMN legacy_notes;

    Adding a nullable column is a quick metadata change in PostgreSQL, and since version 11 so is adding one with a non-volatile default; check your engine. Never drop a column in the release that stops using it, or a code rollback meets a schema it no longer matches.

    Capacity planning

    Capacity planning turns demand into resources: forecast, measured capacity per unit, units needed, headroom (capacity and cost has the method). Demand is from the project: about 2,300 browsing requests and 58 checkouts per second at peak. Unit capacity must come from a load test; these are illustrative assumptions standing in for load-test results: a browsing Pod sustains 150 requests per second and a checkout Pod 20 checkouts per second, each requesting 1 vCPU.

    Step Browsing Checkout
    Pods at 100% of tested capacity 2,300 / 150 = 15.3, so 16 58 / 20 = 2.9, so 3
    At a 60% target (HPA lag, noisy peaks) 2,300 / (150 x 0.6) = 25.6, so 26 58 / (20 x 0.6) = 4.8, so 5
    Lose 1 of 3 zones, survivors at most 80% busy 2,300 / (150 x 0.8) = 19.2, so 20 in two zones: 10 per zone, 30 in all 58 / (20 x 0.8) = 3.6, so 4 in two zones: 2 per zone, 6 in all

    The last row is the sizing rule: capacity must hold after the worst failure you promised to survive. That gives 36 Pods. If an 8 vCPU node offers about 7 vCPU to Pods after system reservations (illustrative), that is 7 Pods per node, so 36 / 7 = 5.1, rounded up to 6 nodes, 2 per zone. Other services add more the same way.

    Headroom also covers rolling-update surge and cold caches. Forecast from real weekly and holiday history, and rerun the plan quarterly.

    Cost optimization

    Cloud cost is an architecture property. Know the drivers, then match levers:

    Driver Why it grows Levers
    Compute Idle headroom, oversized requests Right-size, autoscale, interruptible capacity
    Databases, caches, storage Always on, replicated, never deleted Right-size, lifecycle rules, retention limits
    Network egress Data leaving the cloud, a region or, on some providers, a zone CDN for images, compression, keep chatty services together
    Observability High-cardinality metrics, verbose logs, every trace stored Sampling, shorter hot retention, label limits

    Right-sizing sets requests from observed use plus margin, not guesses. Reserved or committed-use pricing trades a one- or three-year commitment for a lower rate than on-demand: commit to the steady floor (minimum nodes at 04:00) and leave the peak on demand. Interruptible (spot) capacity can be reclaimed by the provider, so it suits stateless workers and batch analytics, not a primary database. Discounts and terms differ by provider, so read each pricing page rather than trusting a figure here.

    Track cost per unit of value: monthly cloud spend divided by orders (15 million a month at the project's volume), shown as cost per 1,000 orders. If orders double and the bill doubles, nothing improved. Cutting a replica or retention period trades money for risk; the SLO says how much is acceptable.

    Decisions and trade-offs

    Option Gain Cost When it is the wrong choice
    Managed OIDC provider No password storage; MFA available Vendor dependency, per-user cost A hard requirement forces self-hosting
    Short-lived JWT plus refresh token Stateless verification at the gateway Staleness up to the lifetime Access must end within seconds
    mTLS from a service mesh Strong service identity, automatic certificates Running a mesh Few services, no mesh skills
    Canary with auto-rollback Small blast radius, no human needed Needs good SLIs and traffic splitting Too little traffic to compare versions

    In practice: QuickCart at this stage

    The situation

    QuickCart has 5 million monthly active users, heading to 10 million (illustrative). A six-engineer platform team shares an on-call rota of four. A security review found that tokens carry no scopes and three services share one database password, and a key leaked in a ticket took two days to rotate by hand. Releases are weekly and manual.

    Starting architecture

    The starting point is Figure 1.

    Options considered

    Option What it fixes Cost Risk
    A. Keep servers and scripts; add dashboards and a password vault Visibility, some secrets Cheap; identity and deploy gaps remain Medium: manual releases stay
    B. OIDC, secrets manager, OpenTelemetry, Kubernetes, GitOps and canaries, in that order All the gaps Several quarters of effort, new skills Medium; each step is reversible
    C. Everything at once, with a mesh on every service The full target Largest change; five new tools together High

    The decision

    Field Decision record
    Decision Option B in a fixed order: telemetry, secrets, OIDC at the gateway with object-level checks, Kubernetes service by service, then canaries for Orders and Payments.
    Good consequences Pages come from burn rates, credentials are short-lived, bad releases roll back automatically.
    Bad consequences The team runs a cluster and an observability stack, and spend rises first.

    Rollout and verification

    1. Instrument the gateway and Orders with OpenTelemetry, and find one checkout from a single trace id.
    2. Define the checkout SLO, write burn-rate alerts and runbooks, and run a fire drill that reaches the on-call phone.
    3. Move secrets into the manager and rotate each one once on purpose.
    4. Put OIDC in front of the gateway behind a flag, verifying tokens in shadow mode against the old tokens. Test that one shopper requesting another's order is refused.
    5. Move Catalogue to Kubernetes, then the rest, Payments last. Run a game day that removes a zone to test the capacity plan.
    6. Enable canaries for Orders and Payments and push a bad build in staging to prove the rollback.

    The 2 a.m. failure

    At 02:07 the on-call phone pages: "Checkout is burning its error budget 14.4 times too fast." Orders is returning errors on calls to Payments.

    Service certificates are short-lived and renewed by an issuer component. Weeks earlier a credential the issuer uses to reach its certificate authority had quietly expired, and no alert watched renewal failures. Certificates expired in waves, and by 02:00 Orders could no longer complete a handshake with Payments.

    The runbook's first three checks: search logs by a failing trace id (the error reads "certificate has expired"), read certificate expiry on the Payments Pods, and read the issuer's logs. Mitigation is to restore the issuer's credential and force re-issue. Rolling back the last release would not help. Follow-ups: alert when any certificate is within days of expiry (a cause alert is right here, because the lead time is long), alert on renewal failures, and rehearse rotation quarterly.

    This is a realistic scenario, not a case history.

    Architecture after this stage

    Muted boxes are earlier stages; highlighted boxes are new.

    Figure 9. QuickCart after Stage 10.

    QuickCart after Stage 10: identity, secrets and keys, telemetry, Kubernetes and a delivery pipeline addedClients to API gateway (TLS); Identity provider to API gateway (public keys); API gateway to Seven services (mTLS); Seven services to Data stores; Secrets and keys to Seven services (credentials); Seven services to Telemetry pipeline (OTLP); Telemetry pipeline to SLO alerts; CI/CD pipeline to Seven services (deploy)KUBERNETESClients: apps, dashboardsClientsapps, dashboardsIdentity provider: OIDC, PKCEIdentityproviderOIDC, PKCEAPI gateway: verifies tokensAPI gatewayverifies tokensSeven services: mTLS insideSeven servicesmTLS insideData stores: encrypted at restData storesencrypted at restSecrets and keys: short-livedSecrets andkeysshort-livedTelemetry pipeline: three signalsTelemetrypipelinethree signalsSLO alerts: runbooks, on-callSLO alertsrunbooks, on-callCI/CD pipeline: canary, rollbackCI/CD pipelinecanary, rollbackTLSpublic keysmTLScredentialsOTLPdeploy

    How to read this: requests flow left to right, authenticated and encrypted at each hop. The top row holds identity and secrets. The bottom row is operations: telemetry flows to alerts, and the pipeline delivers changes into the cluster.

    What QuickCart looks like now

    Component Why it exists What it costs
    Identity provider and object-level checks Sign-in, MFA, tokens; no cross-tenant reads Vendor cost; a check on every read
    TLS, mTLS, KMS encryption Protects data on the wire and on disk Certificate and key operations
    Secrets manager No secrets in images or repositories One more critical dependency
    Telemetry and SLO alerts Find problems before customers do Ingestion cost; runbook effort
    Kubernetes, probes, HPA Repeatable running, self-healing, autoscaling Cluster operations skills
    IaC and GitOps Reviewed, repeatable infrastructure Pipeline work; state discipline
    Canary releases Small blast radius for bad releases Traffic splitting and reliable SLIs

    Architect's notes

    • Order the work: see, then secure, then change. Telemetry makes later steps verifiable.
    • Decide identity early. The IdP, token lifetime and who verifies what are costly to change; mesh features can wait.
    • Rotate every secret once before you must. Until you have, you do not know it can be.
    • Page on burn rate, and size capacity for the failure you promised to survive.

    Common mistakes

    • Authenticating without authorizing, or trusting the algorithm named in a token header.
    • Long-lived JWTs with no revocation plan.
    • Secrets in images, repositories or logs; string-built SQL; fetching user-supplied URLs from inside the network.
    • A liveness probe that checks the database. One blip restarts every Pod.
    • Alerting on causes, and unbounded ids in metric labels.
    • Sizing for steady state, and dropping a column in the release that stops using it.

    Key takeaways

    • Authentication proves who; authorization decides what, per object, in the service that owns the data.
    • Use the authorization code flow with PKCE, validate JWT algorithm, issuer, audience and expiry, and keep access tokens short-lived.
    • Encrypt in transit (TLS, mTLS) and at rest (envelope encryption); apply zero trust and least privilege.
    • Keep secrets out of images and repositories, inject them at runtime, and prefer short-lived ones.
    • Parameterise queries, validate at the boundary, and treat server-side fetches and deserialisation as attack surface.
    • Privacy law shapes design: lawful basis, minimisation, deletion across every store. Take legal advice.
    • Alert on SLO burn rate, give every page a runbook, and tie signals together with trace ids.
    • Migrations go expand, then contract; capacity is sized for the worst promised failure.

    Practice exercises

    Exercise 1 (30 minutes): Redo the browsing capacity table for 3,000 requests per second at peak, 120 per Pod, a 60 percent target, and the loss of one of four zones with survivors at most 80 percent busy.

    Exercise 2 (1 hour): Write the authorization rules for a store manager exporting a monthly sales report, and the test proving another store's manager is refused.

    Exercise 3 (2 hours): For a 99.5 percent browse-latency SLO (illustrative), compute the error ratio at which burn rate 6 fires and draft the runbook's first three checks.

    Exercise 4 (half a day): Plan the migration that splits orders.delivery_address into street, city and postcode: the four releases and the rollback at each point.

    Official documentation

    Enroll to keep reading

    Lesson 10 of 11 · Backend System Design Project: Build QuickCart

    Secure QuickCart with OAuth 2.0 sign-in, encryption and secrets, then run it with SLO alerts, Kubernetes, infrastructure as code and canary releases.

    In the rest of this lesson

    • See what the system is doing
    • Run it
    • Decisions and trade-offs
    • In practice: QuickCart at this stage
    • Architecture after this stage
    • Architect's notes

    Enrolling is free. It unlocks every lesson in Backend System Design Project: Build QuickCart, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments