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
| Concern | Choice | Notes |
|---|---|---|
| Static hosting | S3 us-gov-west-1 | Private bucket, ALB origin via VPC endpoint or signed access |
| TLS termination | ALB + ACM | ACM is fully supported in GovCloud |
| CDN | None | CloudFront not in GovCloud; acceptable for gov-internal user base |
| Backend compute | Lambda | Canonical GovCloud serverless |
| API layer | API Gateway REST | REST chosen for native Cognito User Pool Authorizer |
| Auth gate (API) | Cognito User Pool Authorizer | Zero validation code to write |
| Auth (frontend) | OIDC Auth Code + PKCE via oidc-client-ts | Not Amplify Auth — SDK assumes commercial endpoints |
| WAF | AWS WAF on ALB and API Gateway | Standard managed rule sets |
| Frontend pipeline | S3 + CodePipeline (or GH Actions → S3) | Amplify Hosting is not in GovCloud |
| IaC | Terraform or CDK | Partition-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.htmlso 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-Controlheaders 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
Recommended Simplification: ALB-Only Design
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:
- Unauthenticated request hits ALB → ALB 302s the browser to Cognito’s
/oauth2/authorize. - User logs in at Cognito Hosted UI (FIPS endpoint).
- Cognito redirects back to a special ALB callback path (
/oauth2/idpresponse); ALB exchanges the code for tokens itself. - ALB sets a session cookie and forwards the request to the target group.
- ALB injects two headers downstream:
x-amzn-oidc-accesstoken— the raw Cognito access tokenx-amzn-oidc-data— a signed JWT containing user claims, signed by ALB itself
- Backend code verifies
x-amzn-oidc-dataagainst 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.jsof 600 KB is fine; a 4 MB hero image is not. - Cold-start latency on every infrequent path.
- You pay Lambda per request.
- 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
- 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, easyCache-Controltuning. - 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
- Amazon Cognito in AWS GovCloud (US)
- Amplify in AWS GovCloud (US)
- Serverless web app in GovCloud reference architecture
- Serverless GraphQL in GovCloud
- API Gateway Cognito User Pool Authorizer
- ALB OIDC authentication (incl. GovCloud key endpoints)
- ACM in AWS GovCloud (US)
- IaC best practices for AWS GovCloud (US)