Last active
June 21, 2026 03:20
-
-
Save bricehartmann/e04c5396b092cc3548c6c7e64336819e to your computer and use it in GitHub Desktop.
AWS CloudFormation template for Plausible.io proxy with AWS CloudFront
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Consider reading the article that explains this CloudFormation template.