---
title: "AWS GovCloud Architecture: SPA + Small Backend, Cognito-Gated"
description: "Single-partition GovCloud design for an SPA + ECS backend gated by a Cognito user pool owned by another team."
date: 2026-05-22T00:00:00.000Z
tags: [aws, govcloud, cognito, ecs, alb, architecture]
---

# 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

```mermaid
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

```mermaid
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

```mermaid
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

```mermaid
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.

```mermaid
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.

```mermaid
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.

```mermaid
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.

```mermaid
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.

```mermaid
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:

```mermaid
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.

```mermaid
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:

```mermaid
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)](https://docs.aws.amazon.com/govcloud-us/latest/UserGuide/govcloud-cog.html)
- [Amplify in AWS GovCloud (US)](https://aws.amazon.com/blogs/mobile/using-aws-amplify-in-aws-govcloud-us-regions/)
- [Serverless web app in GovCloud reference architecture](https://aws.amazon.com/blogs/publicsector/how-improve-government-customer-experience-building-modern-serverless-web-application-aws-govcloud-us/)
- [Serverless GraphQL in GovCloud](https://aws.amazon.com/blogs/publicsector/implement-serverless-graphql-architecture-aws-govcloud-us-optimize-api/)
- [API Gateway Cognito User Pool Authorizer](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html)
- [ALB OIDC authentication (incl. GovCloud key endpoints)](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html)
- [ACM in AWS GovCloud (US)](https://docs.aws.amazon.com/govcloud-us/latest/UserGuide/govcloud-acm.html)
- [IaC best practices for AWS GovCloud (US)](https://aws.amazon.com/blogs/infrastructure-and-automation/best-practices-for-creating-iac-for-aws-govcloud-us/)