Skip to content

Instantly share code, notes, and snippets.

@bricehartmann
Last active June 21, 2026 03:20
Show Gist options
  • Select an option

  • Save bricehartmann/e04c5396b092cc3548c6c7e64336819e to your computer and use it in GitHub Desktop.

Select an option

Save bricehartmann/e04c5396b092cc3548c6c7e64336819e to your computer and use it in GitHub Desktop.
AWS CloudFormation template for Plausible.io proxy with AWS CloudFront
AWSTemplateFormatVersion: '2010-09-09'
Description: >
CloudFormation template to proxy Plausible.io analytics through CloudFront with Route53 DNS.
Proxies /js/script.js to plausible.io/js/pa-XXXX.js and /api/event to plausible.io/api/event.
This helps bypass adblockers by serving analytics as first-party requests.
Parameters:
PlausibleScriptId:
Type: String
Description: The Plausible script ID (e.g., pa-XXXX from your Plausible dashboard)
AllowedPattern: ^pa-[a-zA-Z0-9]+$
ConstraintDescription: Must be a valid Plausible script ID starting with 'pa-'
DomainName:
Type: String
Description: Your site's root domain for Plausible tracking (e.g., example.com)
AllowedPattern: ^[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9]\.[a-zA-Z]{2,}$
ConstraintDescription: Must be a valid domain name
AnalyticsSubdomain:
Type: String
Description: Subdomain for the CloudFront distribution (e.g., analytics for analytics.example.com)
AllowedPattern: ^[a-zA-Z0-9][a-zA-Z0-9\-]*
ConstraintDescription: Must be a valid subdomain
HostedZoneId:
Type: AWS::Route53::HostedZone::Id
Description: The Route53 Hosted Zone ID for your domain
CertificateArn:
Type: String
Description: ARN of an ACM certificate for the custom domain (must be in us-east-1)
AllowedPattern: ^arn:aws:acm:us-east-1:[0-9]+:certificate/[a-zA-Z0-9-]+$
ConstraintDescription: Must be a valid ACM certificate ARN in us-east-1
Resources:
# IAM Role for Lambda@Edge
LambdaEdgeExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub '${AWS::StackName}-lambda-edge-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
- edgelambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: LambdaEdgeLogging
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource:
- !Sub 'arn:aws:logs:*:${AWS::AccountId}:log-group:/aws/lambda/us-east-1.${AWS::StackName}-api-event:*'
# CloudFront Function for script rewriting (/js/script.js -> /js/pa-XXXX.js)
ScriptRewriteFunction:
Type: AWS::CloudFront::Function
Properties:
Name: !Sub '${AWS::StackName}-script-rewrite'
AutoPublish: true
FunctionConfig:
Comment: Rewrites /js/script.js to the Plausible script path
Runtime: cloudfront-js-2.0
FunctionCode: !Sub |
function handler(event) {
var request = event.request;
if (request.uri === '/js/script.js') {
request.uri = '/js/${PlausibleScriptId}.js';
}
return request;
}
# Lambda@Edge function for API event proxying
ApiEventFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub '${AWS::StackName}-api-event'
Description: Handles /api/event requests and sets proper headers for Plausible
Runtime: nodejs20.x
Handler: index.handler
Role: !GetAtt LambdaEdgeExecutionRole.Arn
Timeout: 5
MemorySize: 128
Code:
ZipFile: |
'use strict';
exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
// Set the Host header to plausible.io
request.headers['host'] = [{ key: 'host', value: 'plausible.io' }];
// Forward the original client IP via X-Forwarded-For if not already set
const clientIp = event.Records[0].cf.request.clientIp;
if (clientIp && !request.headers['x-forwarded-for']) {
request.headers['x-forwarded-for'] = [{ key: 'X-Forwarded-For', value: clientIp }];
}
callback(null, request);
};
# Version for API event Lambda (required for Lambda@Edge)
ApiEventFunctionVersion:
Type: AWS::Lambda::Version
DeletionPolicy: Retain
Properties:
FunctionName: !Ref ApiEventFunction
Description: Version for Lambda@Edge deployment
# Cache Policy for the script (cache for 1 day)
ScriptCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
CachePolicyConfig:
Name: !Sub '${AWS::StackName}-script-cache-policy'
Comment: Cache policy for Plausible analytics script
DefaultTTL: 86400 # 1 day
MinTTL: 3600 # 1 hour minimum
MaxTTL: 604800 # 7 days maximum
ParametersInCacheKeyAndForwardedToOrigin:
EnableAcceptEncodingGzip: true
EnableAcceptEncodingBrotli: true
CookiesConfig:
CookieBehavior: none
HeadersConfig:
HeaderBehavior: none
QueryStringsConfig:
QueryStringBehavior: none
# Cache Policy for API (no caching)
ApiCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
CachePolicyConfig:
Name: !Sub '${AWS::StackName}-api-cache-policy'
Comment: No-cache policy for Plausible API events
DefaultTTL: 0
MinTTL: 0
MaxTTL: 0
ParametersInCacheKeyAndForwardedToOrigin:
EnableAcceptEncodingGzip: false
EnableAcceptEncodingBrotli: false
CookiesConfig:
CookieBehavior: none
HeadersConfig:
HeaderBehavior: none
QueryStringsConfig:
QueryStringBehavior: none
# Origin Request Policy to forward necessary headers
PlausibleOriginRequestPolicy:
Type: AWS::CloudFront::OriginRequestPolicy
Properties:
OriginRequestPolicyConfig:
Name: !Sub '${AWS::StackName}-origin-request-policy'
Comment: Origin request policy for Plausible analytics
CookiesConfig:
CookieBehavior: none
HeadersConfig:
HeaderBehavior: whitelist
Headers:
- User-Agent
- X-Forwarded-For
QueryStringsConfig:
QueryStringBehavior: all
# CloudFront Function to block unmatched paths with 403
BlockUnmatchedPathsFunction:
Type: AWS::CloudFront::Function
Properties:
Name: !Sub '${AWS::StackName}-block-unmatched'
AutoPublish: true
FunctionConfig:
Comment: Returns 403 for any path not explicitly allowed
Runtime: cloudfront-js-2.0
FunctionCode: |
function handler(event) {
return {
statusCode: 403,
statusDescription: 'Forbidden',
headers: {
'content-type': { value: 'text/plain' }
},
body: { encoding: 'text', data: 'Forbidden' }
};
}
# Response headers policy with security headers
SecurityHeadersPolicy:
Type: AWS::CloudFront::ResponseHeadersPolicy
Properties:
ResponseHeadersPolicyConfig:
Name: !Sub '${AWS::StackName}-security-headers'
Comment: Security headers for Plausible proxy
SecurityHeadersConfig:
ContentTypeOptions:
Override: true
FrameOptions:
FrameOption: DENY
Override: true
StrictTransportSecurity:
AccessControlMaxAgeSec: 31536000
IncludeSubdomains: true
Override: true
ReferrerPolicy:
ReferrerPolicy: strict-origin-when-cross-origin
Override: true
# CloudFront Distribution
PlausibleDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
Comment: !Sub 'Plausible Analytics Proxy - ${AWS::StackName}'
PriceClass: PriceClass_100 # Use only North America and Europe edge locations
HttpVersion: http2and3
# Custom domain configuration
Aliases:
- !Sub '${AnalyticsSubdomain}.${DomainName}'
ViewerCertificate:
AcmCertificateArn: !Ref CertificateArn
SslSupportMethod: sni-only
MinimumProtocolVersion: TLSv1.2_2021
# Plausible.io origin
Origins:
- Id: plausible-origin
DomainName: plausible.io
CustomOriginConfig:
HTTPSPort: 443
OriginProtocolPolicy: https-only
OriginSSLProtocols:
- TLSv1.2
OriginCustomHeaders:
- HeaderName: X-Forwarded-Host
HeaderValue: plausible.io
# Default behavior (blocks unmatched paths with 403)
DefaultCacheBehavior:
TargetOriginId: plausible-origin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
CachePolicyId: !Ref ScriptCachePolicy
Compress: true
FunctionAssociations:
- EventType: viewer-request
FunctionARN: !GetAtt BlockUnmatchedPathsFunction.FunctionARN
# Cache behaviors for specific paths
CacheBehaviors:
# Script behavior
- PathPattern: /js/script.js
TargetOriginId: plausible-origin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
CachePolicyId: !Ref ScriptCachePolicy
OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy
ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy
Compress: true
FunctionAssociations:
- EventType: viewer-request
FunctionARN: !GetAtt ScriptRewriteFunction.FunctionARN
# API event behavior
- PathPattern: /api/event
TargetOriginId: plausible-origin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods:
- GET
- HEAD
- OPTIONS
- PUT
- POST
- PATCH
- DELETE
CachedMethods:
- GET
- HEAD
CachePolicyId: !Ref ApiCachePolicy
OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy
ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy
Compress: true
LambdaFunctionAssociations:
- EventType: origin-request
LambdaFunctionARN: !Ref ApiEventFunctionVersion
IncludeBody: true
# Route53 DNS Record (A record with alias to CloudFront)
DNSRecordA:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZoneId
Name: !Sub '${AnalyticsSubdomain}.${DomainName}'
Type: A
AliasTarget:
DNSName: !GetAtt PlausibleDistribution.DomainName
HostedZoneId: Z2FDTNDATAQYW2 # CloudFront's hosted zone ID (constant for all distributions)
EvaluateTargetHealth: false
# Route53 DNS Record (AAAA record for IPv6)
DNSRecordAAAA:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZoneId
Name: !Sub '${AnalyticsSubdomain}.${DomainName}'
Type: AAAA
AliasTarget:
DNSName: !GetAtt PlausibleDistribution.DomainName
HostedZoneId: Z2FDTNDATAQYW2
EvaluateTargetHealth: false
Outputs:
DistributionId:
Description: CloudFront Distribution ID
Value: !Ref PlausibleDistribution
Export:
Name: !Sub '${AWS::StackName}-DistributionId'
DistributionDomainName:
Description: CloudFront Distribution Domain Name
Value: !GetAtt PlausibleDistribution.DomainName
Export:
Name: !Sub '${AWS::StackName}-DistributionDomainName'
ScriptSnippet:
Description: HTML snippet to add to your website
Value: !Sub |
<script defer data-domain="${DomainName}" src="https://${AnalyticsSubdomain}.${DomainName}/js/script.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init({
endpoint: "https://${AnalyticsSubdomain}.${DomainName}/api/event"
})
</script>
@bricehartmann

Copy link
Copy Markdown
Author

Consider reading the article that explains this CloudFormation template.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment