Introduction
Plausible.io
Plausible.io is a lightweight, open source analytics platform that describes itself as an "Easy to use and privacy-friendly Google Analytics alternative." Unlike Google Analytics, Plausible does not use cookies and does not collect personal data. This means there is no need for GDPR consent banners or cookie notices when using it.
Adblockers
While Plausible respects visitor privacy, many adblockers and privacy tools do not make that distinction. Some blocklist maintainers block all analytics scripts regardless of how privacy-friendly they are.
As stated in Plausible's proxy documentation:
Some visitors use adblockers or privacy tools that block analytics scripts. [...] some blocklist maintainers block all analytics regardless of privacy practices.
This means that a portion of your visitors will never be counted in your analytics. Depending on your audience, the gap can be significant.
As Plausible notes:
Expect some visitors with strict blockers to be missed, typically between 5% and 25% depending on your audience.
Proxy
One way to close this gap is to proxy the analytics script and API requests through your own domain. When the script is served from your domain, adblockers treat it as a first-party resource and let it through.
From Plausible's documentation:
A proxy routes the Plausible script through your own domain as a first-party request, making it indistinguishable from your own files. This bypasses most blockers and lets you count visits that would otherwise be missed.
Plausible provides guides for setting up proxies with several platforms, but AWS CloudFront is not among them. This article fills that gap.
End goal
By the end of this article, we will have a CloudFront distribution that proxies two requests:
https://pa.example.com/js/script.jsproxied tohttps://plausible.io/js/pa-XXXX.js- (where
pa-XXXXis your Plausible script ID)
- (where
https://pa.example.com/api/eventproxied tohttps://plausible.io/api/event
The first request serves the Plausible analytics script. The second forwards analytics events from the visitor's browser to Plausible's API. Together, they allow Plausible to function entirely through your own subdomain.
This article walks through a CloudFormation template that provisions all of the necessary AWS resources to make this work.
Prerequisites
- An active Plausible.io account
- The Plausible script ID for your site (e.g.
pa-XXXX)
- The Plausible script ID for your site (e.g.
- An AWS account
- Comfort in the AWS console or using the AWS CLI
- creating CloudFormation stacks
- issuing certificates using ACM
- Your site's DNS managed by AWS Route53
- A HostedZone in Route53 for your website
- Access to make changes to whichever website for which you'd like to track analytics
Certificate required
CloudFront requires an SSL certificate to serve traffic on a custom domain. You will need to issue a certificate through AWS Certificate Manager (ACM) for the subdomain you plan to use for the proxy (e.g. pa.example.com).
The certificate must be issued in the us-east-1 (N. Virginia) region. This is a CloudFront requirement regardless of where your other AWS resources live.
To issue the certificate, navigate to ACM in the us-east-1 region, request a public certificate for pa.example.com (replacing example.com with your site's root domain), and complete the domain ownership verification. The certificate must be in the "Issued" state before creating the CloudFormation stack.
CloudFormation template
Use this CloudFormation template to create a new stack. You can do this through the AWS Console by navigating to CloudFormation and choosing "Create stack", or via the AWS CLI with aws cloudformation create-stack.
Parameters
The template accepts five parameters:
- PlausibleScriptId: Your Plausible script ID, found in your Plausible dashboard. It follows the format
pa-XXXX. This is used by a Lambda@Edge function to rewrite requests for/js/script.jsto the correct Plausible script path. - DomainName: Your site's root domain (e.g.
example.com). This is the domain Plausible is tracking and is used to construct the proxy subdomain. - AnalyticsSubdomain: The subdomain prefix for the proxy (e.g.
paforpa.example.com). Combined withDomainNameto form the full proxy URL. - HostedZoneId: The ID of the Route53 Hosted Zone for your domain. Used to create a DNS record pointing to the CloudFront distribution.
- CertificateArn: The ARN of the ACM certificate you issued in the previous step. Must be a certificate in
us-east-1.
Resources
The template creates several resources that work together to proxy requests from your subdomain to Plausible's servers.
IAM Role
The LambdaEdgeExecutionRole is an IAM role that both Lambda@Edge functions assume when they execute. It grants permission for the Lambda functions to be invoked by both the standard Lambda service and the CloudFront edge service (edgelambda.amazonaws.com). It also includes a policy allowing the functions to write logs to CloudWatch in any region, which is necessary because Lambda@Edge functions execute at whichever CloudFront edge location is nearest to the visitor.
Lambda@Edge Functions
Two Lambda@Edge functions handle the request rewriting. These are lightweight Node.js functions that run at CloudFront edge locations, modifying requests before they reach the origin (plausible.io).
The ScriptRewriteFunction intercepts requests to /js/script.js and rewrites the URI to /js/pa-XXXX.js (using your PlausibleScriptId). It also sets the Host header to plausible.io so the origin server handles the request correctly. Without this rewrite, your visitors' browsers would request a generic path on your subdomain, and CloudFront would not know which Plausible script to fetch.
The ApiEventFunction handles requests to /api/event. It sets the Host header to plausible.io and forwards the visitor's IP address via the X-Forwarded-For header. This is important because Plausible uses the visitor's IP (in a privacy-friendly, anonymized way) for unique visitor counting. Without forwarding the IP, all events would appear to come from the CloudFront edge server.
Each function has a corresponding Version resource. Lambda@Edge requires a specific, published version of a function (not $LATEST), so these version resources are created alongside the functions and referenced by the CloudFront distribution.
Cache Policies
Two cache policies control how CloudFront caches responses from Plausible.
The ScriptCachePolicy caches the analytics script with a default TTL of 1 day, a minimum of 1 hour, and a maximum of 7 days. This keeps the script served quickly from edge locations while still picking up updates from Plausible within a reasonable window.
The ApiCachePolicy disables caching entirely (all TTLs set to 0). Every analytics event must be forwarded to Plausible's API in real time, so caching would cause events to be lost.
Origin Request Policy
The PlausibleOriginRequestPolicy controls which headers CloudFront forwards to the origin. It forwards User-Agent and X-Forwarded-For, which Plausible uses for analytics processing (browser identification and anonymized visitor counting). It also forwards all query strings, though the current setup does not rely on them.
CloudFront Distribution
The PlausibleDistribution is the core of the proxy. It ties everything together.
The distribution is configured with plausible.io as its origin, using HTTPS-only connections with TLS 1.2. An X-Forwarded-Host header is set on the origin to plausible.io. The distribution uses HTTP/2 and HTTP/3 for performance, and is limited to PriceClass_100 (North America and Europe edge locations) to minimize cost.
It defines two cache behaviors that match the paths we need to proxy:
-
/js/script.jsis handled by the script cache behavior. It only allowsGETandHEADrequests, uses theScriptCachePolicyfor caching, and associates theScriptRewriteFunctionLambda@Edge onorigin-requestto rewrite the path before it reaches Plausible. -
/api/eventis handled by the API event cache behavior. It allows all HTTP methods (includingPOST, which is how the browser sends events), uses theApiCachePolicy(no caching), and associates theApiEventFunctionLambda@Edge onorigin-requestwithIncludeBody: trueso the event payload is forwarded.
Any other path hits the default cache behavior, which does not have a Lambda@Edge function attached and will simply return whatever Plausible responds with (typically a 404).
DNS Record
The DNSRecordA creates a Route53 A record that aliases your proxy subdomain (e.g. pa.example.com) to the CloudFront distribution's domain name. This is what makes https://pa.example.com resolve to your CloudFront distribution. The hosted zone ID Z2FDTNDATAQYW2 is a constant that AWS uses for all CloudFront distributions.
Outputs
The template produces three outputs:
- DistributionId: The CloudFront distribution's ID.
- DistributionDomainName: The CloudFront-assigned domain name (e.g.
d1234567890.cloudfront.net). - ScriptSnippet: An HTML snippet to add to the
<head>of your website.
The ScriptSnippet output includes a <script> tag that loads the analytics script from your proxy subdomain and configures Plausible to send events to your proxy's /api/event endpoint. Copy this snippet into your site and you are up and running.
Conclusion
Using a CloudFormation template, we provisioned a CloudFront distribution with Lambda@Edge functions that proxy Plausible's analytics script and event API through a custom subdomain. Route53 handles DNS, ACM provides the SSL certificate, and two lightweight Lambda functions rewrite requests at the edge. The result is a first-party analytics setup that bypasses most adblockers while keeping Plausible's privacy-friendly approach intact.
Full CloudFormation template for reference
1AWSTemplateFormatVersion: '2010-09-09' 2Description: > 3 CloudFormation template to proxy Plausible.io analytics through CloudFront with Route53 DNS. 4 Proxies /js/script.js to plausible.io/js/pa-XXXX.js and /api/event to plausible.io/api/event. 5 This helps bypass adblockers by serving analytics as first-party requests. 6 7Parameters: 8 PlausibleScriptId: 9 Type: String 10 Description: The Plausible script ID (e.g., pa-XXXX from your Plausible dashboard) 11 AllowedPattern: ^pa-[a-zA-Z0-9]+$ 12 ConstraintDescription: Must be a valid Plausible script ID starting with 'pa-' 13 14 DomainName: 15 Type: String 16 Description: Your site's root domain for Plausible tracking (e.g., example.com) 17 AllowedPattern: ^[a-zA-Z0-9][a-zA-Z0-9\-\.]*[a-zA-Z0-9]\.[a-zA-Z]{2,}$ 18 ConstraintDescription: Must be a valid domain name 19 20 AnalyticsSubdomain: 21 Type: String 22 Description: Subdomain for the CloudFront distribution (e.g., analytics for analytics.example.com) 23 AllowedPattern: ^[a-zA-Z0-9][a-zA-Z0-9\-]* 24 ConstraintDescription: Must be a valid subdomain 25 26 HostedZoneId: 27 Type: AWS::Route53::HostedZone::Id 28 Description: The Route53 Hosted Zone ID for your domain 29 30 CertificateArn: 31 Type: String 32 Description: ARN of an ACM certificate for the custom domain (must be in us-east-1) 33 AllowedPattern: ^arn:aws:acm:us-east-1:[0-9]+:certificate/[a-zA-Z0-9-]+$ 34 ConstraintDescription: Must be a valid ACM certificate ARN in us-east-1 35 36Resources: 37 # IAM Role for Lambda@Edge 38 LambdaEdgeExecutionRole: 39 Type: AWS::IAM::Role 40 Properties: 41 RoleName: !Sub '${AWS::StackName}-lambda-edge-role' 42 AssumeRolePolicyDocument: 43 Version: '2012-10-17' 44 Statement: 45 - Effect: Allow 46 Principal: 47 Service: 48 - lambda.amazonaws.com 49 - edgelambda.amazonaws.com 50 Action: sts:AssumeRole 51 ManagedPolicyArns: 52 - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 53 Policies: 54 - PolicyName: LambdaEdgeLogging 55 PolicyDocument: 56 Version: '2012-10-17' 57 Statement: 58 - Effect: Allow 59 Action: 60 - logs:CreateLogGroup 61 - logs:CreateLogStream 62 - logs:PutLogEvents 63 Resource: 64 - !Sub 'arn:aws:logs:*:${AWS::AccountId}:log-group:/aws/lambda/us-east-1.${AWS::StackName}-script-rewrite:*' 65 - !Sub 'arn:aws:logs:*:${AWS::AccountId}:log-group:/aws/lambda/us-east-1.${AWS::StackName}-api-event:*' 66 67 # Lambda@Edge function for script rewriting (/js/script.js -> /js/pa-XXXX.js) 68 ScriptRewriteFunction: 69 Type: AWS::Lambda::Function 70 Properties: 71 FunctionName: !Sub '${AWS::StackName}-script-rewrite' 72 Description: Rewrites /js/script.js requests to the Plausible script path 73 Runtime: nodejs20.x 74 Handler: index.handler 75 Role: !GetAtt LambdaEdgeExecutionRole.Arn 76 Timeout: 5 77 MemorySize: 128 78 Code: 79 ZipFile: !Sub | 80 'use strict'; 81 exports.handler = (event, context, callback) => { 82 const request = event.Records[0].cf.request; 83 84 // Rewrite the URI from /js/script.js to /js/${PlausibleScriptId}.js 85 if (request.uri === '/js/script.js') { 86 request.uri = '/js/${PlausibleScriptId}.js'; 87 } 88 89 // Set the Host header to plausible.io 90 request.headers['host'] = [{ key: 'host', value: 'plausible.io' }]; 91 92 callback(null, request); 93 }; 94 95 # Version for script rewrite Lambda (required for Lambda@Edge) 96 ScriptRewriteFunctionVersion: 97 Type: AWS::Lambda::Version 98 Properties: 99 FunctionName: !Ref ScriptRewriteFunction100 Description: Version for Lambda@Edge deployment101 102 # Lambda@Edge function for API event proxying103 ApiEventFunction:104 Type: AWS::Lambda::Function105 Properties:106 FunctionName: !Sub '${AWS::StackName}-api-event'107 Description: Handles /api/event requests and sets proper headers for Plausible108 Runtime: nodejs20.x109 Handler: index.handler110 Role: !GetAtt LambdaEdgeExecutionRole.Arn111 Timeout: 5112 MemorySize: 128113 Code:114 ZipFile: |115 'use strict';116 exports.handler = (event, context, callback) => {117 const request = event.Records[0].cf.request;118 119 // Set the Host header to plausible.io120 request.headers['host'] = [{ key: 'host', value: 'plausible.io' }];121 122 // Forward the original client IP via X-Forwarded-For if not already set123 const clientIp = event.Records[0].cf.request.clientIp;124 if (clientIp && !request.headers['x-forwarded-for']) {125 request.headers['x-forwarded-for'] = [{ key: 'X-Forwarded-For', value: clientIp }];126 }127 128 callback(null, request);129 };130 131 # Version for API event Lambda (required for Lambda@Edge)132 ApiEventFunctionVersion:133 Type: AWS::Lambda::Version134 Properties:135 FunctionName: !Ref ApiEventFunction136 Description: Version for Lambda@Edge deployment137 138 # Cache Policy for the script (cache for 1 day)139 ScriptCachePolicy:140 Type: AWS::CloudFront::CachePolicy141 Properties:142 CachePolicyConfig:143 Name: !Sub '${AWS::StackName}-script-cache-policy'144 Comment: Cache policy for Plausible analytics script145 DefaultTTL: 86400 # 1 day146 MinTTL: 3600 # 1 hour minimum147 MaxTTL: 604800 # 7 days maximum148 ParametersInCacheKeyAndForwardedToOrigin:149 EnableAcceptEncodingGzip: false150 EnableAcceptEncodingBrotli: false151 CookiesConfig:152 CookieBehavior: none153 HeadersConfig:154 HeaderBehavior: none155 QueryStringsConfig:156 QueryStringBehavior: none157 158 # Cache Policy for API (no caching)159 ApiCachePolicy:160 Type: AWS::CloudFront::CachePolicy161 Properties:162 CachePolicyConfig:163 Name: !Sub '${AWS::StackName}-api-cache-policy'164 Comment: No-cache policy for Plausible API events165 DefaultTTL: 0166 MinTTL: 0167 MaxTTL: 0168 ParametersInCacheKeyAndForwardedToOrigin:169 EnableAcceptEncodingGzip: false170 EnableAcceptEncodingBrotli: false171 CookiesConfig:172 CookieBehavior: none173 HeadersConfig:174 HeaderBehavior: none175 QueryStringsConfig:176 QueryStringBehavior: none177 178 # Origin Request Policy to forward necessary headers179 PlausibleOriginRequestPolicy:180 Type: AWS::CloudFront::OriginRequestPolicy181 Properties:182 OriginRequestPolicyConfig:183 Name: !Sub '${AWS::StackName}-origin-request-policy'184 Comment: Origin request policy for Plausible analytics185 CookiesConfig:186 CookieBehavior: none187 HeadersConfig:188 HeaderBehavior: whitelist189 Headers:190 - User-Agent191 - X-Forwarded-For192 QueryStringsConfig:193 QueryStringBehavior: all194 195 # CloudFront Distribution196 PlausibleDistribution:197 Type: AWS::CloudFront::Distribution198 Properties:199 DistributionConfig:200 Enabled: true201 Comment: !Sub 'Plausible Analytics Proxy - ${AWS::StackName}'202 PriceClass: PriceClass_100 # Use only North America and Europe edge locations203 HttpVersion: http2and3204 205 # Custom domain configuration206 Aliases:207 - !Sub '${AnalyticsSubdomain}.${DomainName}'208 209 ViewerCertificate:210 AcmCertificateArn: !Ref CertificateArn211 SslSupportMethod: sni-only212 MinimumProtocolVersion: TLSv1.2_2021213 214 # Plausible.io origin215 Origins:216 - Id: plausible-origin217 DomainName: plausible.io218 CustomOriginConfig:219 HTTPSPort: 443220 OriginProtocolPolicy: https-only221 OriginSSLProtocols:222 - TLSv1.2223 OriginCustomHeaders:224 - HeaderName: X-Forwarded-Host225 HeaderValue: plausible.io226 227 # Default behavior (returns 403 for unmatched paths)228 DefaultCacheBehavior:229 TargetOriginId: plausible-origin230 ViewerProtocolPolicy: redirect-to-https231 AllowedMethods:232 - GET233 - HEAD234 CachedMethods:235 - GET236 - HEAD237 CachePolicyId: !Ref ScriptCachePolicy238 Compress: true239 240 # Cache behaviors for specific paths241 CacheBehaviors:242 # Script behavior243 - PathPattern: /js/script.js244 TargetOriginId: plausible-origin245 ViewerProtocolPolicy: redirect-to-https246 AllowedMethods:247 - GET248 - HEAD249 CachedMethods:250 - GET251 - HEAD252 CachePolicyId: !Ref ScriptCachePolicy253 OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy254 Compress: true255 LambdaFunctionAssociations:256 - EventType: origin-request257 LambdaFunctionARN: !Ref ScriptRewriteFunctionVersion258 IncludeBody: false259 260 # API event behavior261 - PathPattern: /api/event262 TargetOriginId: plausible-origin263 ViewerProtocolPolicy: redirect-to-https264 AllowedMethods:265 - GET266 - HEAD267 - OPTIONS268 - PUT269 - POST270 - PATCH271 - DELETE272 CachedMethods:273 - GET274 - HEAD275 CachePolicyId: !Ref ApiCachePolicy276 OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy277 Compress: true278 LambdaFunctionAssociations:279 - EventType: origin-request280 LambdaFunctionARN: !Ref ApiEventFunctionVersion281 IncludeBody: true282 283 # Route53 DNS Record (A record with alias to CloudFront)284 DNSRecordA:285 Type: AWS::Route53::RecordSet286 Properties:287 HostedZoneId: !Ref HostedZoneId288 Name: !Sub '${AnalyticsSubdomain}.${DomainName}'289 Type: A290 AliasTarget:291 DNSName: !GetAtt PlausibleDistribution.DomainName292 HostedZoneId: Z2FDTNDATAQYW2 # CloudFront's hosted zone ID (constant for all distributions)293 EvaluateTargetHealth: false294 295Outputs:296 DistributionId:297 Description: CloudFront Distribution ID298 Value: !Ref PlausibleDistribution299 Export:300 Name: !Sub '${AWS::StackName}-DistributionId'301 302 DistributionDomainName:303 Description: CloudFront Distribution Domain Name304 Value: !GetAtt PlausibleDistribution.DomainName305 Export:306 Name: !Sub '${AWS::StackName}-DistributionDomainName'307 308 ScriptSnippet:309 Description: HTML snippet to add to your website310 Value: !Sub |311 <script defer data-domain="${DomainName}" src="https://${AnalyticsSubdomain}.${DomainName}/js/script.js"></script>312 <script>313 window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};314 plausible.init({315 endpoint: "https://${AnalyticsSubdomain}.${DomainName}/api/event"316 })317 </script>