Skip to content

Deploying IDP in a Private Network

This runbook deploys the GenAI IDP Accelerator in a fully private / air-gapped environment:

  • Web UI served via API Gateway — an S3 proxy on the same private REST API that backs the UI’s data operations (no CloudFront, no ALB, no public internet)
  • REST API (Web UI and data operations) reachable only from inside the VPC via an execute-api Interface VPC Endpoint (no public endpoint)
  • All Lambda → AWS service traffic routed through VPC Interface Endpoints
  • Browser → S3 presigned uploads routed through global S3 (default, via NAT) or optionally through a bring-your-own S3 Interface VPC Endpoint (opt-in via S3PresignedUrlViaVpcEndpoint=true)
  • Internet-facing features (MCP Gateway, Knowledge Base) disabled

For standard public deployments, see Deployment Guide. For the full reference on this hosting mode (including public/regional variants), see API Gateway Hosting Guide.


Browser (VPN/DC client)
│ HTTPS
execute-api Interface VPC Endpoint (PrivateDnsEnabled=true)
API Gateway REST API (private, stage: /api)
├── GET / → S3 proxy → Web UI bucket / index.html
├── GET /{proxy+} → S3 proxy → Web UI bucket / <key>
└── POST /op/{field} → Lambda dispatcher (data operations, Cognito-authed)
browser also calls (via VPN/DC):
├── Cognito user-pool (public — needs egress, see WorkSpaces note)
└── S3 presigned upload → global s3.amazonaws.com (default, via NAT)
OR BYO S3 Interface VPC Endpoint (opt-in)
Lambda functions (in VPC subnets, attached to LambdaSecurityGroup)
└── all AWS API calls → VPC Interface / Gateway Endpoints
(Bedrock, Textract, SQS, States, KMS, Logs, CloudWatch, SSM,
Lambda, Events, Athena, STS, S3, DynamoDB, ...)

The Web UI and the data API are served from one origin and one stage on the private REST API, so there is no CORS between them and VPC access is inherited from the execute-api endpoint. No S3 VPC endpoint is created for UI hosting — API Gateway reaches the Web UI bucket over AWS-internal networking via an IAM role (WebUIProxyRole), not through a customer VPCE. Browser uploads use global S3 by default (via NAT); set S3PresignedUrlViaVpcEndpoint=true to route them through a bring-your-own S3 Interface VPC Endpoint instead (requires corporate network DNS/routing to VPCE hostnames).


API Gateway hosting does not create any S3 VPC endpoint — the UI is served by the private REST API, not by an S3-fronting ALB. The two models below differ only in whether browser presigned uploads route through a bring-your-own S3 Interface VPCE.

ModeWhat you supplyWhat the stack creates
A. Global-S3 uploads (default)VPC id, subnet ids, Lambda SG (or stack-managed), execute-api VPCE id.The private REST API + WebUI S3 proxy. Browser uploads go to global s3.amazonaws.com via NAT. scripts/deploy-vpc-endpoints.py adds the service endpoints.
B. Customer-managed S3 VPCE uploadsEverything in A plus a pre-existing S3 Interface VPCE in the VPC, its vpce-id, its full DNS name (with random suffix), and S3PresignedUrlViaVpcEndpoint=true.Nothing extra — the stack reads the VPCE id and DNS name from parameters and rewrites presigned upload URLs to use it.

Mode B fits central-network-account topologies where one team manages all VPC endpoints and IDP is one of many tenants.


See Deployment Guide → Dependencies for AWS CLI, SAM CLI, Python 3.12+, Node.js 22+. Docker is not required locally — images are built in AWS by CodeBuild.

Note: When DeployInVPC=true, all CodeBuild projects (WebUI build, Docker image builds, SDLC pipeline) run inside the VPC. They require either a NAT Gateway for public registry access or an internal artifact repository (JFrog Artifactory, AWS CodeArtifact, etc.) for air-gapped builds. See Dependency Mirroring for generating the full dependency manifest.

  • At least 2 private subnets in different Availability Zones — for the Lambdas.
  • DNS resolution enabled on the VPC — enableDnsSupport=true and enableDnsHostnames=true.
  • An execute-api Interface VPC Endpoint with PrivateDnsEnabled: true, passed as ApiGatewayVpcEndpointId. This is what makes the regional execute-api.<region>.amazonaws.com hostname resolve to private IPs inside the VPC — it fronts both the Web UI and the data API.
  • (Mode B only) DNS resolution for the BYO S3 Interface VPCEPrivateDnsEnabled is off for S3 VPCEs by AWS design; bucket-vhost addressing routes via the per-endpoint bucket.<vpce-id>-<random>.s3.<region>.vpce.amazonaws.com name. VPC DNS resolution is what makes that name resolvable for in-VPC clients (Lambdas). Browsers only need to resolve this name when S3PresignedUrlViaVpcEndpoint=true; with the default (false), browsers use s3.amazonaws.com via NAT.
  • A Lambda security group allowing outbound HTTPS, passed as LambdaSecurityGroupId (or let the stack create one).
  • No 0.0.0.0/0 → IGW or → NAT route on the Lambda subnet — proves no public-internet egress (verify after deployment).

Don’t have a VPC? A ready-made, self-contained test VPC (NAT egress + one execute-api endpoint) is provided at scripts/sdlc/apigw-hosting-test-vpc.yaml and is used by the CI deployment test:

Terminal window
aws cloudformation deploy \
--stack-name IDP-TestVPC \
--template-file scripts/sdlc/apigw-hosting-test-vpc.yaml \
--capabilities CAPABILITY_IAM \
--region us-east-1
aws cloudformation describe-stacks --stack-name IDP-TestVPC \
--query 'Stacks[0].Outputs[*].{Key:OutputKey,Value:OutputValue}' --output table

Users must reach the private REST API via VPN, Direct Connect, AWS Client VPN, WorkSpaces, or SSM port forwarding. The regional execute-api hostname is only resolvable inside the VPC (via the execute-api endpoint’s private DNS) or via a VPC DNS forwarder over the VPN. No TLS certificate is required — API Gateway terminates HTTPS with an AWS-managed certificate for the regional execute-api domain.


One-click console deployment (CloudFormation)

Section titled “One-click console deployment (CloudFormation)”

Every private-mode knob is exposed as a CloudFormation parameter, so the stack can be deployed entirely via the AWS console “Create stack” form — no CLI required.

Pre-requisites (must complete before clicking Launch)

Section titled “Pre-requisites (must complete before clicking Launch)”
  1. Publish artifacts to your S3 bucket once (Step 1 below). One-click reuses idp-main.yaml from the artifact bucket. The bucket can live in the same or a different account.
  2. VPC + subnets — 2+ subnets in 2+ AZs (enableDnsSupport=true, enableDnsHostnames=true).
  3. execute-api Interface VPC Endpointvpce-id with PrivateDnsEnabled: true (see Prereq §2), passed as ApiGatewayVpcEndpointId.
  4. (Mode B only) the vpce-id and full DNS name (with random suffix) of your existing S3 Interface VPCE.

Construct the 1-click URL once after publishing:

https://<region>.console.aws.amazon.com/cloudformation/home?region=<region>#/stacks/create/review?templateURL=https://s3.<region>.amazonaws.com/<bucket>/idp/idp-main.yaml&stackName=IDP-PRIVATE

idp-cli publish prints the exact URL at the end of its run. Bookmark it for the customer.

ParameterValue
AdminEmailadmin@example.com
WebUIHostingAPIGateway
ApiGatewayVisibilityPRIVATE
DeployInVPCtrue
VpcIdvpc-xxx
PrivateSubnetIdssubnet-a,subnet-b (comma-separated, 2+ AZs)
LambdaSubnetIdssubnet-a,subnet-b (can match PrivateSubnetIds)
ApiGatewayVpcEndpointIdvpce-xxx (execute-api interface endpoint, PrivateDnsEnabled: true)
EnableMCPfalse
DocumentKnowledgeBaseDISABLED
ParameterWhen to set
LambdaSecurityGroupIdBYO Lambda SG. Empty = stack creates one.
WAFAllowedIPv4RangesRestrict the API stage (UI + API) to specific CIDRs via the built-in WAFv2 IP allow-list. Empty = no IP restriction.
S3PresignedUrlViaVpcEndpointSet to true to route browser S3 uploads through a BYO S3 VPCE instead of global S3. Default false (uses NAT).
ArtifactsBucketKmsKeyArnRequired if artifact bucket is KMS-encrypted.
ParameterValue
S3VpcEndpointIdOverridevpce-0123… (your existing S3 Interface VPCE)
S3VpcEndpointDnsNameOverridevpce-0123-abcdef12.s3.us-east-1.vpce.amazonaws.com (full DNS, random suffix included, no leading *.)

Both must be set together. A CloudFormation Rules assertion blocks change-set creation if only one is provided. Run aws ec2 describe-vpc-endpoints --vpc-endpoint-ids <vpce-id> --query 'VpcEndpoints[0].DnsEntries[0].DnsName' and strip the leading *. to get the DNS-name value.

CloudFormation provisions the main stack + nested stacks (API resolvers, Pattern, DocumentKB, MultiDocDiscovery). Expect ~25–35 min.

Then complete the post-stack operation the console can’t do:

  1. Deploy service VPC endpoints (Step 3 below) — required so Lambdas can reach Bedrock, Textract, etc.

Once that post-stack step completes, the UI is reachable from any VPN/DC client at the URL in the ApplicationWebURL stack output (the execute-api /api/ URL).


Terminal window
export PATH="/opt/homebrew/opt/node@22/bin:$PATH" # macOS with brew node@22
node --version # must be v22.x or later
idp-cli publish \
--source-dir . \
--bucket-basename <bucket-basename> \
--prefix idp \
--region <region>
# Example: idp-cli publish --source-dir . --bucket-basename idp-<account-id> --prefix idp --region us-east-1

Creates <bucket-basename>-<region> if needed, builds Lambda layers and templates, uploads. Prints the Template URL to use in Step 2.

publish.py is deprecated — use idp-cli publish.

Enterprise artifact bucket hardening (optional)

Section titled “Enterprise artifact bucket hardening (optional)”

Pre-create a compliant bucket (KMS, tags, bucket policy, access logging, block public access) and pass it via --bucket-basename. When deploying, pass ArtifactsBucketKmsKeyArn=<key-arn> so CodeBuild (DockerBuildRole) and ConfigurationCopyFunction get kms:Decrypt.

Terminal window
aws s3api create-bucket --bucket my-idp-artifacts --region us-east-1
aws s3api put-bucket-encryption --bucket my-idp-artifacts \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"<key-arn>"}}]}'
idp-cli publish --source-dir . --bucket-basename my-idp-artifacts --prefix idp --region <region>

Section titled “Mode A — global-S3 uploads (recommended for first deploys)”

The stack deploys the private REST API + WebUI S3 proxy. Browser presigned uploads go to global s3.amazonaws.com via NAT; no S3 VPC endpoint is involved.

Terminal window
idp-cli deploy \
--stack-name IDP-PRIVATE \
--template-url https://s3.<region>.amazonaws.com/<bucket>/idp/idp-main.yaml \
--admin-email admin@example.com \
--region <region> \
--wait \
--parameters "WebUIHosting=APIGateway,\
ApiGatewayVisibility=PRIVATE,\
DeployInVPC=true,\
VpcId=<vpc-id>,\
PrivateSubnetIds=<subnet-1>,<subnet-2>,\
LambdaSubnetIds=<subnet-1>,<subnet-2>,\
ApiGatewayVpcEndpointId=<execute-api-vpce-id>,\
EnableMCP=false,\
DocumentKnowledgeBase=DISABLED"

Mode B — customer-managed S3 VPCE for uploads (BYO endpoint)

Section titled “Mode B — customer-managed S3 VPCE for uploads (BYO endpoint)”

Set S3PresignedUrlViaVpcEndpoint=true plus both S3VpcEndpointIdOverride and S3VpcEndpointDnsNameOverride. The stack rewrites presigned upload URLs to use your S3 VPCE. The deploy fails fast at change-set creation if only one of the two override params is set (CFN Rules assertion).

Discover your VPCE’s full DNS name (the vpce-<id>-<random> suffix is AWS-assigned and cannot be derived from the id alone):

Terminal window
VPCE_ID=vpce-0123456789abcdef0
DNS_NAME=$(aws ec2 describe-vpc-endpoints \
--vpc-endpoint-ids $VPCE_ID \
--region <region> \
--query 'VpcEndpoints[0].DnsEntries[0].DnsName' \
--output text \
| sed 's|^\*\.||')
echo $DNS_NAME
# vpce-0123456789abcdef0-abcdef12.s3.us-east-1.vpce.amazonaws.com

Add to deploy:

Terminal window
--parameters "...,\
S3VpcEndpointIdOverride=$VPCE_ID,\
S3VpcEndpointDnsNameOverride=$DNS_NAME"
ParameterValuePurpose
WebUIHostingAPIGatewayServe the UI from the REST API (S3 proxy) instead of CloudFront
ApiGatewayVisibilityPRIVATEREST API (UI + data) only inside VPC via execute-api endpoint
DeployInVPCtruePlace Lambdas + CodeBuild in the VPC
VpcIdvpc-idVPC for the deployment
PrivateSubnetIdssubnet idsPrivate subnets (2+ AZs)
LambdaSubnetIdssubnet idsSubnets where Lambdas run (can match PrivateSubnetIds)
ApiGatewayVpcEndpointIdvpce-...execute-api Interface VPCE (PrivateDnsEnabled: true); fronts UI + API
LambdaSecurityGroupId(optional) sg-idBYO Lambda SG; stack creates one if empty
WAFAllowedIPv4Ranges(optional) CIDRsStage-level WAFv2 IP allow-list (UI + API). Empty = no IP restriction
S3PresignedUrlViaVpcEndpoint(optional) true/falseRoute browser S3 uploads via BYO VPCE (true) or global S3 via NAT (false, default)
S3VpcEndpointIdOverride(Mode B) vpce-...BYO S3 VPCE id
S3VpcEndpointDnsNameOverride(Mode B) full DNS w/ suffixBYO S3 VPCE DNS name (required if id override is set)
EnableMCPfalseDisable AgentCore Gateway (public-only)
DocumentKnowledgeBaseDISABLEDDisable KB (cuts extra VPC endpoints)
ArtifactsBucketKmsKeyArn(optional) key arnRequired if artifact bucket is KMS-encrypted

--wait streams stack events and exits non-zero on failure (useful for CI).


The stack itself creates no service VPC endpoints. You supply the execute-api interface endpoint (fronts the private REST API — UI + data) as ApiGatewayVpcEndpointId, and the remaining service endpoints (Bedrock, Textract, S3, SQS, States, KMS, Logs, monitoring, SSM, Lambda, Events, Athena, STS, secretsmanager, plus DynamoDB Gateway) come from scripts/vpc-endpoints.yaml via the deploy script:

Terminal window
python scripts/deploy-vpc-endpoints.py \
--vpc-id <vpc-id> \
--stack-name IDP-PRIVATE \
--region <region>

The script:

  1. Reads LambdaSubnetIds and LambdaVpcSecurityGroupId from the IDP stack outputs
  2. Detects which endpoints already exist in the VPC and skips them (avoids conflicting DNS domain)
  3. Creates a shared endpoint SG that allows 443 from the Lambda SG
  4. Adds egress from the Lambda SG to the VPC endpoints SG — required when the Lambda SG has restricted egress (no default 0.0.0.0/0 rule)
  5. Includes the monitoring (CloudWatch) endpoint required by the DashboardMerger custom resource that fires during stack create/update — without it the stack hangs

Pass --dry-run to preview without changes.

⚠️ Lambda SG egress requirement: If your Lambda SG does NOT have a default 0.0.0.0/0 egress rule (common in hardened environments), you must also add egress to the S3 and DynamoDB Gateway endpoint prefix lists. The check-vpc-endpoints.sh script detects this and prints the required aws ec2 authorize-security-group-egress commands. Without these rules, custom resource Lambdas (InitializeConcurrencyTable, ConfigurationCopy, dataset deployers) will time out because they cannot reach DynamoDB or send cfnresponse via S3.

Mode B note: if you bring your own VPCEs for some/all services, set the corresponding Create*Endpoint=false flags in the vpc-endpoints.yaml parameters. The script auto-detects existing ones.

Cross-account Bedrock note: if you use the Cross-Account Bedrock feature (BedrockHubRoleArn set), the STS interface VPC endpoint is mandatory — Lambdas need it for sts:AssumeRole against the hub-account role. The endpoint is already in scripts/vpc-endpoints.yaml; just verify it deployed.


API Gateway hosting creates no S3 VPC endpoint. The REST API serves the SPA by proxying GET requests to the Web UI bucket over AWS-internal networking, authenticated with an IAM role (WebUIProxyRole) rather than through a customer VPCE. The proxy integration and the private endpoint live in the api-resolvers nested stack (nested/api-resolvers/template.yaml) and the main template.yaml (WebUIProxyRole, and the APIGateway branch of WebUIBucketPolicy).

Authorization for which buckets can be read or written is enforced at three layers:

LayerMechanismWhere
APIStage-level WAFv2 IP allow-list (WAFAllowedIPv4Ranges) + Cognito auth on POST /op/{field}nested/api-resolvers/template.yaml
IAMWebUIProxyRole (read-only, Web UI bucket) + per-Lambda execution-role policies (least-privilege)template.yaml
BucketWeb UI bucket policy restricts reads to the API Gateway proxy roletemplate.yaml::WebUIBucketPolicy (APIGateway branch)

When S3PresignedUrlViaVpcEndpoint=true, browser presigned uploads route through your bring-your-own S3 Interface VPCE. Your central network team owns that endpoint’s policy; IDP just consumes it via S3VpcEndpointIdOverride + S3VpcEndpointDnsNameOverride. Apply your own scoping (e.g. aws:PrincipalAccount, bucket ARNs) on that endpoint policy as needed. Each app bucket is additionally pinned to the VPCE via an aws:sourceVpce condition in template.yaml::*BucketPolicy.


The Lambdas attach to LambdaSecurityGroupId (BYO or stack-created). The service endpoints in scripts/vpc-endpoints.yaml use a shared endpoint SG with ingress 443 from the Lambda SG. The execute-api endpoint you supply must allow ingress 443 from both the Lambdas and the browser’s source network:

Source SG / networkDirectionPortPurpose
LambdaSecurityGroupIdingress → endpoint SG443All app Lambdas → AWS service endpoints
Browser source networkingress → execute-api endpoint SG443Browser → private REST API (UI + data)

VPN clients are source-NAT’d to the IP of the VPN-association subnet (AWS Client VPN, Site-to-Site VPN). Add ingress on the relevant SGs from the NAT subnet’s CIDR, not the VPN client CIDR.

Target SGAdd ingressPortReason
execute-api endpoint SG (e.g. 10.1.3.0/24)443Browser → private REST API (UI + data).
BYO S3 VPCE endpoint SG443Browser → S3 VPCE for presigned upload. Only needed when S3PresignedUrlViaVpcEndpoint=true.

AWS Client VPN example: client tunnel IPs are 172.16.0.0/22, but inbound packets to the endpoint are seen as coming from the VPN association subnet (e.g. 10.1.3.0/24). Authorize 10.1.3.0/24, not 172.16.0.0/22.

Site-to-Site VPN over VGW + corporate VPN: source-NAT happens at the customer firewall. Use the post-NAT IP range advertised into the VPC.

Production with Direct Connect / Transit Gateway: typically no NAT — clients reach VPC with on-prem IPs. Authorize those CIDRs on the execute-api endpoint SG as part of standard onboarding.

Note: When S3PresignedUrlViaVpcEndpoint=false (default), browser uploads go to s3.amazonaws.com via NAT/internet — no S3 endpoint SG ingress rule needed for browsers.


Terminal window
aws cloudformation describe-stacks --stack-name IDP-PRIVATE \
--query 'Stacks[0].Outputs[?OutputKey==`ApplicationWebURL`].OutputValue' \
--output text

Open the URL (the execute-api /api/ URL) via VPN/DC. Confirm:

  • ✅ Login succeeds
  • ✅ Upload a doc — status updates live (QUEUED → OCR → CLASSIFICATION → EXTRACTION → ...)
  • ✅ DevTools → Network for upload POST shows:
    • If S3PresignedUrlViaVpcEndpoint=true: host = <bucket>.bucket.vpce-<id>-<random>.s3.<region>.vpce.amazonaws.com
    • If S3PresignedUrlViaVpcEndpoint=false (default): host = <bucket>.s3.<region>.amazonaws.com (via NAT)
Terminal window
# 1) Check presigned URL mode
LG=$(aws cloudformation describe-stack-resources --stack-name <api-resolvers-nested> \
--query 'StackResources[?LogicalResourceId==`UploadResolverFunction`].PhysicalResourceId' --output text)
aws lambda get-function-configuration --function-name $LG --region <region> \
--query 'Environment.Variables.S3_ENDPOINT_URL' --output text
# If S3PresignedUrlViaVpcEndpoint=true:
# Expect: https://bucket.vpce-<id>-<random>.s3.<region>.vpce.amazonaws.com
# If S3PresignedUrlViaVpcEndpoint=false (default):
# Expect: None (no env var set — presigned URLs use global s3.amazonaws.com)
# 2) Confirm Lambda subnet routing
aws ec2 describe-route-tables \
--filters "Name=association.subnet-id,Values=<lambda-subnet-id>" \
--region <region> \
--query 'RouteTables[0].Routes[].[DestinationCidrBlock,DestinationPrefixListId,GatewayId]' --output text
# For S3PresignedUrlViaVpcEndpoint=false: NAT route (0.0.0.0/0 → nat-xxx) is expected
# For fully air-gapped (=true): no 0.0.0.0/0 route; only "<vpc-cidr> local" + prefix-list rows

Cognito IDP egress (browser-only consideration)

Section titled “Cognito IDP egress (browser-only consideration)”

cognito-idp.<region>.amazonaws.com has no VPC Interface Endpoint. The browser must reach it from outside the VPC, which is normal: end-user browsers on VPN/DC use their own internet via the corporate network. Lambdas inside the VPC do not call Cognito IDP at runtime.

For testing scenarios where the browser runs inside the VPC (WorkSpaces, bastion EC2), provide internet egress via a NAT Gateway in a separate public subnet.

WorkSpaces runs a Windows desktop inside the VPC. Setup notes:

  1. Register an AWS Directory Service (Simple AD) with WorkSpaces in the same VPC.
  2. WorkSpaces directory: EnableInternetAccess=true. Set before launching — toggling it later requires rebuild (~25 min).
  3. NAT Gateway in a separate public subnet (with 0.0.0.0/0 → IGW). It cannot egress traffic from the same subnet it lives in. Private subnet route table: 0.0.0.0/0 → NAT GW.
  4. The execute-api endpoint (PrivateDnsEnabled: true) must be in the WorkSpaces VPC so the /api/ URL resolves to the endpoint’s private IPs.

Cost: WorkSpaces AutoStop bundle ~$7.25/mo + NAT GW ~$0.045/hr active. Tear down after testing.

AWS Client VPN option (testing — replicates customer corporate VPN)

Section titled “AWS Client VPN option (testing — replicates customer corporate VPN)”
Terminal window
# 1) Create PKI (server + client cert/key, CA)
brew install easy-rsa openvpn # macOS
easyrsa init-pki && easyrsa build-ca nopass
easyrsa --san=DNS:server build-server-full server.idp-vpn nopass
easyrsa build-client-full client.idp-vpn nopass
# 2) Import server cert to ACM in the same region
SERVER_CERT_ARN=$(aws acm import-certificate \
--certificate fileb://pki/issued/server.idp-vpn.crt \
--private-key fileb://pki/private/server.idp-vpn.key \
--certificate-chain fileb://pki/ca.crt \
--region <region> --query CertificateArn --output text)
# 3) Create Client VPN endpoint (use 172.16.0.0/22 as client CIDR)
CVPN=$(aws ec2 create-client-vpn-endpoint \
--client-cidr-block 172.16.0.0/22 \
--server-certificate-arn $SERVER_CERT_ARN \
--authentication-options "Type=certificate-authentication,MutualAuthentication={ClientRootCertificateChainArn=$SERVER_CERT_ARN}" \
--connection-log-options Enabled=false \
--dns-servers 10.1.0.2 \
--vpc-id <vpc-id> --security-group-ids <lambda-sg> \
--region <region> --query ClientVpnEndpointId --output text)
# 4) Associate with Lambda subnet, authorize VPC CIDR
aws ec2 associate-client-vpn-target-network --client-vpn-endpoint-id $CVPN \
--subnet-id <lambda-subnet-id> --region <region>
aws ec2 authorize-client-vpn-ingress --client-vpn-endpoint-id $CVPN \
--target-network-cidr 10.1.0.0/16 --authorize-all-groups --region <region>
# 5) Download .ovpn config; append <cert> + <key> blocks for the client cert/key; connect:
sudo openvpn --config idp-vpn.ovpn --daemon --log /tmp/openvpn.log

Authorize the VPN association-subnet CIDR (e.g. 10.1.3.0/24) on the execute-api endpoint SG (and the BYO S3 VPCE endpoint SG when S3PresignedUrlViaVpcEndpoint=true) — see SG matrix above.

See Deployment Guide → SSM tunneling — applies unchanged. Required only when not using VPN/WorkSpaces.


When WebUIHosting=APIGateway and ApiGatewayVisibility=PRIVATE:

  • REST API → deployed as PRIVATE with a resource policy pinning access to the ApiGatewayVpcEndpointId execute-api endpoint; serves both the UI (S3 proxy) and data ops (POST /op/{field})
  • Cognito callback / logout URLs → the execute-api /api/ URL, registered post-deploy via the WebUIClientOAuthUrls custom resource; plus CustomDomainUrl (with and without trailing slash) when set
  • UI build → built with Vite base=/api/ (VITE_UI_BASE_PATH=/api/); VITE_CLOUDFRONT_DOMAIN set to the execute-api /api/ URL by default, or "" when CustomDomainUrl is set (the Web UI then uses window.location.origin so both URLs work side by side — see API Gateway Hosting Guide)
  • Web UI bucket policy → APIGateway branch restricts reads to the WebUIProxyRole (API Gateway S3 proxy)
  • App bucket policiesaws:sourceVpce condition restricts access to the BYO S3 VPCE when S3PresignedUrlViaVpcEndpoint=true
  • CodeBuild projects (WebUI build, Docker image builds, SDLC pipeline) → placed in VPC with LambdaSecurityGroup; requires NAT or internal artifact repository for dependency resolution
  • Lambda functions (~21) → placed in LambdaSubnetIds with LambdaSecurityGroup
  • S3_ENDPOINT_URL env injected on presigner Lambdas (only when S3PresignedUrlViaVpcEndpoint=true or BYO endpoint override is set):
    • UploadResolverFunction (browser presigned POST)
    • DiscoveryUploadResolverFunction
    • TestSetResolverFunction
    • ApiHandlerFunction (jobs API presigner)
    • All API resolver Lambdas via the api-resolvers nested-stack S3EndpointUrl parameter
  • Lambda S3 client uses signature_version=s3v4 + addressing_style=virtual when S3_ENDPOINT_URL is set; falls back to default path style when unset (global S3)

Private REST API DNS resolution in cross-VPC and hybrid networks

Section titled “Private REST API DNS resolution in cross-VPC and hybrid networks”

When ApiGatewayVisibility=PRIVATE, the IDP stack deploys the REST API (Web UI and data operations) as a private API Gateway API that is only reachable via the execute-api VPC Interface Endpoint (com.amazonaws.<region>.execute-api). The frontend (browser) is baked at build time — and the Cognito callback/logout URLs are registered — with the API’s regional hostname:

https://<api-id>.execute-api.<region>.amazonaws.com/api/

For the browser to reach the API (and load the SPA the API serves), DNS must resolve this hostname to the private IP addresses of the execute-api VPC Interface Endpoint. That endpoint also exposes its own VPCE-specific hostname:

<vpce-id>.execute-api.<region>.vpce.amazonaws.com

When does this “just work”?

If the execute-api VPC endpoint is created in the same VPC where the browser’s DNS queries are resolved (i.e., the VPC’s built-in Route 53 Resolver at <VPC-CIDR-base>+2), and PrivateDnsEnabled: true is set on that endpoint, then DNS resolution works automatically. PrivateDnsEnabled: true makes the regional execute-api.<region>.amazonaws.com name resolve to the endpoint’s private IPs inside that VPC. This is the case when:

  • The user is on a VPN whose DNS server is the VPC’s .2 resolver
  • The user is on a WorkSpace or EC2 instance in that VPC
  • Lambdas are in the same VPC as the endpoint

This is the happy path assumed by the rest of this runbook — Prereq §2 requires PrivateDnsEnabled: true on the execute-api endpoint.

When does it NOT work?

PrivateDnsEnabled only injects the DNS override into the VPC that owns the endpoint (AWS creates a managed Route 53 Private Hosted Zone for execute-api.<region>.amazonaws.com, scoped to that VPC). It does NOT propagate across:

  • VPC peering connections
  • Transit Gateway attachments
  • Cross-account VPC associations
  • On-premises networks connected via Direct Connect or Site-to-Site VPN (unless DNS is forwarded to the VPC resolver)

This is the central network account scenario (Mode B): a networking team manages all VPC endpoints in a shared-services VPC, and the IDP workload VPC connects via Transit Gateway or peering. A browser (on corporate VPN) and the Lambdas (in the workload VPC) cannot resolve <api-id>.execute-api.<region>.amazonaws.com because the private DNS override lives only in the endpoint’s VPC.

For S3, the IDP stack solves cross-VPC access by rewriting presigned URLs to use the VPCE-specific DNS name (bucket.vpce-xxx.s3.<region>.vpce.amazonaws.com). This works because:

  1. The IDP stack controls the presigner Lambda and can inject S3_ENDPOINT_URL
  2. S3 VPCE DNS names are resolvable from any VPC (they are public DNS names that happen to route to VPCE IPs when resolved from within the VPC)
  3. No custom headers are needed — S3 uses the Host header that the browser sets naturally from the URL

The private REST API is harder to redirect at the URL layer because:

  1. The SPA origin is fixed at build time (the Vite /api/ base and VITE_CLOUDFRONT_DOMAIN), and the Cognito OAuth callback/logout URLs are registered to the regional <api-id>.execute-api.<region>.amazonaws.com origin. Swapping in a VPCE hostname would break the single-origin model and the Cognito redirect URLs.
  2. You can invoke a private API via the VPCE’s own hostname (<vpce-id>.execute-api.<region>.vpce.amazonaws.com), but only if the request also carries Host: <api-id>.execute-api.<region>.amazonaws.com (or an x-apigw-api-id: <api-id> header) so API Gateway can route to the correct private API. A browser sets Host from the URL and cannot override it for SPA asset loads and navigations, so this path is impractical for a baked SPA.

Note: Unlike AWS AppSync (which required an X-AppSync-Domain/Host header and could not be reached over browser WebSockets), the private REST API needs no custom-header workaround on the happy path — the DNS-layer fix below makes the ordinary regional hostname resolve to the endpoint and every request works unchanged. Document status is delivered by REST polling, and chat streaming uses a separate Lambda Function URL origin that is direct-to-browser and not behind the private API, so streaming is unaffected by this DNS problem.

Therefore, the solution operates at the DNS layer — making the standard regional REST API hostname resolve to the correct private IPs regardless of which VPC the client is in.


Section titled “Solution A: Route 53 Private Hosted Zone (recommended)”

This is the AWS-recommended approach for cross-VPC and cross-account private API Gateway access — associate a Route 53 Private Hosted Zone for the API’s execute-api domain with the VPCs that need to resolve it, pointing at the execute-api Interface VPC Endpoint. It is the direct analogue of the pattern AWS documents for reaching any Interface-VPC-Endpoint-fronted service across VPCs (see Centralized access to VPC private endpoints).

  1. Create an API-ID-scoped Route 53 Private Hosted Zone (PHZ) named for the exact regional hostname (<api-id>.execute-api.<region>.amazonaws.com)
  2. In that zone, create an apex A-record alias to the execute-api VPC Interface Endpoint (com.amazonaws.<region>.execute-api)
  3. Associate the PHZ with every VPC that needs to resolve the API hostname (workload VPC, shared-services VPC, etc.)
  4. For on-premises/hybrid browsers: set up a Route 53 Resolver Inbound Endpoint and configure the corporate DNS to conditionally forward only this FQDN to it

Why scope to the API ID instead of the whole execute-api.<region>.amazonaws.com domain? Route 53 resolves the most specific matching private hosted zone first. A PHZ named for the exact API hostname overrides DNS for only this API; every other private API in the region — yours or another team’s — keeps resolving normally. A PHZ created at the regional execute-api.<region>.amazonaws.com apex would instead intercept all API Gateway DNS in every associated VPC, which can break unrelated applications. The scoped approach below is the least-blast-radius default; see Important considerations for the regional-zone alternative and when it makes sense.

  • Central network account manages VPC endpoints in a shared VPC
  • Workload VPC connects to shared VPC via Transit Gateway or VPC peering
  • On-premises users access the IDP UI via Direct Connect or Site-to-Site VPN
  • Multiple VPCs need to reach the same private REST API
  • The execute-api VPC Interface Endpoint (com.amazonaws.<region>.execute-api) must exist in at least one VPC (the central/shared VPC)
  • PrivateDnsEnabled should be set to false on that endpoint. Enabling it makes AWS create a managed PHZ for the regional execute-api.<region>.amazonaws.com domain inside the endpoint’s VPC, which reintroduces the blanket interception the scoped zone is designed to avoid. The scoped PHZ below replaces its function for this API only. (This differs from the single-VPC happy path in Prereq §2, where PrivateDnsEnabled: true is exactly what you want.)
  • The VPC endpoint must have a security group allowing inbound HTTPS (443) from the source networks
  • The API’s resource policy must permit access via this endpoint. When ApiGatewayVisibility=PRIVATE, IDP pins the policy to the ApiGatewayVpcEndpointId you passed; a second execute-api endpoint in a shared VPC must be the same one, or the policy must be widened to include it.

Single hostname, single zone. Unlike AWS AppSync (which exposed a separate appsync-realtime-api WebSocket hostname and therefore needed a second zone), the REST API is a single regional hostname. One scoped PHZ covers the UI and all data operations. Document status is delivered by REST polling over this same hostname; chat streaming uses a separate Lambda Function URL origin that is not behind the private API and does not need a zone here.

These steps are performed by the networking team or the person managing Route 53 in the account that owns the VPC endpoint.

Step 1: Identify the API ID and region

The IDP stack outputs the application URL (ApplicationWebURL), which is the regional execute-api URL. Extract the API ID:

Terminal window
# From the IDP stack outputs:
API_URL=$(aws cloudformation describe-stacks --stack-name IDP-PRIVATE \
--query 'Stacks[0].Outputs[?OutputKey==`ApplicationWebURL`].OutputValue' \
--output text --region <region>)
echo $API_URL
# Example: https://abcdef1234.execute-api.us-east-1.amazonaws.com/api/
# Strip scheme + path, then split the hostname:
API_HOST=$(echo $API_URL | sed -E 's#https?://##; s#/.*##')
API_ID=$(echo $API_HOST | cut -d. -f1)
REGION=$(echo $API_HOST | cut -d. -f3)
echo "API_ID=$API_ID REGION=$REGION"

Step 2: Identify the execute-api VPC Endpoint

Terminal window
EXECAPI_VPCE_ID=$(aws ec2 describe-vpc-endpoints \
--filters "Name=service-name,Values=com.amazonaws.${REGION}.execute-api" \
"Name=vpc-id,Values=<central-vpc-id>" \
--query 'VpcEndpoints[0].VpcEndpointId' \
--output text --region $REGION)
echo $EXECAPI_VPCE_ID

Step 3: Create the API-ID-scoped Private Hosted Zone

Create a PHZ named for the exact regional hostname. Because the zone name is the full API FQDN, Route 53 overrides DNS for only this API — other private APIs in the region are unaffected.

Terminal window
PHZ_ID=$(aws route53 create-hosted-zone \
--name "${API_ID}.execute-api.${REGION}.amazonaws.com" \
--vpc VPCRegion=${REGION},VPCId=<central-vpc-id> \
--caller-reference "idp-execapi-$(date +%s)" \
--hosted-zone-config Comment="Private DNS for IDP REST API ${API_ID}",PrivateZone=true \
--query 'HostedZone.Id' --output text | sed 's|/hostedzone/||')
echo "PHZ_ID=$PHZ_ID"

Step 4: Create an apex alias record in the scoped zone

The zone gets a single record at its apex (the record name equals the zone name) aliased to the execute-api VPC endpoint.

Terminal window
# Get the VPC endpoint's regional DNS name + its hosted zone ID (the alias target).
# Both values come from the same DnsEntries[0] element so they stay consistent.
VPCE_HZ_ID=$(aws ec2 describe-vpc-endpoints \
--vpc-endpoint-ids $EXECAPI_VPCE_ID \
--query 'VpcEndpoints[0].DnsEntries[0].HostedZoneId' \
--output text --region $REGION)
VPCE_DNS=$(aws ec2 describe-vpc-endpoints \
--vpc-endpoint-ids $EXECAPI_VPCE_ID \
--query 'VpcEndpoints[0].DnsEntries[0].DnsName' \
--output text --region $REGION | sed 's|^\*\.||')
# VPCE_DNS looks like: <vpce-id>.execute-api.<region>.vpce.amazonaws.com
# Apex alias in the scoped zone → execute-api VPC endpoint
# EvaluateTargetHealth is false: a VPC interface endpoint is highly available by
# design and exposes no Route 53-evaluable health signal, so true adds no benefit.
aws route53 change-resource-record-sets --hosted-zone-id $PHZ_ID --change-batch '{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "'${API_ID}'.execute-api.'${REGION}'.amazonaws.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "'${VPCE_HZ_ID}'",
"DNSName": "'${VPCE_DNS}'",
"EvaluateTargetHealth": false
}
}
}
]
}'

Step 5: Associate the PHZ with the workload VPC

The zone must be associated with every VPC where DNS queries originate.

If the workload VPC is in the same account:

Terminal window
aws route53 associate-vpc-with-hosted-zone \
--hosted-zone-id $PHZ_ID \
--vpc VPCRegion=${REGION},VPCId=<workload-vpc-id>

If the workload VPC is in a different account (cross-account), run the authorize → associate → cleanup sequence:

Terminal window
# In the account that owns the PHZ (central/networking account):
aws route53 create-vpc-association-authorization \
--hosted-zone-id $PHZ_ID \
--vpc VPCRegion=${REGION},VPCId=<workload-vpc-id-in-other-account>
# In the workload account (use that account's credentials/profile):
aws route53 associate-vpc-with-hosted-zone \
--hosted-zone-id $PHZ_ID \
--vpc VPCRegion=${REGION},VPCId=<workload-vpc-id-in-other-account>
# Back in the PHZ-owner account (cleanup — recommended):
aws route53 delete-vpc-association-authorization \
--hosted-zone-id $PHZ_ID \
--vpc VPCRegion=${REGION},VPCId=<workload-vpc-id-in-other-account>

Step 6 (hybrid/on-prem only): Route 53 Resolver Inbound Endpoint

If users access the IDP UI from on-premises (via Direct Connect or VPN) and their DNS does not use the VPC’s .2 resolver, you need a Route 53 Resolver Inbound Endpoint:

Terminal window
# Create resolver inbound endpoint in the VPC associated with the scoped PHZ
RESOLVER_EP=$(aws route53resolver create-resolver-endpoint \
--creator-request-id "idp-execapi-inbound-$(date +%s)" \
--name "idp-execapi-inbound" \
--security-group-ids <sg-allowing-dns-from-onprem> \
--direction INBOUND \
--ip-addresses SubnetId=<subnet-1> SubnetId=<subnet-2> \
--region $REGION \
--query 'ResolverEndpoint.Id' --output text)
# Get the resolver endpoint IPs
aws route53resolver list-resolver-endpoint-ip-addresses \
--resolver-endpoint-id $RESOLVER_EP --region $REGION \
--query 'IpAddresses[].Ip' --output text

Then configure your on-premises DNS server (Active Directory, BIND, Unbound, etc.) to conditionally forward the API-specific FQDN to the resolver endpoint IPs:

  • <api-id>.execute-api.<region>.amazonaws.com

Scoping the forwarder to this exact name (rather than the regional execute-api.<region>.amazonaws.com parent) keeps on-premises resolution of every other API Gateway API on its normal public path. Windows DNS, BIND, and Unbound all support conditional forwarding keyed to a full FQDN.

Note: If your DNS appliance can only forward at a broader suffix, you may forward execute-api.<region>.amazonaws.com instead. The blast radius stays contained on the AWS side — the scoped PHZ only answers for this API’s hostname, so any other execute-api name forwarded in still resolves via public DNS through the VPC resolver.

Note: If you already have a Route 53 Resolver Inbound Endpoint for other AWS services (e.g., S3, STS), you can reuse it. Just add the API FQDN to your on-premises conditional forwarder configuration.

From a machine using the VPC DNS (or the forwarded DNS path):

Terminal window
# Should resolve to private IPs (10.x.x.x or 172.x.x.x) of the VPC endpoint ENIs
nslookup ${API_ID}.execute-api.${REGION}.amazonaws.com
# Scoping check — a DIFFERENT private API must still resolve to PUBLIC IPs,
# proving the scoped zone did not capture the whole regional domain:
nslookup some-other-api-id.execute-api.${REGION}.amazonaws.com
# Expect: public API Gateway IPs (not the VPC endpoint ENIs)

From the browser (DevTools → Network tab):

  • Requests to <api-id>.execute-api.<region>.amazonaws.com/api/ should succeed (SPA assets load, POST /op/{field} data operations return)
  • The response should carry the execute-api endpoint id in the x-amzn-vpce-id header
  • Document status updates should appear as the UI polls, without manual page reload
ConsiderationDetail
Scoped zone does not affect other APIsBecause the PHZ is named for the exact API FQDN, Route 53’s most-specific-match means the zone overrides DNS for only this API. Other API Gateway APIs in the region (public or private, yours or another team’s) keep resolving through normal public DNS. This is the key advantage over a regional-domain PHZ.
One zone per private APIEach ApiGatewayVisibility=PRIVATE API needs its own scoped zone (<api-id>.execute-api.<region>.amazonaws.com). Deploying multiple private IDP stacks in the same region simply means another scoped zone per stack — there is no shared zone to maintain and no record-enumeration burden.
Regional-zone alternativeIf you genuinely want a single PHZ to cover every private API in a VPC, you can instead create one PHZ at the regional apex execute-api.<region>.amazonaws.com and add an alias record per API ID inside it. This is simpler to reason about for a fleet of private APIs, but it intercepts all API Gateway DNS in associated VPCs — any private API you do not add a record for will fail to resolve, and any public API in those VPCs breaks. Use it only when every execute-api consumer in those VPCs is private and accounted for.
Endpoint in multiple AZsDeploy the execute-api VPC endpoint in at least 2 AZs for high availability. The alias record automatically load-balances across all endpoint ENIs.
PrivateDnsEnabled conflictIf PrivateDnsEnabled=true is set on the execute-api VPC endpoint, AWS creates a managed PHZ for the regional execute-api.<region>.amazonaws.com domain in the endpoint’s VPC. For VPCs associated with both that managed zone and your scoped zone, the scoped zone wins (most-specific match) — but to avoid confusion and unintended blanket interception, set PrivateDnsEnabled=false when using the PHZ approach for cross-VPC access.
No IDP stack changes requiredThis is purely a DNS/networking configuration. The IDP stack and frontend code work unchanged — the browser calls the same regional hostname it was built with.

ScenarioRecommended approach
Single VPC — endpoint in same VPC as browser/LambdasNo action needed. PrivateDnsEnabled=true on the execute-api endpoint handles everything.
Cross-VPC (Transit Gateway / peering) — same accountScoped PHZ associated with every VPC that resolves the API
Cross-account — central networking account owns endpointsScoped PHZ with cross-account VPC association
Hybrid (on-premises browsers via DC/VPN)Scoped PHZ + Route 53 Resolver Inbound Endpoint + corporate DNS conditional forwarder (scoped to the API FQDN)

IssueResolution
ModuleNotFoundError: No module named 'boto3'Use venv Python. make setup && source .venv/bin/activate then retry.
npm error engine Unsupported engineNode.js 22.12+ required. brew install node@22 && export PATH="/opt/homebrew/opt/node@22/bin:$PATH"
Stack fails with conflicting DNS domainA VPC endpoint already exists for that service. Re-run deploy-vpc-endpoints.py — it auto-detects and skips existing ones.
Browser upload fails: ERR_NAME_NOT_RESOLVED on bucket.vpce-...vpce.amazonaws.com(a) Browser is not on the VPN — VPCE host is VPC-DNS-only. (b) Mode B: S3VpcEndpointDnsNameOverride was set without the AWS-assigned random suffix. Re-deploy with the full DNS from aws ec2 describe-vpc-endpoints ... DnsEntries[0].DnsName minus the *. prefix.
Browser upload fails: Failed to fetch / connect timeout(a) S3 VPCE endpoint SG missing ingress for VPN NAT-subnet CIDR (see SG matrix). (b) VPN association subnet differs from S3 VPCE subnets — return path is fine if SG has the NAT CIDR.
Backend processing fails with ConnectTimeoutError on an AWS service endpointShared endpoint SG (scripts/vpc-endpoints.yaml) does not allow ingress from this stack’s Lambda SG. Re-run deploy-vpc-endpoints.py, or add ingress 443 from the Lambda SG manually. Common when reusing a shared endpoint SG across stacks.
ConfigurationCopyFunction times out (5 min) during stack createShared service-endpoint SG (scripts/vpc-endpoints.yaml) missing ingress from the Lambda SG, so the function can’t reach S3/DynamoDB. Confirm LambdaSecurityGroupId is passed and re-run deploy-vpc-endpoints.py. Manual unblock: aws ec2 authorize-security-group-ingress --group-id <endpoint-sg> --protocol tcp --port 443 --source-group <lambda-sg>.
DashboardMerger custom resource times outMissing monitoring (CloudWatch) Interface VPCE. Re-run deploy-vpc-endpoints.py — script now includes it.
OCR Lambda times out / Textract calls hangMissing textract VPC endpoint. Re-run deploy-vpc-endpoints.py.
BDA Lambda fails STS AssumeRoleMissing sts VPC endpoint. Re-run deploy-vpc-endpoints.py.
403 Forbidden loading the UIWAF is enabled and your client IP is not in WAFAllowedIPv4Ranges, or (PRIVATE mode) the request did not arrive via the ApiGatewayVpcEndpointId execute-api endpoint the API resource policy pins to.
UI unreachable in PRIVATE modeThe execute-api endpoint’s private DNS is off, or its SG blocks your client. Confirm PrivateDnsEnabled: true and 443 ingress from your source network.
UI assets 404The UI was not built with VITE_UI_BASE_PATH=/api/. CodeBuild sets this automatically when WebUIHosting=APIGateway; asset URLs in index.html must start with /api/.
Login hangs on cognito-idp.amazonaws.com (browser inside VPC)No VPCE for Cognito IDP. Browser needs internet egress: NAT GW in a public subnet + private route 0.0.0.0/0 → NAT GW. End-user browsers on VPN don’t need this.
CodeBuild fails: timeout on pip install / npm ciCodeBuild is in the VPC but has no route to package registries. Add a NAT Gateway, or configure an internal artifact repository and set registry environment variables. See Dependency Mirroring.
CodeBuild fails: AccessDenied: kms:DecryptArtifact bucket is KMS-encrypted but ArtifactsBucketKmsKeyArn was not passed. Redeploy with the key ARN.
UpdateDefaultConfig custom resource fails: NoSuchKeySame as above — ConfigurationCopyFunction silently skipped due to missing kms:Decrypt. Pass ArtifactsBucketKmsKeyArn.
Mode B: change-set fails with S3VpcEndpointDnsNameOverride is required when S3VpcEndpointIdOverride is setWorking as intended. Both override params must be supplied together. Set both, or clear both to use Mode A.
Browser UI/API fails: ERR_NAME_NOT_RESOLVED on <api-id>.execute-api.<region>.amazonaws.comBrowser cannot resolve the regional REST API hostname. In cross-VPC/hybrid deployments, the execute-api VPC endpoint’s PrivateDnsEnabled only works within the VPC that owns the endpoint. See “Private REST API DNS resolution” section above — create the API-ID-scoped Route 53 PHZ (<api-id>.execute-api.<region>.amazonaws.com), add an apex alias to the execute-api endpoint, and associate it with the VPC where DNS queries originate.
UI loads but data operations (POST /op/{field}) fail / time out(a) The execute-api endpoint SG is missing ingress 443 from the browser’s source network (see SG matrix). (b) The API resource policy pins access to a different ApiGatewayVpcEndpointId than the endpoint the request arrived through. Confirm the request carries the pinned endpoint id in x-amzn-vpce-id.
nslookup resolves the API to public IPs (not VPC endpoint IPs)The scoped PHZ is not associated with the VPC where the DNS query runs, or PrivateDnsEnabled interference. Verify the association: aws route53 get-hosted-zone --id <phz-id> → check the VPCs list.

  • Stack status CREATE_COMPLETE
  • ApplicationWebURL output is the execute-api /api/ URL and loads the UI from inside the VPC
  • (Mode B only) S3_ENDPOINT_URL env on UploadResolverFunction is https://bucket.vpce-<id>-<random>.s3.<region>.vpce.amazonaws.com
  • Lambda subnet route table has no 0.0.0.0/0 route (fully air-gapped) — or the expected NAT route for Mode A uploads
  • Browser DevTools → request to the /api/ origin succeeds (execute-api endpoint id in x-amzn-vpce-id)
  • Login succeeds and live document status updates appear
  • Document upload + processing pipeline completes end-to-end

  • API Gateway hosting guide — API Gateway Hosting (private REST API, WebUI S3 proxy, execute-api endpoint)
  • API resolvers stack template — nested/api-resolvers/template.yaml (private REST API, WebUI S3 proxy integration, execute-api endpoint wiring)
  • VPC endpoints template — scripts/vpc-endpoints.yaml (17 services + 2 gateways)
  • Test VPC template — scripts/sdlc/apigw-hosting-test-vpc.yaml (NAT egress + execute-api endpoint)
  • Lambda S3 client pattern — nested/api-resolvers/src/lambda/upload_resolver/index.py, src/lambda/api_handler/index.py
  • AWS docs: Create a private REST API in API Gateway (private API, execute-api VPC endpoint, resource policy)
  • AWS docs: Centralized access to VPC private endpoints (Route 53 PHZ pattern for cross-VPC/cross-account/hybrid access to Interface VPC Endpoints)