feat(infra): serve dev on dev.seahaven.com with HTTPS API proxy

Move the dev frontend to its custom domain now that the backend, DNS zones,
and ACM cert all live in the dev account (396287094661):

- CloudFront alias dev.seahaven.com with the *.seahaven.com ACM cert
- /api/* proxied over HTTPS to api.dev.seahaven.com (no CORS, no mixed content)
- Route 53 apex A/AAAA alias -> CloudFront in the delegated dev.seahaven.com zone

Env-specific values (domain, cert, zone, API host, protocol) are parameterized
and set in cdk.json context so the CI `cdk deploy` produces this with no flags;
staging/prod override the same keys per environment.
This commit is contained in:
Alexandre Brandizzi 2026-07-06 14:40:51 -03:00
parent 8bd89a1f9d
commit d1cca884eb
4 changed files with 139 additions and 23 deletions

View file

@ -3,9 +3,12 @@
AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo,
deployed through the org's **reusable** GitHub Actions workflow.
- **Hosting:** private S3 bucket (origin) + CloudFront (CDN, HTTPS, SPA routing)
- **API:** CloudFront proxies `/api/*` to the HTTP-only backend, so the HTTPS
SPA calls it same-origin (no mixed-content blocking). `VITE_API_URL=/api`.
- **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom
domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias).
- **API:** CloudFront proxies `/api/*` over HTTPS to `api.dev.seahaven.com`, so
the SPA calls it same-origin (`VITE_API_URL=/api`) — no CORS, no mixed content.
- Env-specific values (domain, cert, zone, API host) live in `cdk.json` context
so the CI `cdk deploy` picks them up with no flags.
- **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys)
- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's
`Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy`
@ -70,20 +73,21 @@ npm ci
npx cdk bootstrap aws://<ACCOUNT_ID>/us-east-1
```
### 4. Confirm the backend API origin
### 4. Domain, cert, and API origin (already wired for dev)
The app calls its API same-origin at `/api` (`VITE_API_URL=/api` in
`.env.production`), and CloudFront proxies `/api/*` to the backend over HTTP.
The backend host defaults to `console.seahavenind.com`; override it if the dev
API lives elsewhere:
For dev these are set in `cdk.json` context (account `396287094661`):
```bash
# default is fine for dev; otherwise:
npx cdk deploy -c apiOriginDomain=<dev-api-host>
```
| Context key | Value |
| --------------------------------------- | ------------------------------------------------------------ |
| `domainNames` | `dev.seahaven.com` |
| `certificateArn` | `…:certificate/2b78e74f-…` (ACM `*.seahaven.com`, us-east-1) |
| `hostedZoneId` / `hostedZoneName` | `Z07671212N75U4YLPWZR8` / `dev.seahaven.com` |
| `apiOriginDomain` / `apiOriginProtocol` | `api.dev.seahaven.com` / `https` |
No mixed-content risk: the browser talks HTTPS to CloudFront, and CloudFront
talks HTTP to the origin.
The SPA calls `/api` same-origin; CloudFront proxies `/api/*` over **HTTPS** to
`api.dev.seahaven.com`. The stack creates the apex A/AAAA alias in the hosted
zone (which is in this account, delegated from the parent `seahaven.com` zone).
For staging/prod, override these context keys per environment.
### 5. First deploy (locally, with admin creds)

View file

@ -8,13 +8,36 @@ const app = new App();
const envName = app.node.tryGetContext("envName") ?? "dev";
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
// API origin the CloudFront /api behavior proxies to.
// Option A (interim, default): the HTTP-only console.seahavenind.com backend.
// Option B: -c apiOriginDomain=<seahaven.com API host> -c apiOriginProtocol=https
const apiOriginDomain = app.node.tryGetContext("apiOriginDomain") ?? "console.seahavenind.com";
const apiOriginProtocol =
app.node.tryGetContext("apiOriginProtocol") === "https" ? "https" : "http";
// Custom domain (Option B). Comma-separated, e.g. -c domainNames=console.seahaven.com
// The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to.
const domainNames = (app.node.tryGetContext("domainNames") ?? "")
.split(",")
.map((d: string) => d.trim())
.filter((d: string) => d.length > 0);
const certificateArn = app.node.tryGetContext("certificateArn") ?? "";
// Route 53 hosted zone (this account) for the custom-domain alias record.
const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
envName,
githubRepo,
deployBranch,
apiOriginDomain,
apiOriginProtocol,
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? "us-east-1",

View file

@ -8,6 +8,14 @@
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true
"@aws-cdk/aws-cloudfront:useDefaultSecurityPolicyTLSv1.2_2021": true,
"//": "dev environment (account 396287094661). CI runs `cdk deploy` with no -c flags, so these live here.",
"domainNames": "dev.seahaven.com",
"certificateArn": "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00",
"apiOriginDomain": "api.dev.seahaven.com",
"apiOriginProtocol": "https",
"hostedZoneId": "Z07671212N75U4YLPWZR8",
"hostedZoneName": "dev.seahaven.com"
}
}

View file

@ -4,6 +4,9 @@ import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as targets from "aws-cdk-lib/aws-route53-targets";
export interface FrontendStackProps extends StackProps {
/** Environment label, e.g. "dev". Used in names/tags. */
@ -13,11 +16,36 @@ export interface FrontendStackProps extends StackProps {
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
readonly deployBranch: string;
/**
* Hostname of the backend API. CloudFront proxies `/api/*` to it over HTTP
* so the HTTPS SPA can call an HTTP-only backend without mixed-content
* blocking (browser <-> CloudFront is HTTPS; CloudFront <-> origin is HTTP).
* Hostname of the backend API. CloudFront proxies `/api/*` to it (see
* `apiOriginProtocol`) so the SPA can call it same-origin at `/api`.
*/
readonly apiOriginDomain: string;
/**
* Protocol CloudFront uses to reach the API origin.
* - "http" (Option A, interim): HTTP-only backend (console.seahavenind.com)
* - "https" (Option B): the seahaven.com HTTPS backend
*/
readonly apiOriginProtocol: "http" | "https";
/**
* Custom domain(s) for the distribution, e.g. ["console.seahaven.com"].
* Empty = serve on the default *.cloudfront.net domain (Option A).
*/
readonly domainNames: string[];
/**
* ARN of an ACM certificate (us-east-1, SAME account as this stack) covering
* `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot
* use a certificate from another account, so for Option B the cert must live
* in whichever account this stack deploys to.
*/
readonly certificateArn: string;
/**
* Route 53 hosted zone (in THIS account) to create the custom-domain alias
* record in. Empty = don't manage DNS (add the record manually). When set,
* hostedZoneName must also be provided.
*/
readonly hostedZoneId: string;
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
readonly hostedZoneName: string;
}
/**
@ -36,7 +64,25 @@ export class FrontendStack extends Stack {
constructor(scope: Construct, id: string, props: FrontendStackProps) {
super(scope, id, props);
const { envName, githubRepo, deployBranch, apiOriginDomain } = props;
const {
envName,
githubRepo,
deployBranch,
apiOriginDomain,
apiOriginProtocol,
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
} = props;
const useHttpsApiOrigin = apiOriginProtocol === "https";
const hasCustomDomain = domainNames.length > 0;
if (hasCustomDomain && !certificateArn) {
throw new Error(
"certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).",
);
}
// --- Origin bucket: private, encrypted, no public access ----------------
const bucket = new s3.Bucket(this, "SiteBucket", {
@ -79,6 +125,15 @@ export class FrontendStack extends Stack {
defaultRootObject: "index.html",
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
// Option B: serve on the custom domain(s) with the ACM cert. When unset,
// CloudFront uses its default *.cloudfront.net domain + certificate.
domainNames: hasCustomDomain ? domainNames : undefined,
certificate: hasCustomDomain
? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn)
: undefined,
minimumProtocolVersion: hasCustomDomain
? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021
: undefined,
defaultBehavior: {
// withOriginAccessControl wires up OAC + the bucket policy automatically.
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
@ -94,12 +149,16 @@ export class FrontendStack extends Stack {
],
},
additionalBehaviors: {
// Proxy API calls to the HTTP-only backend. The SPA calls same-origin
// `/api/...` over HTTPS; CloudFront forwards to the origin over HTTP.
// Proxy API calls to the backend. The SPA calls same-origin `/api/...`
// over HTTPS; CloudFront forwards to the origin over HTTP (Option A) or
// HTTPS (Option B). Either way there's no mixed content or CORS.
"/api/*": {
origin: new origins.HttpOrigin(apiOriginDomain, {
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
protocolPolicy: useHttpsApiOrigin
? cloudfront.OriginProtocolPolicy.HTTPS_ONLY
: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
httpPort: 80,
httpsPort: 443,
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
@ -172,12 +231,34 @@ export class FrontendStack extends Stack {
}),
);
// --- DNS: point the custom domain at CloudFront ------------------------
// Only when a hosted zone is supplied (it must be in THIS account). Creates
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
if (hostedZoneId && hasCustomDomain) {
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
hostedZoneId,
zoneName: hostedZoneName,
});
const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution));
// apex record when the domain equals the zone name.
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
new route53.ARecord(this, "AliasA", { zone, recordName, target });
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
}
// --- Outputs -----------------------------------------------------------
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
new CfnOutput(this, "SiteUrl", {
value: `https://${distribution.distributionDomainName}`,
value: hasCustomDomain
? `https://${domainNames[0]}`
: `https://${distribution.distributionDomainName}`,
description: "Public URL of the deployed SPA",
});
new CfnOutput(this, "DistributionDomainName", {
value: distribution.distributionDomainName,
description: "CloudFront domain — point the custom-domain DNS record here",
});
new CfnOutput(this, "BucketName", {
value: bucket.bucketName,
});