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', }); } } }