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 CloudFront 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. It uses CloudFront Functions for lightweight request processing at the viewer edge and a Lambda@Edge function where access to the request body is required.
IAM Role
The LambdaEdgeExecutionRole is an IAM role that the Lambda@Edge function assumes when it executes. It grants permission for the function to be invoked by both the standard Lambda service and the CloudFront edge service (edgelambda.amazonaws.com). It also includes a policy allowing the function 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.
CloudFront Functions
Two CloudFront Functions handle lightweight request processing at the edge. CloudFront Functions run at the viewer-request stage, execute in under a millisecond, and do not require IAM roles or published versions.
The ScriptRewriteFunction intercepts requests to /js/script.js and rewrites the URI to /js/pa-XXXX.js (using your PlausibleScriptId). 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 BlockUnmatchedPathsFunction is attached to the default cache behavior and returns a 403 response for any request that does not match /js/script.js or /api/event. This prevents the distribution from proxying arbitrary requests to Plausible's servers.
Lambda@Edge Function
The ApiEventFunction is a Lambda@Edge function that 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.
This function remains as Lambda@Edge rather than a CloudFront Function because it needs access to the request body. The browser sends analytics events as POST requests with a JSON payload, and the Lambda@Edge association is configured with IncludeBody: true to forward that payload to Plausible. CloudFront Functions cannot read request bodies.
Lambda@Edge requires a specific, published version of a function (not $LATEST), so a corresponding Version resource is created alongside the function. The version has a DeletionPolicy of Retain to prevent CloudFormation from deleting old versions during stack updates while CloudFront may still reference them.
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 policy enables gzip and Brotli compression to reduce payload size.
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.
Response Headers Policy
The SecurityHeadersPolicy adds security headers to responses from both the script and API behaviors:
X-Content-Type-Options: nosniffprevents browsers from MIME-sniffing the response away from the declared content type.X-Frame-Options: DENYprevents the proxy from being embedded in iframes.Strict-Transport-Securitywith a one-year max age andincludeSubdomainsensures browsers always connect over HTTPS.Referrer-Policy: strict-origin-when-cross-originlimits referrer information sent to Plausible's origin.
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.
The default cache behavior uses the BlockUnmatchedPathsFunction CloudFront Function to return a 403 for any request that does not match an explicitly defined path. This ensures the distribution only proxies the two paths it is designed to handle.
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 theScriptRewriteFunctionCloudFront Function onviewer-requestto rewrite the path before it reaches Plausible. TheSecurityHeadersPolicyis attached to add security headers to the response. -
/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. TheSecurityHeadersPolicyis also attached to this behavior.
DNS Records
Two Route53 records alias your proxy subdomain (e.g. pa.example.com) to the CloudFront distribution: an A record for IPv4 and an AAAA record for IPv6. This is what makes https://pa.example.com resolve to your CloudFront distribution over both protocols. 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 that proxies Plausible's analytics script and event API through a custom subdomain. CloudFront Functions handle script rewriting and path blocking at the viewer edge, while a Lambda@Edge function forwards API events with the visitor's IP. Security headers, compression, and IPv6 support round out the setup. Route53 handles DNS and ACM provides the SSL certificate. 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}-api-event:*' 65 66 # CloudFront Function for script rewriting (/js/script.js -> /js/pa-XXXX.js) 67 ScriptRewriteFunction: 68 Type: AWS::CloudFront::Function 69 Properties: 70 Name: !Sub '${AWS::StackName}-script-rewrite' 71 AutoPublish: true 72 FunctionConfig: 73 Comment: Rewrites /js/script.js to the Plausible script path 74 Runtime: cloudfront-js-2.0 75 FunctionCode: !Sub | 76 function handler(event) { 77 var request = event.request; 78 if (request.uri === '/js/script.js') { 79 request.uri = '/js/${PlausibleScriptId}.js'; 80 } 81 return request; 82 } 83 84 # Lambda@Edge function for API event proxying 85 ApiEventFunction: 86 Type: AWS::Lambda::Function 87 Properties: 88 FunctionName: !Sub '${AWS::StackName}-api-event' 89 Description: Handles /api/event requests and sets proper headers for Plausible 90 Runtime: nodejs20.x 91 Handler: index.handler 92 Role: !GetAtt LambdaEdgeExecutionRole.Arn 93 Timeout: 5 94 MemorySize: 128 95 Code: 96 ZipFile: | 97 'use strict'; 98 exports.handler = (event, context, callback) => { 99 const request = event.Records[0].cf.request;100 101 // Set the Host header to plausible.io102 request.headers['host'] = [{ key: 'host', value: 'plausible.io' }];103 104 // Forward the original client IP via X-Forwarded-For if not already set105 const clientIp = event.Records[0].cf.request.clientIp;106 if (clientIp && !request.headers['x-forwarded-for']) {107 request.headers['x-forwarded-for'] = [{ key: 'X-Forwarded-For', value: clientIp }];108 }109 110 callback(null, request);111 };112 113 # Version for API event Lambda (required for Lambda@Edge)114 ApiEventFunctionVersion:115 Type: AWS::Lambda::Version116 DeletionPolicy: Retain117 Properties:118 FunctionName: !Ref ApiEventFunction119 Description: Version for Lambda@Edge deployment120 121 # Cache Policy for the script (cache for 1 day)122 ScriptCachePolicy:123 Type: AWS::CloudFront::CachePolicy124 Properties:125 CachePolicyConfig:126 Name: !Sub '${AWS::StackName}-script-cache-policy'127 Comment: Cache policy for Plausible analytics script128 DefaultTTL: 86400 # 1 day129 MinTTL: 3600 # 1 hour minimum130 MaxTTL: 604800 # 7 days maximum131 ParametersInCacheKeyAndForwardedToOrigin:132 EnableAcceptEncodingGzip: true133 EnableAcceptEncodingBrotli: true134 CookiesConfig:135 CookieBehavior: none136 HeadersConfig:137 HeaderBehavior: none138 QueryStringsConfig:139 QueryStringBehavior: none140 141 # Cache Policy for API (no caching)142 ApiCachePolicy:143 Type: AWS::CloudFront::CachePolicy144 Properties:145 CachePolicyConfig:146 Name: !Sub '${AWS::StackName}-api-cache-policy'147 Comment: No-cache policy for Plausible API events148 DefaultTTL: 0149 MinTTL: 0150 MaxTTL: 0151 ParametersInCacheKeyAndForwardedToOrigin:152 EnableAcceptEncodingGzip: false153 EnableAcceptEncodingBrotli: false154 CookiesConfig:155 CookieBehavior: none156 HeadersConfig:157 HeaderBehavior: none158 QueryStringsConfig:159 QueryStringBehavior: none160 161 # Origin Request Policy to forward necessary headers162 PlausibleOriginRequestPolicy:163 Type: AWS::CloudFront::OriginRequestPolicy164 Properties:165 OriginRequestPolicyConfig:166 Name: !Sub '${AWS::StackName}-origin-request-policy'167 Comment: Origin request policy for Plausible analytics168 CookiesConfig:169 CookieBehavior: none170 HeadersConfig:171 HeaderBehavior: whitelist172 Headers:173 - User-Agent174 - X-Forwarded-For175 QueryStringsConfig:176 QueryStringBehavior: all177 178 # CloudFront Function to block unmatched paths with 403179 BlockUnmatchedPathsFunction:180 Type: AWS::CloudFront::Function181 Properties:182 Name: !Sub '${AWS::StackName}-block-unmatched'183 AutoPublish: true184 FunctionConfig:185 Comment: Returns 403 for any path not explicitly allowed186 Runtime: cloudfront-js-2.0187 FunctionCode: |188 function handler(event) {189 return {190 statusCode: 403,191 statusDescription: 'Forbidden',192 headers: {193 'content-type': { value: 'text/plain' }194 },195 body: { encoding: 'text', data: 'Forbidden' }196 };197 }198 199 # Response headers policy with security headers200 SecurityHeadersPolicy:201 Type: AWS::CloudFront::ResponseHeadersPolicy202 Properties:203 ResponseHeadersPolicyConfig:204 Name: !Sub '${AWS::StackName}-security-headers'205 Comment: Security headers for Plausible proxy206 SecurityHeadersConfig:207 ContentTypeOptions:208 Override: true209 FrameOptions:210 FrameOption: DENY211 Override: true212 StrictTransportSecurity:213 AccessControlMaxAgeSec: 31536000214 IncludeSubdomains: true215 Override: true216 ReferrerPolicy:217 ReferrerPolicy: strict-origin-when-cross-origin218 Override: true219 220 # CloudFront Distribution221 PlausibleDistribution:222 Type: AWS::CloudFront::Distribution223 Properties:224 DistributionConfig:225 Enabled: true226 Comment: !Sub 'Plausible Analytics Proxy - ${AWS::StackName}'227 PriceClass: PriceClass_100 # Use only North America and Europe edge locations228 HttpVersion: http2and3229 230 # Custom domain configuration231 Aliases:232 - !Sub '${AnalyticsSubdomain}.${DomainName}'233 234 ViewerCertificate:235 AcmCertificateArn: !Ref CertificateArn236 SslSupportMethod: sni-only237 MinimumProtocolVersion: TLSv1.2_2021238 239 # Plausible.io origin240 Origins:241 - Id: plausible-origin242 DomainName: plausible.io243 CustomOriginConfig:244 HTTPSPort: 443245 OriginProtocolPolicy: https-only246 OriginSSLProtocols:247 - TLSv1.2248 OriginCustomHeaders:249 - HeaderName: X-Forwarded-Host250 HeaderValue: plausible.io251 252 # Default behavior (blocks unmatched paths with 403)253 DefaultCacheBehavior:254 TargetOriginId: plausible-origin255 ViewerProtocolPolicy: redirect-to-https256 AllowedMethods:257 - GET258 - HEAD259 CachedMethods:260 - GET261 - HEAD262 CachePolicyId: !Ref ScriptCachePolicy263 Compress: true264 FunctionAssociations:265 - EventType: viewer-request266 FunctionARN: !GetAtt BlockUnmatchedPathsFunction.FunctionARN267 268 # Cache behaviors for specific paths269 CacheBehaviors:270 # Script behavior271 - PathPattern: /js/script.js272 TargetOriginId: plausible-origin273 ViewerProtocolPolicy: redirect-to-https274 AllowedMethods:275 - GET276 - HEAD277 CachedMethods:278 - GET279 - HEAD280 CachePolicyId: !Ref ScriptCachePolicy281 OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy282 ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy283 Compress: true284 FunctionAssociations:285 - EventType: viewer-request286 FunctionARN: !GetAtt ScriptRewriteFunction.FunctionARN287 288 # API event behavior289 - PathPattern: /api/event290 TargetOriginId: plausible-origin291 ViewerProtocolPolicy: redirect-to-https292 AllowedMethods:293 - GET294 - HEAD295 - OPTIONS296 - PUT297 - POST298 - PATCH299 - DELETE300 CachedMethods:301 - GET302 - HEAD303 CachePolicyId: !Ref ApiCachePolicy304 OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy305 ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy306 Compress: true307 LambdaFunctionAssociations:308 - EventType: origin-request309 LambdaFunctionARN: !Ref ApiEventFunctionVersion310 IncludeBody: true311 312 # Route53 DNS Record (A record with alias to CloudFront)313 DNSRecordA:314 Type: AWS::Route53::RecordSet315 Properties:316 HostedZoneId: !Ref HostedZoneId317 Name: !Sub '${AnalyticsSubdomain}.${DomainName}'318 Type: A319 AliasTarget:320 DNSName: !GetAtt PlausibleDistribution.DomainName321 HostedZoneId: Z2FDTNDATAQYW2 # CloudFront's hosted zone ID (constant for all distributions)322 EvaluateTargetHealth: false323 324 # Route53 DNS Record (AAAA record for IPv6)325 DNSRecordAAAA:326 Type: AWS::Route53::RecordSet327 Properties:328 HostedZoneId: !Ref HostedZoneId329 Name: !Sub '${AnalyticsSubdomain}.${DomainName}'330 Type: AAAA331 AliasTarget:332 DNSName: !GetAtt PlausibleDistribution.DomainName333 HostedZoneId: Z2FDTNDATAQYW2334 EvaluateTargetHealth: false335 336Outputs:337 DistributionId:338 Description: CloudFront Distribution ID339 Value: !Ref PlausibleDistribution340 Export:341 Name: !Sub '${AWS::StackName}-DistributionId'342 343 DistributionDomainName:344 Description: CloudFront Distribution Domain Name345 Value: !GetAtt PlausibleDistribution.DomainName346 Export:347 Name: !Sub '${AWS::StackName}-DistributionDomainName'348 349 ScriptSnippet:350 Description: HTML snippet to add to your website351 Value: !Sub |352 <script defer data-domain="${DomainName}" src="https://${AnalyticsSubdomain}.${DomainName}/js/script.js"></script>353 <script>354 window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};355 plausible.init({356 endpoint: "https://${AnalyticsSubdomain}.${DomainName}/api/event"357 })358 </script>