Proxying Plausible.io through AWS CloudFront

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:

  1. https://pa.example.com/js/script.js proxied to https://plausible.io/js/pa-XXXX.js
    • (where pa-XXXX is your Plausible script ID)
  2. https://pa.example.com/api/event proxied to https://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)
  • 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
Review the prerequisites above carefully before continuing. Missing any of these will prevent you from completing the setup.

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.js to 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. pa for pa.example.com). Combined with DomainName to 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:

  1. /js/script.js is handled by the script cache behavior. It only allows GET and HEAD requests, uses the ScriptCachePolicy for caching, and associates the ScriptRewriteFunction Lambda@Edge on origin-request to rewrite the path before it reaches Plausible.

  2. /api/event is handled by the API event cache behavior. It allows all HTTP methods (including POST, which is how the browser sends events), uses the ApiCachePolicy (no caching), and associates the ApiEventFunction Lambda@Edge on origin-request with IncludeBody: true so 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 ScriptRewriteFunction
100 Description: Version for Lambda@Edge deployment
101 
102 # Lambda@Edge function for API event proxying
103 ApiEventFunction:
104 Type: AWS::Lambda::Function
105 Properties:
106 FunctionName: !Sub '${AWS::StackName}-api-event'
107 Description: Handles /api/event requests and sets proper headers for Plausible
108 Runtime: nodejs20.x
109 Handler: index.handler
110 Role: !GetAtt LambdaEdgeExecutionRole.Arn
111 Timeout: 5
112 MemorySize: 128
113 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.io
120 request.headers['host'] = [{ key: 'host', value: 'plausible.io' }];
121 
122 // Forward the original client IP via X-Forwarded-For if not already set
123 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::Version
134 Properties:
135 FunctionName: !Ref ApiEventFunction
136 Description: Version for Lambda@Edge deployment
137 
138 # Cache Policy for the script (cache for 1 day)
139 ScriptCachePolicy:
140 Type: AWS::CloudFront::CachePolicy
141 Properties:
142 CachePolicyConfig:
143 Name: !Sub '${AWS::StackName}-script-cache-policy'
144 Comment: Cache policy for Plausible analytics script
145 DefaultTTL: 86400 # 1 day
146 MinTTL: 3600 # 1 hour minimum
147 MaxTTL: 604800 # 7 days maximum
148 ParametersInCacheKeyAndForwardedToOrigin:
149 EnableAcceptEncodingGzip: false
150 EnableAcceptEncodingBrotli: false
151 CookiesConfig:
152 CookieBehavior: none
153 HeadersConfig:
154 HeaderBehavior: none
155 QueryStringsConfig:
156 QueryStringBehavior: none
157 
158 # Cache Policy for API (no caching)
159 ApiCachePolicy:
160 Type: AWS::CloudFront::CachePolicy
161 Properties:
162 CachePolicyConfig:
163 Name: !Sub '${AWS::StackName}-api-cache-policy'
164 Comment: No-cache policy for Plausible API events
165 DefaultTTL: 0
166 MinTTL: 0
167 MaxTTL: 0
168 ParametersInCacheKeyAndForwardedToOrigin:
169 EnableAcceptEncodingGzip: false
170 EnableAcceptEncodingBrotli: false
171 CookiesConfig:
172 CookieBehavior: none
173 HeadersConfig:
174 HeaderBehavior: none
175 QueryStringsConfig:
176 QueryStringBehavior: none
177 
178 # Origin Request Policy to forward necessary headers
179 PlausibleOriginRequestPolicy:
180 Type: AWS::CloudFront::OriginRequestPolicy
181 Properties:
182 OriginRequestPolicyConfig:
183 Name: !Sub '${AWS::StackName}-origin-request-policy'
184 Comment: Origin request policy for Plausible analytics
185 CookiesConfig:
186 CookieBehavior: none
187 HeadersConfig:
188 HeaderBehavior: whitelist
189 Headers:
190 - User-Agent
191 - X-Forwarded-For
192 QueryStringsConfig:
193 QueryStringBehavior: all
194 
195 # CloudFront Distribution
196 PlausibleDistribution:
197 Type: AWS::CloudFront::Distribution
198 Properties:
199 DistributionConfig:
200 Enabled: true
201 Comment: !Sub 'Plausible Analytics Proxy - ${AWS::StackName}'
202 PriceClass: PriceClass_100 # Use only North America and Europe edge locations
203 HttpVersion: http2and3
204 
205 # Custom domain configuration
206 Aliases:
207 - !Sub '${AnalyticsSubdomain}.${DomainName}'
208 
209 ViewerCertificate:
210 AcmCertificateArn: !Ref CertificateArn
211 SslSupportMethod: sni-only
212 MinimumProtocolVersion: TLSv1.2_2021
213 
214 # Plausible.io origin
215 Origins:
216 - Id: plausible-origin
217 DomainName: plausible.io
218 CustomOriginConfig:
219 HTTPSPort: 443
220 OriginProtocolPolicy: https-only
221 OriginSSLProtocols:
222 - TLSv1.2
223 OriginCustomHeaders:
224 - HeaderName: X-Forwarded-Host
225 HeaderValue: plausible.io
226 
227 # Default behavior (returns 403 for unmatched paths)
228 DefaultCacheBehavior:
229 TargetOriginId: plausible-origin
230 ViewerProtocolPolicy: redirect-to-https
231 AllowedMethods:
232 - GET
233 - HEAD
234 CachedMethods:
235 - GET
236 - HEAD
237 CachePolicyId: !Ref ScriptCachePolicy
238 Compress: true
239 
240 # Cache behaviors for specific paths
241 CacheBehaviors:
242 # Script behavior
243 - PathPattern: /js/script.js
244 TargetOriginId: plausible-origin
245 ViewerProtocolPolicy: redirect-to-https
246 AllowedMethods:
247 - GET
248 - HEAD
249 CachedMethods:
250 - GET
251 - HEAD
252 CachePolicyId: !Ref ScriptCachePolicy
253 OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy
254 Compress: true
255 LambdaFunctionAssociations:
256 - EventType: origin-request
257 LambdaFunctionARN: !Ref ScriptRewriteFunctionVersion
258 IncludeBody: false
259 
260 # API event behavior
261 - PathPattern: /api/event
262 TargetOriginId: plausible-origin
263 ViewerProtocolPolicy: redirect-to-https
264 AllowedMethods:
265 - GET
266 - HEAD
267 - OPTIONS
268 - PUT
269 - POST
270 - PATCH
271 - DELETE
272 CachedMethods:
273 - GET
274 - HEAD
275 CachePolicyId: !Ref ApiCachePolicy
276 OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy
277 Compress: true
278 LambdaFunctionAssociations:
279 - EventType: origin-request
280 LambdaFunctionARN: !Ref ApiEventFunctionVersion
281 IncludeBody: true
282 
283 # Route53 DNS Record (A record with alias to CloudFront)
284 DNSRecordA:
285 Type: AWS::Route53::RecordSet
286 Properties:
287 HostedZoneId: !Ref HostedZoneId
288 Name: !Sub '${AnalyticsSubdomain}.${DomainName}'
289 Type: A
290 AliasTarget:
291 DNSName: !GetAtt PlausibleDistribution.DomainName
292 HostedZoneId: Z2FDTNDATAQYW2 # CloudFront's hosted zone ID (constant for all distributions)
293 EvaluateTargetHealth: false
294 
295Outputs:
296 DistributionId:
297 Description: CloudFront Distribution ID
298 Value: !Ref PlausibleDistribution
299 Export:
300 Name: !Sub '${AWS::StackName}-DistributionId'
301 
302 DistributionDomainName:
303 Description: CloudFront Distribution Domain Name
304 Value: !GetAtt PlausibleDistribution.DomainName
305 Export:
306 Name: !Sub '${AWS::StackName}-DistributionDomainName'
307 
308 ScriptSnippet:
309 Description: HTML snippet to add to your website
310 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>
🤖
Did you spot a mistake in this article? Have a suggestion for how something can be improved? Even if you'd just like to comment or chat about something else, I'd love to hear from you! Contact me.

Syntax highlighting by Torchlight.dev

End of article