AWS GovCloud Architecture: SPA + Small Backend, Cognito-Gated

AWS GovCloud Architecture: SPA + Small Backend, Cognito-Gated

Status: Draft recommendation Date: 2026-05-22 Partition: aws-us-gov only — no commercial-partition components Primary region: us-gov-west-1


TL;DR

Single-partition GovCloud deployment. Host the SPA in S3 (us-gov-west-1) behind an HTTPS-terminating ALB + WAF. Run the backend as Lambda behind API Gateway REST. Gate the API with a Cognito User Pool Authorizer pointing at the other team’s user pool. The SPA authenticates via OIDC Authorization Code + PKCE directly against the Cognito Hosted UI on FIPS endpoints. No CloudFront, no Amplify, no cross-partition anything.


High-Level Architecture

flowchart LR
    User[End User Browser]

    subgraph GovCloud["AWS GovCloud us-gov-west-1"]
        ALB[ALB + WAF<br/>HTTPS, ACM cert]
        S3[(S3 Bucket<br/>SPA assets, private)]
        APIGW[API Gateway REST<br/>+ WAF<br/>Cognito Authorizer]
        Lambda[Lambda Functions]
        DDB[(DynamoDB / RDS)]
    end

    subgraph CognitoTeam["Cognito - other team, GovCloud"]
        Pool[User Pool<br/>FIPS Hosted UI]
    end

    User -->|HTTPS| ALB
    ALB --> S3
    User -->|OIDC PKCE| Pool
    User -->|Bearer token| APIGW
    APIGW -->|validate JWT via JWKS| Pool
    APIGW --> Lambda
    Lambda --> DDB

Everything in one partition, one account if you want, one region.


Authentication Flow

sequenceDiagram
    autonumber
    participant U as User Browser
    participant SPA as SPA in S3 via ALB
    participant Cog as Cognito Hosted UI<br/>auth-fips.us-gov-west-1
    participant API as API Gateway REST
    participant L as Lambda

    U->>SPA: GET /
    SPA->>U: render, detect no session
    U->>Cog: /oauth2/authorize<br/>code + PKCE challenge
    Cog->>U: login form
    U->>Cog: credentials
    Cog->>U: 302 redirect with code
    U->>Cog: /oauth2/token<br/>code + verifier
    Cog->>U: id_token + access_token + refresh_token
    U->>API: GET /resource<br/>Authorization Bearer id_token
    API->>API: Cognito Authorizer<br/>validates iss aud exp sig
    API->>L: invoke with claims
    L->>API: response
    API->>U: 200 + payload

Component Matrix

ConcernChoiceNotes
Static hostingS3 us-gov-west-1Private bucket, ALB origin via VPC endpoint or signed access
TLS terminationALB + ACMACM is fully supported in GovCloud
CDNNoneCloudFront not in GovCloud; acceptable for gov-internal user base
Backend computeLambdaCanonical GovCloud serverless
API layerAPI Gateway RESTREST chosen for native Cognito User Pool Authorizer
Auth gate (API)Cognito User Pool AuthorizerZero validation code to write
Auth (frontend)OIDC Auth Code + PKCE via oidc-client-tsNot Amplify Auth — SDK assumes commercial endpoints
WAFAWS WAF on ALB and API GatewayStandard managed rule sets
Frontend pipelineS3 + CodePipeline (or GH Actions → S3)Amplify Hosting is not in GovCloud
IaCTerraform or CDKPartition-aware (aws-us-gov), no hardcoded ARN prefixes

Cognito Integration Contract

Since a separate team owns the user pool, treat this as an integration contract. Request from them:

  • userPoolId
  • A dedicated app client for your SPA (PKCE enabled, no client secret, Authorization Code grant)
  • Hosted UI domain prefix
  • Whitelist of your callback and logout URLs
  • Allowed OAuth scopes

Endpoints you will hardcode in the SPA (FIPS variants are mandatory in GovCloud):

Issuer:    https://cognito-idp-fips.us-gov-west-1.amazonaws.com/<userPoolId>
Authorize: https://<prefix>.auth-fips.us-gov-west-1.amazoncognito.com/oauth2/authorize
Token:     https://<prefix>.auth-fips.us-gov-west-1.amazoncognito.com/oauth2/token
JWKS:      https://cognito-idp-fips.us-gov-west-1.amazonaws.com/<userPoolId>/.well-known/jwks.json

Backend gating is declarative — attach the Cognito authorizer to each API Gateway method. Claims surface as $context.authorizer.claims.* for downstream Lambda.


Frontend Delivery Without CloudFront

flowchart LR
    U[User] -->|HTTPS 443| ALB[ALB + WAF<br/>ACM cert]
    ALB -->|/api/*| APIGW[API Gateway REST]
    ALB -->|/*| S3Origin[S3 origin]
    S3Origin --> S3[(SPA bucket)]

    style ALB fill:#e8f0ff
    style APIGW fill:#e8f0ff
    style S3 fill:#fff4e0

Pattern: one ALB, two listener rules.

  • /api/* → forwards to API Gateway (VPC link or public endpoint)
  • /* → serves SPA assets from S3
  • 403/404 from S3 → rewritten to 200 /index.html so client-side routing works
  • WAF managed rule sets on the ALB cover the OWASP basics
  • For S3-as-ALB-origin, use a Lambda@-equivalent shim (small Lambda behind ALB target group) or CloudFront-less approach: serve via an ALB → Lambda → S3 GetObject. Simpler alternative: front S3 with an Nginx/Fargate task on ECS as the static origin. Pick whichever the team is more comfortable operating.

Trade-off: no edge caching. For a gov-internal user base this is fine. If latency becomes a real problem later, add CloudFront-equivalent caching at the ALB/Fargate-Nginx layer with Cache-Control headers and a managed ElastiCache layer for API responses.


GovCloud Gotchas

mindmap
  root((GovCloud<br/>Constraints))
    Not Available
      CloudFront
      Amplify Hosting
      Amplify CLI
      Cognito Hosted UI custom domain
      Pinpoint Cognito integration
    Must Use FIPS
      cognito-idp-fips endpoints
      auth-fips Hosted UI domain
    Partition Differences
      ARN prefix aws-us-gov
      Trust principals differ
      Service principals differ
      Copy-pasted IAM silently breaks
    Identity Pool Quirks
      Role name <= 24 chars in us-gov-east-1
    SDK Behavior
      Amplify SDK assumes commercial
      Must override endpoint resolvers if used

Because the backend is small, the cleanest GovCloud-native design collapses API Gateway into the ALB and puts authentication at the ALB itself using its built-in OIDC action against Cognito.

flowchart LR
    U[User Browser] -->|HTTPS 443| ALB[ALB + WAF<br/>OIDC authenticate action]
    ALB <-->|OIDC redirect & token| Cog[Cognito Hosted UI<br/>auth-fips.us-gov-west-1]
    ALB -->|/api/*<br/>x-amzn-oidc-data header| Backend[Backend target<br/>Lambda or Fargate]
    ALB -->|/*<br/>x-amzn-oidc-data header| Static[Static SPA target<br/>Fargate Nginx]
    Backend --> DB[(DynamoDB / RDS)]

How ALB OIDC works:

  1. Unauthenticated request hits ALB → ALB 302s the browser to Cognito’s /oauth2/authorize.
  2. User logs in at Cognito Hosted UI (FIPS endpoint).
  3. Cognito redirects back to a special ALB callback path (/oauth2/idpresponse); ALB exchanges the code for tokens itself.
  4. ALB sets a session cookie and forwards the request to the target group.
  5. ALB injects two headers downstream:
    • x-amzn-oidc-accesstoken — the raw Cognito access token
    • x-amzn-oidc-data — a signed JWT containing user claims, signed by ALB itself
  6. Backend code verifies x-amzn-oidc-data against the GovCloud ALB public-key endpoint (aws-elb-public-keys-prod-us-gov-west-1) — no need to call Cognito.

What this buys you:

  • One place to configure auth (the ALB listener rule), not per-API-method authorizers.
  • No API Gateway at all — fewer moving parts, fewer IaC resources, no execution-role chain.
  • Same auth for static and API routes — the SPA is also protected, not just the API.
  • Session cookies handled by ALB — no SPA-side token storage to get wrong.

Trade-off: you give up API Gateway features (request/response transformations, usage plans, throttling per key, request validation). For a small service, you don’t need those.


S3 Design — How Static Hosting Actually Works Without CloudFront

This is the part that surprises people. S3 is not a valid ALB target. ALB target groups accept EC2 instances, IPs, Lambda functions, or ECS tasks — not S3 buckets. So “S3 behind ALB” always means S3 + a compute layer in between.

There are three honest options. Pick one.

flowchart TB
    subgraph A["Option A: ALB - Lambda - S3"]
        A1[ALB] --> A2[Lambda target] --> A3[(S3 GetObject)]
    end
    subgraph B["Option B: ALB - Fargate Nginx proxy - S3"]
        B1[ALB] --> B2[Fargate Nginx<br/>proxies to S3] --> B3[(S3 bucket)]
    end
    subgraph C["Option C: ALB - Fargate Nginx, SPA baked in"]
        C1[ALB] --> C2[Fargate Nginx<br/>SPA inside container]
        C2 -.->|no S3| C3[ ]
    end

Option A — ALB → Lambda → S3 GetObject

Lambda is the ALB target. On each request it calls s3:GetObject and returns the bytes.

sequenceDiagram
    participant U as Browser
    participant ALB
    participant L as Lambda target
    participant S3

    U->>ALB: GET /assets/main.js
    ALB->>L: invoke with event
    L->>S3: GetObject(bucket, /assets/main.js)
    S3->>L: bytes + content-type
    L->>ALB: 200 + body + headers<br/>Cache-Control: max-age=31536000
    ALB->>U: 200 response
  • Pros: scales to zero, no container ops, simple IaC.
  • Cons:
    • ALB-to-Lambda response payload cap is 1 MB (base64-encoded). Larger assets need chunking or streaming via a CloudFront-equivalent — which we don’t have. A typical SPA main.js of 600 KB is fine; a 4 MB hero image is not.
    • Cold-start latency on every infrequent path.
    • You pay Lambda per request.
  • Best when: SPA bundle is small (<1 MB per file, gzipped), traffic is low and bursty.

Option B — ALB → Fargate Nginx → S3

Long-running Nginx container fronts S3.

sequenceDiagram
    participant U as Browser
    participant ALB
    participant Nginx as Fargate Nginx
    participant S3

    U->>ALB: GET /assets/main.js
    ALB->>Nginx: forward
    Nginx->>S3: HTTP GET to S3 endpoint<br/>or SigV4-signed
    S3->>Nginx: bytes
    Nginx->>ALB: 200 + body<br/>with local cache + headers
    ALB->>U: 200 response
  • Pros: no payload limits, local in-container caching (Nginx proxy_cache), real HTTP semantics, easy Cache-Control tuning.
  • Cons: always-on cost (small — 0.25 vCPU Fargate is cheap), container image and ECS service to maintain, S3 access needs either the S3 VPC gateway endpoint with bucket policy + SigV4 signing in Nginx, or a presigned-URL pattern.
  • Best when: SPA has any large assets, you want a real cache layer.

Option C — Fargate Nginx with SPA baked into the container, no S3 at all

The simplest design once CloudFront is off the table. The SPA is part of the container image; deploys are just a new image tag.

flowchart LR
    Build[CI build] -->|docker build| Img[Container image<br/>nginx + dist/]
    Img --> ECR[(ECR GovCloud)]
    ECR --> Fargate[Fargate task<br/>Nginx serving /usr/share/nginx/html]
    ALB --> Fargate
  • Pros:
    • No S3 indirection. Without CloudFront, S3 wasn’t earning its keep anyway.
    • Atomic deploys — image tag is the version of truth.
    • Easy rollback — re-deploy the previous tag.
    • Standard Nginx config, no SDK calls, no IAM gymnastics.
  • Cons: image rebuilds on every SPA change; slightly bigger artifacts.
  • Best when: small to medium SPA, infrequent deploys, team is comfortable with containers. This is the design I’d recommend.

Recommendation

For “frontend + small backend in GovCloud with no CDN,” Option C wins. S3 only makes sense if a CDN is caching in front of it. Without a CDN, the storage layer needs an HTTP serving layer anyway — so just have the HTTP serving layer hold the assets directly.

Updated picture under this recommendation:

flowchart LR
    U[User Browser] -->|HTTPS| ALB[ALB + WAF<br/>OIDC auth]
    ALB <--> Cog[Cognito FIPS Hosted UI]
    ALB -->|/api/*| API[Lambda<br/>backend]
    ALB -->|/*| SPA[Fargate Nginx<br/>SPA baked in]
    API --> DB[(DynamoDB)]

One ALB. One Cognito integration point. One Fargate service for static. One Lambda (or Fargate) for the API. Zero S3 in the request path. Zero API Gateway.


ECS Service Layout — One Service or Two?

Since the backend is going to ECS, the question becomes how to organize the frontend (Nginx-SPA) and backend (API) on ECS. There are three real options.

flowchart TB
    subgraph A["Option A: Two services, two task defs"]
        A1[ALB] -->|/*| A2[ECS Service: web<br/>Nginx + SPA]
        A1 -->|/api/*| A3[ECS Service: api<br/>backend]
    end
    subgraph B["Option B: One service, sidecar task<br/>two containers, same task"]
        B1[ALB] --> B2[ECS Service<br/>Task with 2 containers<br/>nginx + api on localhost]
    end
    subgraph C["Option C: One service, one container<br/>API also serves the SPA"]
        C1[ALB] --> C2[ECS Service<br/>API container<br/>serves /static and /api]
    end

Terminology check so the comparisons are clean:

  • Service = the controller that maintains N replicas of one task definition.
  • Task definition = the blueprint; can declare one or more containers that run together.
  • “Two tasks in one service” isn’t a thing — that’s just desired count > 1 (replicas of the same task def).

Option A — Two services, two task definitions

Separate ECS service for the SPA-Nginx, separate service for the API. Each has its own task definition, target group, and scaling policy. ALB listener rules route by path prefix.

  • Pros:
    • Independent scaling. Static Nginx is flat; the API scales with load.
    • Independent deploys. SPA changes don’t redeploy the API and vice versa. Smaller blast radius per release.
    • Independent resource sizing. Nginx at 0.25 vCPU / 0.5 GB; API sized to its workload.
    • Failure isolation. API OOM doesn’t take the SPA down (or vice versa).
    • Clean IAM: each task role gets only the permissions its workload needs.
  • Cons:
    • Two ECS services, two task defs, two target groups to maintain in IaC.
    • Slight cost floor — minimum one task per service, so two tasks always running.

Option B — One service, one task definition with two containers (sidecar)

Nginx and API containers run inside the same task, talking over localhost.

  • Pros:
    • Single deploy unit; lock-step versioning of frontend and API.
    • Lower task count — minimum one Fargate task instead of two.
    • Localhost networking between containers (no ALB hop for internal calls — though we wouldn’t be doing that anyway).
  • Cons:
    • Scale together or not at all. Can’t add API capacity without also adding Nginx replicas, wasting either CPU or money.
    • Deploy together. Any SPA change rebuilds and redeploys the whole task. Any API rollback also rolls back the SPA.
    • Shared failure domain — one container OOM kills the whole task.
    • Task definition gets fatter and noisier; harder to reason about resource limits.
  • Sidecar is the right pattern for infrastructure companions (log forwarders, service-mesh proxies, OTel collectors) — things with the same lifecycle as the app. Frontend + backend have different lifecycles.

Option C — One service, one container that serves both

The API process also serves the SPA static files (e.g., a Node/Express or Go server with express.static or http.FileServer).

  • Pros:
    • Single artifact, single deploy, single scaling unit. Simplest possible setup.
    • No Nginx at all — fewer moving parts.
    • Same auth path for everything (ALB OIDC still applies).
  • Cons:
    • Backend process now also handles static serving — competes for CPU and event loop with API traffic.
    • Couples frontend and backend release cadence even harder than sidecar (same image).
    • Static serving from a general-purpose web framework is less efficient than Nginx (worse caching headers, no zero-copy sendfile, etc.).
    • Awkward in polyglot scenarios — if the backend is Python/Java, the SPA artifact has to be baked into a non-web-server runtime.

Recommendation

Option A — two services. It’s the design that matches how frontend and backend actually behave: different release cadences, different scaling curves, different failure modes. The “extra” overhead is two Terraform/CDK modules and one extra always-on Fargate task (~$10/mo for a 0.25 vCPU Nginx). That’s a rounding error against the operational clarity you get.

Option B (sidecar) is appropriate for true co-lifecycle companions, not for frontend/backend pairs. Option C is tempting for very early prototypes but accrues coupling debt fast.

Recommended layout:

flowchart LR
    ALB[ALB + WAF<br/>OIDC auth] -->|/api/*| TGapi[Target group: api]
    ALB -->|/*| TGweb[Target group: web]
    TGapi --> Sapi[ECS Service: api<br/>task def: api:v<br/>scale 2-10]
    TGweb --> Sweb[ECS Service: web<br/>task def: web:v<br/>scale 1-2]
    Sapi --> DB[(DynamoDB / RDS)]

One ECS cluster, two services, two target groups, two listener rules. Each service deploys, scales, and fails on its own.


Open Questions

  • Backend compute: Lambda or Fargate? Lambda is simpler if the API is event-shaped and bursty. Fargate is simpler if it’s a real HTTP service with a framework you already know. The ALB targets either equally well.
  • Single account vs multi-account: A second GovCloud account for prod isolation is recommended but not required for v1.
  • If you really want S3: keep Option B (Fargate Nginx proxy) — but understand you’re paying for two layers (S3 + Fargate) to get less than Option C gives you.

Sources