Files
Portfolio-Site/aws/stack.ts
Jared Kling b3f30bc715
Some checks failed
Build and Publish Portfolio / build (push) Successful in 24s
Build and Publish Portfolio / publish (push) Failing after 3m9s
First try at pipeline publish
2025-08-07 22:31:49 -05:00

203 lines
8.1 KiB
TypeScript

import * as cdk from 'aws-cdk-lib';
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 route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
import * as certificatemanager from 'aws-cdk-lib/aws-certificatemanager';
import { Construct } from 'constructs';
export interface ForgePortfolioStackProps extends cdk.StackProps {
domainName?: string; // Optional custom domain
hostedZoneId?: string; // Required if using custom domain
}
export class ForgePortfolioStack extends cdk.Stack {
public readonly bucket: s3.Bucket;
public readonly distribution: cloudfront.Distribution;
public readonly deploymentUser: iam.User;
constructor(scope: Construct, id: string, props: ForgePortfolioStackProps) {
super(scope, id, props);
// S3 Bucket for hosting static files
this.bucket = new s3.Bucket(this, 'PortfolioBucket', {
bucketName: `${id.toLowerCase()}-portfolio-${this.account}`,
removalPolicy: cdk.RemovalPolicy.DESTROY, // Be careful with this in production
autoDeleteObjects: true,
publicReadAccess: false, // CloudFront will handle access
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
versioned: false, // Keep costs low
websiteIndexDocument: 'index.html',
websiteErrorDocument: 'error.html',
});
// Origin Access Control for CloudFront to access S3
const originAccessControl = new cloudfront.S3OriginAccessControl(this, 'OAC', {
description: 'OAC for portfolio website',
});
// SSL Certificate (only if custom domain is provided)
let certificate: certificatemanager.Certificate | undefined;
if (props.domainName) {
certificate = new certificatemanager.Certificate(this, 'Certificate', {
domainName: props.domainName,
validation: certificatemanager.CertificateValidation.fromDns(),
});
}
// CloudFront Distribution
this.distribution = new cloudfront.Distribution(this, 'Distribution', {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket, {
originAccessControl,
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
compress: true,
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
},
defaultRootObject: 'index.html',
errorResponses: [
{
httpStatus: 404,
responseHttpStatus: 200,
responsePagePath: '/index.html', // For SPA routing
ttl: cdk.Duration.minutes(5),
},
{
httpStatus: 403,
responseHttpStatus: 200,
responsePagePath: '/index.html', // For SPA routing
ttl: cdk.Duration.minutes(5),
},
],
domainNames: props.domainName ? [props.domainName] : undefined,
certificate: certificate,
minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
priceClass: cloudfront.PriceClass.PRICE_CLASS_100, // Use only NA and Europe for cost optimization
});
// Grant CloudFront access to S3 bucket
this.bucket.addToResourcePolicy(
new iam.PolicyStatement({
actions: ['s3:GetObject'],
resources: [this.bucket.arnForObjects('*')],
principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
conditions: {
StringEquals: {
'AWS:SourceArn': `arn:aws:cloudfront::${this.account}:distribution/${this.distribution.distributionId}`,
},
},
})
);
// Ensure bucket allows CloudFront to list objects (needed for error handling)
this.bucket.addToResourcePolicy(
new iam.PolicyStatement({
actions: ['s3:ListBucket'],
resources: [this.bucket.bucketArn],
principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
conditions: {
StringEquals: {
'AWS:SourceArn': `arn:aws:cloudfront::${this.account}:distribution/${this.distribution.distributionId}`,
},
},
})
);
// Custom domain DNS record (if provided)
if (props.domainName && props.hostedZoneId) {
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: props.hostedZoneId,
zoneName: props.domainName,
});
new route53.ARecord(this, 'ARecord', {
zone: hostedZone,
target: route53.RecordTarget.fromAlias(
new targets.CloudFrontTarget(this.distribution)
),
});
}
// IAM User for GitHub Actions deployment
this.deploymentUser = new iam.User(this, 'DeploymentUser', {
userName: `${id}-github-actions-user`,
});
// Policy for deployment user
const deploymentPolicy = new iam.Policy(this, 'DeploymentPolicy', {
statements: [
// S3 permissions for uploading files
new iam.PolicyStatement({
actions: [
's3:PutObject',
's3:PutObjectAcl',
's3:GetObject',
's3:DeleteObject',
's3:ListBucket',
],
resources: [
this.bucket.bucketArn,
this.bucket.arnForObjects('*'),
],
}),
// CloudFront invalidation permissions
new iam.PolicyStatement({
actions: ['cloudfront:CreateInvalidation'],
resources: [
`arn:aws:cloudfront::${this.account}:distribution/${this.distribution.distributionId}`,
],
}),
],
});
this.deploymentUser.attachInlinePolicy(deploymentPolicy);
// Create Access Key for the deployment user
const accessKey = new iam.AccessKey(this, 'DeploymentUserAccessKey', {
user: this.deploymentUser,
});
// Outputs for GitHub Actions
new cdk.CfnOutput(this, 'BucketName', {
value: this.bucket.bucketName,
description: 'S3 Bucket name for deployment',
});
new cdk.CfnOutput(this, 'DistributionId', {
value: this.distribution.distributionId,
description: 'CloudFront Distribution ID for cache invalidation',
});
new cdk.CfnOutput(this, 'DistributionDomainName', {
value: this.distribution.distributionDomainName,
description: 'CloudFront Distribution domain name',
});
new cdk.CfnOutput(this, 'AccessKeyId', {
value: accessKey.accessKeyId,
description: 'Access Key ID for GitHub Actions (store as secret)',
});
new cdk.CfnOutput(this, 'SecretAccessKey', {
value: accessKey.secretAccessKey.unsafeUnwrap(),
description: 'Secret Access Key for GitHub Actions (store as secret)',
});
if (props.domainName) {
new cdk.CfnOutput(this, 'WebsiteURL', {
value: `https://${props.domainName}`,
description: 'Website URL',
});
} else {
new cdk.CfnOutput(this, 'WebsiteURL', {
value: `https://${this.distribution.distributionDomainName}`,
description: 'Website URL',
});
}
}
}