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 CloudFront 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. 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: nosniff prevents browsers from MIME-sniffing the response away from the declared content type.
  • X-Frame-Options: DENY prevents the proxy from being embedded in iframes.
  • Strict-Transport-Security with a one-year max age and includeSubdomains ensures browsers always connect over HTTPS.
  • Referrer-Policy: strict-origin-when-cross-origin limits 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:

  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 CloudFront Function on viewer-request to rewrite the path before it reaches Plausible. The SecurityHeadersPolicy is attached to add security headers to the response.

  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. The SecurityHeadersPolicy is 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.io
102 request.headers['host'] = [{ key: 'host', value: 'plausible.io' }];
103 
104 // Forward the original client IP via X-Forwarded-For if not already set
105 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::Version
116 DeletionPolicy: Retain
117 Properties:
118 FunctionName: !Ref ApiEventFunction
119 Description: Version for Lambda@Edge deployment
120 
121 # Cache Policy for the script (cache for 1 day)
122 ScriptCachePolicy:
123 Type: AWS::CloudFront::CachePolicy
124 Properties:
125 CachePolicyConfig:
126 Name: !Sub '${AWS::StackName}-script-cache-policy'
127 Comment: Cache policy for Plausible analytics script
128 DefaultTTL: 86400 # 1 day
129 MinTTL: 3600 # 1 hour minimum
130 MaxTTL: 604800 # 7 days maximum
131 ParametersInCacheKeyAndForwardedToOrigin:
132 EnableAcceptEncodingGzip: true
133 EnableAcceptEncodingBrotli: true
134 CookiesConfig:
135 CookieBehavior: none
136 HeadersConfig:
137 HeaderBehavior: none
138 QueryStringsConfig:
139 QueryStringBehavior: none
140 
141 # Cache Policy for API (no caching)
142 ApiCachePolicy:
143 Type: AWS::CloudFront::CachePolicy
144 Properties:
145 CachePolicyConfig:
146 Name: !Sub '${AWS::StackName}-api-cache-policy'
147 Comment: No-cache policy for Plausible API events
148 DefaultTTL: 0
149 MinTTL: 0
150 MaxTTL: 0
151 ParametersInCacheKeyAndForwardedToOrigin:
152 EnableAcceptEncodingGzip: false
153 EnableAcceptEncodingBrotli: false
154 CookiesConfig:
155 CookieBehavior: none
156 HeadersConfig:
157 HeaderBehavior: none
158 QueryStringsConfig:
159 QueryStringBehavior: none
160 
161 # Origin Request Policy to forward necessary headers
162 PlausibleOriginRequestPolicy:
163 Type: AWS::CloudFront::OriginRequestPolicy
164 Properties:
165 OriginRequestPolicyConfig:
166 Name: !Sub '${AWS::StackName}-origin-request-policy'
167 Comment: Origin request policy for Plausible analytics
168 CookiesConfig:
169 CookieBehavior: none
170 HeadersConfig:
171 HeaderBehavior: whitelist
172 Headers:
173 - User-Agent
174 - X-Forwarded-For
175 QueryStringsConfig:
176 QueryStringBehavior: all
177 
178 # CloudFront Function to block unmatched paths with 403
179 BlockUnmatchedPathsFunction:
180 Type: AWS::CloudFront::Function
181 Properties:
182 Name: !Sub '${AWS::StackName}-block-unmatched'
183 AutoPublish: true
184 FunctionConfig:
185 Comment: Returns 403 for any path not explicitly allowed
186 Runtime: cloudfront-js-2.0
187 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 headers
200 SecurityHeadersPolicy:
201 Type: AWS::CloudFront::ResponseHeadersPolicy
202 Properties:
203 ResponseHeadersPolicyConfig:
204 Name: !Sub '${AWS::StackName}-security-headers'
205 Comment: Security headers for Plausible proxy
206 SecurityHeadersConfig:
207 ContentTypeOptions:
208 Override: true
209 FrameOptions:
210 FrameOption: DENY
211 Override: true
212 StrictTransportSecurity:
213 AccessControlMaxAgeSec: 31536000
214 IncludeSubdomains: true
215 Override: true
216 ReferrerPolicy:
217 ReferrerPolicy: strict-origin-when-cross-origin
218 Override: true
219 
220 # CloudFront Distribution
221 PlausibleDistribution:
222 Type: AWS::CloudFront::Distribution
223 Properties:
224 DistributionConfig:
225 Enabled: true
226 Comment: !Sub 'Plausible Analytics Proxy - ${AWS::StackName}'
227 PriceClass: PriceClass_100 # Use only North America and Europe edge locations
228 HttpVersion: http2and3
229 
230 # Custom domain configuration
231 Aliases:
232 - !Sub '${AnalyticsSubdomain}.${DomainName}'
233 
234 ViewerCertificate:
235 AcmCertificateArn: !Ref CertificateArn
236 SslSupportMethod: sni-only
237 MinimumProtocolVersion: TLSv1.2_2021
238 
239 # Plausible.io origin
240 Origins:
241 - Id: plausible-origin
242 DomainName: plausible.io
243 CustomOriginConfig:
244 HTTPSPort: 443
245 OriginProtocolPolicy: https-only
246 OriginSSLProtocols:
247 - TLSv1.2
248 OriginCustomHeaders:
249 - HeaderName: X-Forwarded-Host
250 HeaderValue: plausible.io
251 
252 # Default behavior (blocks unmatched paths with 403)
253 DefaultCacheBehavior:
254 TargetOriginId: plausible-origin
255 ViewerProtocolPolicy: redirect-to-https
256 AllowedMethods:
257 - GET
258 - HEAD
259 CachedMethods:
260 - GET
261 - HEAD
262 CachePolicyId: !Ref ScriptCachePolicy
263 Compress: true
264 FunctionAssociations:
265 - EventType: viewer-request
266 FunctionARN: !GetAtt BlockUnmatchedPathsFunction.FunctionARN
267 
268 # Cache behaviors for specific paths
269 CacheBehaviors:
270 # Script behavior
271 - PathPattern: /js/script.js
272 TargetOriginId: plausible-origin
273 ViewerProtocolPolicy: redirect-to-https
274 AllowedMethods:
275 - GET
276 - HEAD
277 CachedMethods:
278 - GET
279 - HEAD
280 CachePolicyId: !Ref ScriptCachePolicy
281 OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy
282 ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy
283 Compress: true
284 FunctionAssociations:
285 - EventType: viewer-request
286 FunctionARN: !GetAtt ScriptRewriteFunction.FunctionARN
287 
288 # API event behavior
289 - PathPattern: /api/event
290 TargetOriginId: plausible-origin
291 ViewerProtocolPolicy: redirect-to-https
292 AllowedMethods:
293 - GET
294 - HEAD
295 - OPTIONS
296 - PUT
297 - POST
298 - PATCH
299 - DELETE
300 CachedMethods:
301 - GET
302 - HEAD
303 CachePolicyId: !Ref ApiCachePolicy
304 OriginRequestPolicyId: !Ref PlausibleOriginRequestPolicy
305 ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy
306 Compress: true
307 LambdaFunctionAssociations:
308 - EventType: origin-request
309 LambdaFunctionARN: !Ref ApiEventFunctionVersion
310 IncludeBody: true
311 
312 # Route53 DNS Record (A record with alias to CloudFront)
313 DNSRecordA:
314 Type: AWS::Route53::RecordSet
315 Properties:
316 HostedZoneId: !Ref HostedZoneId
317 Name: !Sub '${AnalyticsSubdomain}.${DomainName}'
318 Type: A
319 AliasTarget:
320 DNSName: !GetAtt PlausibleDistribution.DomainName
321 HostedZoneId: Z2FDTNDATAQYW2 # CloudFront's hosted zone ID (constant for all distributions)
322 EvaluateTargetHealth: false
323 
324 # Route53 DNS Record (AAAA record for IPv6)
325 DNSRecordAAAA:
326 Type: AWS::Route53::RecordSet
327 Properties:
328 HostedZoneId: !Ref HostedZoneId
329 Name: !Sub '${AnalyticsSubdomain}.${DomainName}'
330 Type: AAAA
331 AliasTarget:
332 DNSName: !GetAtt PlausibleDistribution.DomainName
333 HostedZoneId: Z2FDTNDATAQYW2
334 EvaluateTargetHealth: false
335 
336Outputs:
337 DistributionId:
338 Description: CloudFront Distribution ID
339 Value: !Ref PlausibleDistribution
340 Export:
341 Name: !Sub '${AWS::StackName}-DistributionId'
342 
343 DistributionDomainName:
344 Description: CloudFront Distribution Domain Name
345 Value: !GetAtt PlausibleDistribution.DomainName
346 Export:
347 Name: !Sub '${AWS::StackName}-DistributionDomainName'
348 
349 ScriptSnippet:
350 Description: HTML snippet to add to your website
351 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>
🤖
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