Automating Workshop Infrastructure Deployments with Pulumi, Go, and Serverless Services on AWS
Posted March 31, 2025 by Trevor Roberts Jr ‐ 5 min read
Have you ever wanted to teach a technology topic online with accompanying hands-on content for your attendees? Read on to see how I hosted my content online using AWS serverless services...
Last time on trevorrobertsjr.io...
In my previous blog post, I discussed how GitHub Actions keeps my Justice Through Code (JTC) workshop content up-to-date. In this blog post, I'll cover how I used Pulumi code written in Go to deploy the serverless components that host the workshop, namely:
- Amazon S3
- Amazon CloudFront
- Amazon Route 53
A Brief Word About Justice Through Code
JTC is an inspiring program whose goal is to educate and nurture talent with conviction histories to create a more just and diverse workforce. I built this workshop for the JTC session that I taught. Due to my family being justice-impacted, I jumped at the opportunity to serve as a guest lecturer on the topics of cloud and automation with this cohort.
Solution Overview
As a refresher, here is the workshop architecture:

For the purposes of this lab, we will focus on the right side of the diagram. Here is a high-level description of the user access flow:
- The user accesses the CloudFront distribution using a DNS name provided by Route 53.
- If CloudFront already cached my workshop content, it serves it to the user via an AWS point of presence that is geographically closest to the user.
- If there is a cache miss in CloudFront, CloudFront retrieves the requested content from the S3 origin.
Source Code
First, I set a few values with pulumi's configuration system so that I do not inadvertently store sensitive information in GitHub.
For example:
pulumi config set bucketName my-bucket-name
In addition to my bucket name, I also set pulumi configuration values for my Route 53 hosted zone and my AWS Certificate Manager (ACM) certificate Amazon Resource Name (ARN).
Next comes the pulumi go code to create the AWS reousrces. The source code is available on GitHub, and I will discuss interesting sections of code in the remainder of this article:
// Create S3 bucket for website hosting
s3Bucket, err := s3.NewBucket(ctx, "websiteBucket", &s3.BucketArgs{
Bucket: pulumi.String(bucketName),
Website: &s3.BucketWebsiteArgs{
IndexDocument: pulumi.String("index.html"),
ErrorDocument: pulumi.String("error.html"),
},
})
if err != nil {
return err
}
// Enable public access for S3 website endpoint
_, err = s3.NewBucketPublicAccessBlock(ctx, "publicAccessBlock", &s3.BucketPublicAccessBlockArgs{
Bucket: s3Bucket.ID(),
BlockPublicAcls: pulumi.Bool(false),
BlockPublicPolicy: pulumi.Bool(false),
IgnorePublicAcls: pulumi.Bool(false),
RestrictPublicBuckets: pulumi.Bool(false),
})
if err != nil {
return err
}
// S3 Bucket Policy to allow public access for the S3 website
_, err = s3.NewBucketPolicy(ctx, "bucketPolicy", &s3.BucketPolicyArgs{
Bucket: s3Bucket.ID(),
Policy: pulumi.String(fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::%s/*"
}
]
}`, bucketName)),
})
if err != nil {
return err
}
I create my bucket and then configure it with static web hosting and the permissions that CloudFront requires to access the content.
// CloudFront Distribution for S3 website
cf, err := cloudfront.NewDistribution(ctx, "websiteDistribution", &cloudfront.DistributionArgs{
Enabled: pulumi.Bool(true),
DefaultRootObject: pulumi.String("index.html"),
Origins: cloudfront.DistributionOriginArray{
&cloudfront.DistributionOriginArgs{
DomainName: s3Bucket.WebsiteEndpoint,
OriginId: pulumi.String("S3WebsiteOrigin"),
CustomOriginConfig: &cloudfront.DistributionOriginCustomOriginConfigArgs{
OriginProtocolPolicy: pulumi.String("http-only"),
HttpPort: pulumi.Int(80),
HttpsPort: pulumi.Int(443),
OriginSslProtocols: pulumi.StringArray{
pulumi.String("TLSv1.2"),
},
},
},
},
DefaultCacheBehavior: &cloudfront.DistributionDefaultCacheBehaviorArgs{
TargetOriginId: pulumi.String("S3WebsiteOrigin"),
ViewerProtocolPolicy: pulumi.String("redirect-to-https"),
AllowedMethods: pulumi.StringArray{
pulumi.String("GET"),
pulumi.String("HEAD"),
},
CachedMethods: pulumi.StringArray{
pulumi.String("GET"),
pulumi.String("HEAD"),
},
ForwardedValues: &cloudfront.DistributionDefaultCacheBehaviorForwardedValuesArgs{
QueryString: pulumi.Bool(false),
Cookies: &cloudfront.DistributionDefaultCacheBehaviorForwardedValuesCookiesArgs{
Forward: pulumi.String("none"),
},
},
},
Aliases: pulumi.StringArray{
pulumi.String(siteName),
},
ViewerCertificate: &cloudfront.DistributionViewerCertificateArgs{
AcmCertificateArn: pulumi.String(acmCertificate),
SslSupportMethod: pulumi.String("sni-only"),
},
Restrictions: &cloudfront.DistributionRestrictionsArgs{
GeoRestriction: &cloudfront.DistributionRestrictionsGeoRestrictionArgs{
RestrictionType: pulumi.String("none"),
},
},
})
if err != nil {
return err
}
I deploy the CloudFront distribution, and it is configured for HTTPS access.
// GitHub OIDC IAM Role for GitHub Actions
trustPolicy, err := json.Marshal(map[string]interface{}{
"Version": "2012-10-17",
"Statement": []map[string]interface{}{
{
"Effect": "Allow",
"Principal": map[string]string{
"Federated": "arn:aws:iam::" + awsAccountID + ":oidc-provider/token.actions.githubusercontent.com",
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": map[string]interface{}{
"StringEquals": map[string]string{
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:" + githubRepo + ":ref:refs/heads/main",
},
},
},
},
})
if err != nil {
return err
}
iamRole, err := iam.NewRole(ctx, "githubActionsRole", &iam.RoleArgs{
AssumeRolePolicy: pulumi.String(trustPolicy),
})
if err != nil {
return err
}
// Attach IAM Policy using NewRolePolicy and ApplyT()
_, err = iam.NewRolePolicy(ctx, "githubActionsPolicy", &iam.RolePolicyArgs{
Role: iamRole.Name,
Policy: cf.ID().ApplyT(func(cfID pulumi.ID) (string, error) {
return fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:ListBucket", "s3:DeleteObject", "s3:GetObject"],
"Resource": ["arn:aws:s3:::%s", "arn:aws:s3:::%s/*"]
},
{
"Effect": "Allow",
"Action": ["lambda:InvokeFunction"],
"Resource": "arn:aws:lambda:%s:*:function:%s"
},
{
"Effect": "Allow",
"Action": ["cloudfront:CreateInvalidation"],
"Resource": "arn:aws:cloudfront::*:distribution/%s"
},
{
"Effect": "Allow",
"Action": "sts:AssumeRoleWithWebIdentity",
"Resource": "*"
}
]
}`, bucketName, bucketName, awsRegion, lambdaFunctionName, string(cfID)), nil
}).(pulumi.StringOutput),
})
if err != nil {
return err
}
I create an IAM role for the GitHub OIDC for GitHub Actions to perform actions like update my S3 bucket contents.
// Route 53 Record
_, err = route53.NewRecord(ctx, "websiteRecord", &route53.RecordArgs{
ZoneId: pulumi.String(hostedZoneId), // Using Pulumi config for Hosted Zone ID
Name: pulumi.String(siteName),
Type: pulumi.String("A"),
Aliases: route53.RecordAliasArray{
&route53.RecordAliasArgs{
Name: cf.DomainName,
ZoneId: cf.HostedZoneId,
EvaluateTargetHealth: pulumi.Bool(false),
},
},
})
if err != nil {
return err
}
Finally, I create my Amazon Route 53 record to associate a DNS name with my CloudFront distribution.
Wrapping Things Up...
In this blog post, I discussed how to serve up a workshop to users using all serverless technolgoies like S3, CloudFront, and Route 53. Further, I showed how to automate the deployment of these AWS resources using Pulumi code writen in Go. Once again, Justice Through Code (JTC) is a great program to volunteer with. The cohort members really appreciate the opportunity to work with folks in the IT industry. I highly recommend you consider volunteering with JTC if you have availability.
Let me know what you think about this post, or any other of my blog posts on BlueSky or on LinkedIn!