1<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8" data-next-head=""/><meta name="viewport" content="width=device-width" data-next-head=""/><link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" data-next-head=""/><link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" data-next-head=""/><link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" data-next-head=""/><link rel="manifest" href="/favicon/site.webmanifest" data-next-head=""/><link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#000000" data-next-head=""/><link rel="shortcut icon" href="/favicon/favicon.ico" data-next-head=""/><meta name="msapplication-TileColor" content="#000000" data-next-head=""/><meta name="msapplication-config" content="/favicon/browserconfig.xml" data-next-head=""/><meta name="theme-color" content="#000" data-next-head=""/><link rel="alternate" type="application/rss+xml" href="/feed.xml" data-next-head=""/><link rel="authorization_endpoint" href="https://indieauth.com/auth" data-next-head=""/><link rel="token_endpoint" href="https://indieauth.com/token" data-next-head=""/><meta name="description" content="A software development blog by David Viramontes" data-next-head=""/><title data-next-head="">Self-Hosted Analytics with Plausible and exe.dev | dviramontes.com</title><meta property="og:image" content="https://www.dviramontes.com/assets/blog/self-hosted-analytics/plausible-ce.png" data-next-head=""/>
1<script> 2 (function () { 3 try { 4 var storedTheme = localStorage.getItem('theme') 5 var theme = 6 storedTheme === 'light' || storedTheme === 'dark' 7 ? storedTheme 8 : window.matchMedia('(prefers-color-scheme: dark)').matches 9 ? 'dark' 10 : 'light' 11 12 document.documentElement.classList.toggle('dark', theme === 'dark') 13 document.documentElement.style.colorScheme = theme 14 } catch (error) {} 15 })() 16</script>
16<script async="" src="https://dotcom-analytics.exe.xyz/js/pa-gfgiDC091HW63G4-E4RvG.js"></script>
16<script> 17 window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}}; 18 plausible.init() 19</script>
19<link rel="preload" href="/_next/static/css/cc03b713235b7d24.css" as="style"/><link rel="stylesheet" href="/_next/static/css/cc03b713235b7d24.css" data-n-g=""/><link rel="preload" href="/_next/static/css/abd1c60e215af921.css" as="style"/><link rel="stylesheet" href="/_next/static/css/abd1c60e215af921.css" data-n-p=""/><noscript data-n-css=""></noscript>
19<script defer="" nomodule="" src="/_next/static/chunks/polyfills-42372ed130431b0a.js"></script>
19<script src="/_next/static/chunks/webpack-8cac0b4b405cede1.js" defer=""></script>
19<script src="/_next/static/chunks/framework-4b485b8982efe9e2.js" defer=""></script>
19<script src="/_next/static/chunks/main-fcab7384e35fabce.js" defer=""></script>
19<script src="/_next/static/chunks/pages/_app-19412082cb9acf9c.js" defer=""></script>
19<script src="/_next/static/chunks/695-e3bd0465020e9d38.js" defer=""></script>
19<script src="/_next/static/chunks/587-ad9c1fa28b16816e.js" defer=""></script>
19<script src="/_next/static/chunks/108-30a8470580f27fa6.js" defer=""></script>
19<script src="/_next/static/chunks/352-6e019b758516dcaf.js" defer=""></script>
19<script src="/_next/static/chunks/pages/posts/%5Bslug%5D-fa09e622644fb1ff.js" defer=""></script>
19<script src="/_next/static/4YgIulaonMc960M2xK8tQ/_buildManifest.js" defer=""></script>
19<script src="/_next/static/4YgIulaonMc960M2xK8tQ/_ssgManifest.js" defer=""></script>
19</head><body><div id="__next"><div class="min-h-screen bg-stone-200 text-stone-950 transition-colors duration-300 dark:bg-[#0f1115] dark:text-stone-100"><div class="fixed right-4 top-4 z-50 md:right-6 md:top-6"><button type="button" aria-label="Switch to dark mode" class="inline-flex items-center gap-2 rounded-full border border-stone-800/90 bg-stone-950/90 px-3 py-2 text-sm font-semibold text-stone-100 shadow-sm backdrop-blur transition-colors hover:border-stone-700 hover:text-white dark:border-stone-300/80 dark:text-stone-800 dark:hover:border-stone-400 dark:hover:text-stone-950"><span aria-hidden="true">ð</span></button></div><main><div class="container mx-auto px-5 max-w-5xl"><h2 class="text-2xl md:text-4xl font-bold tracking-tight md:tracking-tighter leading-tight mb-20 mt-8"><a href="/"><span class="hover:underline">Blog</span></a></h2><article class="mb-32"><h1 class="font-bold tracking-tighter leading-tight md:leading-none mb-12 text-center md:text-left text-5xl md:text-7xl lg:text-8xl">Self-Hosted Analytics with Plausible and exe.dev</h1><div class="hidden md:block md:mb-12"><div class="flex items-center"><img alt="David Viramontes" loading="lazy" width="48" height="48" decoding="async" data-nimg="1" class="rounded-full mr-4" style="color:transparent" srcSet="/_next/image?url=%2Fassets%2Fblog%2Fauthors%2Fdavm.png&w=48&q=75 1x, /_next/image?url=%2Fassets%2Fblog%2Fauthors%2Fdavm.png&w=96&q=75 2x" src="/_next/image?url=%2Fassets%2Fblog%2Fauthors%2Fdavm.png&w=96&q=75"/><div class="text-xl font-bold">David Viramontes</div></div></div><div class="mb-8 md:mb-16 sm:mx-0"><div class="sm:mx-0"><img alt="Cover Image for Self-Hosted Analytics with Plausible and exe.dev" loading="lazy" width="1300" height="630" decoding="async" data-nimg="1" class="shadow-sm" style="color:transparent" srcSet="/_next/image?url=%2Fassets%2Fblog%2Fself-hosted-analytics%2Fplausible-ce.png&w=1920&q=75 1x, /_next/image?url=%2Fassets%2Fblog%2Fself-hosted-analytics%2Fplausible-ce.png&w=3840&q=75 2x" src="/_next/image?url=%2Fassets%2Fblog%2Fself-hosted-analytics%2Fplausible-ce.png&w=3840&q=75"/></div></div><div class="max-w-2xl mx-auto"><div class="block md:hidden mb-6"><div class="flex items-center"><img alt="David Viramontes" loading="lazy" width="48" height="48" decoding="async" data-nimg="1" class="rounded-full mr-4" style="color:transparent" srcSet="/_next/image?url=%2Fassets%2Fblog%2Fauthors%2Fdavm.png&w=48&q=75 1x, /_next/image?url=%2Fassets%2Fblog%2Fauthors%2Fdavm.png&w=96&q=75 2x" src="/_next/image?url=%2Fassets%2Fblog%2Fauthors%2Fdavm.png&w=96&q=75"/><div class="text-xl font-bold">David Viramontes</div></div></div><div class="flex"><div class="mb-6 text-lg"><time class="inline-flex items-center rounded-full bg-stone-200 px-2 py-0.5 text-sm font-medium text-stone-800 dark:bg-stone-800 dark:text-stone-200" dateTime="2026-04-26T18:00:00.000Z"><svg class="w-3 h-3 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>2026-04-26</time></div></div></div><div class="max-w-2xl mx-auto"><div class="markdown-styles_markdown__HwUWZ !text-stone-800 dark:!text-stone-200 dark:[&_h1]:!text-stone-100 dark:[&_h2]:!text-stone-100 dark:[&_h3]:!text-stone-100"><p>I've been wanting to collect basic analytics on this blog and the other small 20sites I've deployed for personal use. Nothing fancy â just enough to know what 21people read and where they come from. The two requirements: I don't want to 22hand my visitors over to an ad network, and I don't want to babysit 23infrastructure.</p> 24<p>This is a good use case for <a href="https://exe.dev/">exe.dev</a> â paired with 25<a href="https://github.com/plausible/community-edition">Plausible Community Edition</a>, 26it took less than an hour to put together.</p> 27<h2>Why not Google Analytics?</h2> 28<p>The short answer: when something online is free, 29<a href="https://plausible.io/blog/remove-google-analytics">you are usually the product</a>. 30Google Analytics is free because the data it collects is valuable to Google. 31For a personal blog with a few hundred visitors a month, that trade-off makes 32no sense. Plausible is open source, GDPR-friendly, doesn't use cookies, and 33the dashboard tells me exactly what I want to know without a 30-tab 34configuration UI. If you're curious what that dashboard looks like, Plausible 35hosts a public one for their own site at 36<a href="https://plausible.io/plausible.io">plausible.io/plausible.io</a>.</p> 37<h2>Why exe.dev?</h2> 38<p><a href="https://exe.dev/">exe.dev</a> gives me a small VM with an HTTPS proxy 39that handles TLS termination and DNS for me. I don't have to provision a 40certificate, point a domain, or run Caddy/nginx in front of anything. The 41proxy hands me an <code>*.exe.xyz</code> hostname and that's the public address of the 42service. For a side-project analytics box, that's the entire ops story.</p> 43<p>The canonical setup docs live in the 44<a href="https://github.com/plausible/community-edition">plausible/community-edition</a> 45repo â the steps below are the exe.dev-flavored version of those, with the 46proxy and TLS bits filled in.</p> 47<h2>Prerequisites</h2> 48<ul> 49<li>Docker and Docker Compose installed on the VM</li> 50<li>SSH key registered with exe.dev (<code>ssh-keygen -t ed25519</code> + <code>ssh exe.dev</code> to register)</li> 51</ul> 52<h2>Step 1: Clone the repository</h2> 53<div class="remark-highlight"><pre class="language-bash"><code class="language-bash"><span class="token function">git</span> clone <span class="token parameter variable">-b</span> v3.2.0 --single-branch https://github.com/plausible/community-edition plausible-ce 54<span class="token builtin class-name">cd</span> plausible-ce 55</code></pre></div> 56<h2>Step 2: Create the environment file</h2> 57<div class="remark-highlight"><pre class="language-bash"><code class="language-bash"><span class="token function">touch</span> .env 58<span class="token builtin class-name">echo</span> <span class="token string">"BASE_URL=https://example.exe.xyz"</span> <span class="token operator">>></span> .env 59<span class="token builtin class-name">echo</span> <span class="token string">"SECRET_KEY_BASE=<span class="token variable"><span class="token variable">$(</span>openssl rand <span class="token parameter variable">-base64</span> <span class="token number">48</span><span class="token variable">)</span></span>"</span> <span class="token operator">>></span> .env 60</code></pre></div> 61<p>A couple of things that are easy to get wrong here:</p> 62<ul> 63<li><code>BASE_URL</code> must match the exe.dev proxy hostname, not <code>localhost</code>. Plausible's 64origin check will reject requests otherwise.</li> 65<li><code>SECRET_KEY_BASE</code> must be at least 64 bytes â the <code>-base64 48</code> flag gives you 64.</li> 66</ul> 67<h2>Step 3: Expose port 8000</h2> 68<p>Create a <code>compose.override.yml</code> so Docker maps the Plausible HTTP port out to 69the VM:</p> 70<div class="remark-highlight"><pre class="language-yaml"><code class="language-yaml"><span class="token key atrule">services</span><span class="token punctuation">:</span> 71 <span class="token key atrule">plausible</span><span class="token punctuation">:</span> 72 <span class="token key atrule">ports</span><span class="token punctuation">:</span>
73 <span class="token punctuation">-</span> 8000<span class="token punctuation">:</span><span class="token number">8000</span> 74</code></pre></div> 75<p>Plausible listens on 8000 by default, and the exe.dev proxy will forward 76HTTPS traffic to it.</p> 77<h2>Step 4: Configure the exe.dev proxy</h2> 78<p>Point the proxy at port 8000:</p> 79<div class="remark-highlight"><pre class="language-bash"><code class="language-bash"><span class="token function">ssh</span> exe.dev share port example <span class="token number">8000</span> 80</code></pre></div> 81<p>By default, the proxy is private â visitors get bounced to an exe.dev login 82page. That's the right default for most things, but it's wrong here: the 83tracker script and event endpoint need to be reachable by unauthenticated 84browsers on the sites I'm tracking. So the proxy has to be public:</p> 85<div class="remark-highlight"><pre class="language-bash"><code class="language-bash"><span class="token function">ssh</span> exe.dev share set-public example 86</code></pre></div> 87<p>If you skip this step, requests to <code>/js/script.js</code> and <code>/api/event</code> get 88redirected to the login page and no analytics data is collected. Plausible 89has its own dashboard auth, so the data behind the dashboard is still 90protected even with the proxy public.</p> 91<h2>Step 5: Start the services</h2> 92<div class="remark-highlight"><pre class="language-bash"><code class="language-bash"><span class="token function">docker</span> compose up <span class="token parameter variable">-d</span> 93</code></pre></div> 94<p>This starts three containers:</p> 95<ul> 96<li><strong><code>plausible</code></strong> (<code>ghcr.io/plausible/community-edition:v3.2.0</code>) â the web app 97and analytics ingestion endpoint.</li> 98<li><strong><code>plausible_db</code></strong> (<code>postgres:16-alpine</code>) â stores users, sites, and settings.</li> 99<li><strong><code>plausible_events_db</code></strong> (<code>clickhouse/clickhouse-server:24.12-alpine</code>) â 100stores the analytics events themselves.</li> 101</ul> 102<p>Migrations run automatically on first start.</p> 103<h2>Step 6: Create your account</h2> 104<p>Visit <code>https://example.exe.xyz/register</code> and create the first user. 105That account becomes the admin.</p> 106<h2>Adding the tracker to your site</h2> 107<p>Once Plausible is up, registering a site in the dashboard gives you a 108snippet. Drop it into the <code><head></code> of any page you want to track â a static 109HTML file, a templated page, or whatever your framework uses to add tags 110there:</p> 111<div class="remark-highlight"><pre class="language-html"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation"><</span>script</span> <span class="token attr-name">defer</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>https://example.exe.xyz/js/script.js<span class="token punctuation">"</span></span><span class="token punctuation">></span></span><span class="token script"></span><span class="token tag"><span class="token tag"><span class="token punctuation"></</span>script</span><span class="token punctuation">></span></span> 112<span class="token tag"><span class="token tag"><span class="token punctuation"><</span>script</span><span class="token punctuation">></span></span><span class="token script"><span class="token language-javascript"> 113 <span class="token punctuation">;</span><span class="token punctuation">(</span><span class="token dom variable">window</span><span class="token punctuation">.</span><span class="token property-access">plausible</span> <span class="token operator">=</span> 114 <span class="token dom variable">window</span><span class="token punctuation">.</span><span class="token property-access">plausible</span> <span class="token operator">||</span> 115 <span class="token keyword">function</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
116 <span class="token punctuation">;</span><span class="token punctuation">(</span>plausible<span class="token punctuation">.</span><span class="token property-access">q</span> <span class="token operator">=</span> plausible<span class="token punctuation">.</span><span class="token property-access">q</span> <span class="token operator">||</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">push</span><span class="token punctuation">(</span>arguments<span class="token punctuation">)</span> 117 <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">,</span> 118 <span class="token punctuation">(</span>plausible<span class="token punctuation">.</span><span class="token property-access">init</span> <span class="token operator">=</span> 119 plausible<span class="token punctuation">.</span><span class="token property-access">init</span> <span class="token operator">||</span> 120 <span class="token keyword">function</span> <span class="token punctuation">(</span><span class="token parameter">i</span><span class="token punctuation">)</span> <span class="token punctuation">{</span> 121 plausible<span class="token punctuation">.</span><span class="token property-access">o</span> <span class="token operator">=</span> i <span class="token operator">||</span> <span class="token punctuation">{</span><span class="token punctuation">}</span> 122 <span class="token punctuation">}</span><span class="token punctuation">)</span> 123 plausible<span class="token punctuation">.</span><span class="token method function property-access">init</span><span class="token punctuation">(</span><span class="token punctuation">)</span> 124</span></span><span class="token tag"><span class="token tag"><span class="token punctuation"></</span>script</span><span class="token punctuation">></span></span> 125</code></pre></div> 126<p>That's it. Pageviews start showing up in the dashboard within a few seconds.</p> 127<p>If you'd rather pull in a framework-specific wrapper, there are 128<a href="https://www.npmjs.com/search?q=plausible">a handful of npm packages</a> for 129React, Next.js, Vue, Svelte, and friends. They mostly wrap the same snippet 130above with conveniences like route-change tracking â pick whichever fits 131your stack.</p> 132<h2>Useful commands</h2> 133<div class="remark-highlight"><pre class="language-bash"><code class="language-bash"><span class="token comment"># Check service status</span> 134<span class="token function">docker</span> compose <span class="token function">ps</span> 135 136<span class="token comment"># View Plausible logs</span> 137<span class="token function">docker</span> compose logs plausible <span class="token parameter variable">--tail</span> <span class="token number">50</span> 138 139<span class="token comment"># Restart Plausible (e.g. after changing .env)</span> 140<span class="token function">docker</span> compose up <span class="token parameter variable">-d</span> --force-recreate plausible 141 142<span class="token comment"># Stop everything</span> 143<span class="token function">docker</span> compose down 144 145<span class="token comment"># Stop and remove all data</span> 146<span class="token function">docker</span> compose down <span class="token parameter variable">-v</span> 147</code></pre></div> 148<h2>Notes and gotchas</h2> 149<ul> 150<li><strong>Email</strong>: SMTP isn't configured, so Plausible can't send verification or 151report emails. The app still works without it â see the 152<a href="https://github.com/plausible/community-edition/wiki/configuration#email">configuration wiki</a> 153if you need it.</li> 154<li><strong>TLS</strong>: Handled entirely by the exe.dev proxy. No certificate config on the VM.</li> 155<li><strong>Data persistence</strong>: All data lives in Docker volumes (<code>db-data</code>, <code>event-data</code>, 156<code>event-logs</code>, <code>plausible-data</code>). Back those up if the data matters to you.</li> 157<li><strong>Public proxy means public dashboard login</strong>: Anyone on the internet can
158reach the login page and try to send events. Plausible's own auth is what 159keeps the data safe. If that's not good enough, put a reverse proxy in 160front that only allows public access to <code>/js/*</code> and <code>/api/event</code>, and 161requires auth for everything else.</li> 162</ul> 163<h2>Wrap-up</h2> 164<p>This is the kind of project exe.dev makes really easy to deploy. No DNS, no 165certificates, no nginx config â just a VM, a Docker compose file, and a 166hostname that works. I get analytics I actually own, and my visitors don't 167get tracked across the web, respecting their privacy.</p> 168<hr> 169<p><em>Cover image generated using Nano Banana 2.</em></p> 170</div></div><hr class="mx-auto mt-12 w-1/3 border-t border-dashed border-brand"/><div class="pt-4 mb-4 mt-4 "><div class="flex items-center justify-center space-x-3 text-sm text-stone-500 dark:text-stone-400"><a href="https://xn--sr8hvo.ws" class="font-bold text-stone-700 transition-colors hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-white" title="Learn more about this webring">@</a><span class="text-stone-400 dark:text-stone-500">â¢</span><span class="font-semibold">IndieWeb Ring</span><span class="text-stone-400 dark:text-stone-500">â¢</span><a href="https://xn--sr8hvo.ws/random" class="font-bold text-stone-700 transition-colors hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-white" title="Visit a random site in the ring">ð²</a></div></div></article></div></main></div><footer class="border-t border-stone-300/70 bg-[#171717] text-white dark:border-stone-800 dark:bg-black"><div class="container mx-auto px-5 max-w-5xl"><div class="py-10 flex flex-col lg:flex-row items-center"><h3 class="text-4xl lg:text-[2.5rem] font-bold tracking-tighter leading-tight text-center lg:text-left mb-10 lg:mb-0 lg:pr-4 lg:w-1/2"><a href="/"><span class="hover:underline">~/</span></a></h3><div class="flex flex-col lg:flex-row justify-center items-center lg:pl-4 lg:w-1/3"><div class="grid grid-cols-3 gap-y-6 gap-x-2 lg:flex lg:flex-row w-full lg:space-x-2 items-center px-4 lg:px-0"><div class="w-full lg:w-auto my-2 lg:my-0 text-center"><a href="/about"><span class="border border-white bg-stone-950 px-8 py-3 font-bold text-white transition-colors duration-200 hover:bg-stone-800 lg:px-8">About</span></a></div><div class="w-full lg:w-auto my-2 lg:my-0 text-center"><a href="/til"><span class="font-bold hover:underline">TIL</span></a></div><div class="w-full lg:w-auto my-2 lg:my-0 text-center"><a href="/"><span class="font-bold hover:underline">Posts</span></a></div><div class="w-full lg:w-auto my-2 lg:my-0 text-center"><a href="/bookshelf"><span class="font-bold hover:underline">Bookshelf</span></a></div><div class="w-full lg:w-auto my-2 lg:my-0 text-center"><a href="/tools"><span class="font-bold hover:underline">Tools</span></a></div><div class="w-full lg:w-auto my-2 lg:my-0 text-center"><a href="/feed.xml"><span class="font-bold hover:underline">RSS</span></a></div></div></div></div></div></footer></div>
170<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"post":{"title":"Self-Hosted Analytics with Plausible and exe.dev","date":"2026-04-26T18:00:00.000Z","slug":"self-hosted-analytics","author":{"name":"David Viramontes","picture":"/assets/blog/authors/davm.png"},"content":"\u003cp\u003eI've been wanting to collect basic analytics on this blog and the other small\nsites I've deployed for personal use. Nothing fancy â just enough to know what\npeople read and where they come from. The two requirements: I don't want to\nhand my visitors over to an ad network, and I don't want to babysit\ninfrastructure.\u003c/p\u003e\n\u003cp\u003eThis is a good use case for \u003ca href=\"https://exe.dev/\"\u003eexe.dev\u003c/a\u003e â paired with\n\u003ca href=\"https://github.com/plausible/community-edition\"\u003ePlausible Community Edition\u003c/a\u003e,\nit took less than an hour to put together.\u003c/p\u003e\n\u003ch2\u003eWhy not Google Analytics?\u003c/h2\u003e\n\u003cp\u003eThe short answer: when something online is free,\n\u003ca href=\"https://plausible.io/blog/remove-google-analytics\"\u003eyou are usually the product\u003c/a\u003e.\nGoogle Analytics is free because the data it collects is valuable to Google.\nFor a personal blog with a few hundred visitors a month, that trade-off makes\nno sense. Plausible is open source, GDPR-friendly, doesn't use cookies, and\nthe dashboard tells me exactly what I want to know without a 30-tab\nconfiguration UI. If you're curious what that dashboard looks like, Plausible\nhosts a public one for their own site at\n\u003ca href=\"https://plausible.io/plausible.io\"\u003eplausible.io/plausible.io\u003c/a\u003e.\u003c/p\u003e\n\u003ch2\u003eWhy exe.dev?\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://exe.dev/\"\u003eexe.dev\u003c/a\u003e gives me a small VM with an HTTPS proxy\nthat handles TLS termination and DNS for me. I don't have to provision a\ncertificate, point a domain, or run Caddy/nginx in front of anything. The\nproxy hands me an \u003ccode\u003e*.exe.xyz\u003c/code\u003e hostname and that's the public address of the\nservice. For a side-project analytics box, that's the entire ops story.\u003c/p\u003e\n\u003cp\u003eThe canonical setup docs live in the\n\u003ca href=\"https://github.com/plausible/community-edition\"\u003eplausible/community-edition\u003c/a\u003e\nrepo â the steps below are the exe.dev-flavored version of those, with the\nproxy and TLS bits filled in.\u003c/p\u003e\n\u003ch2\u003ePrerequisites\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eDocker and Docker Compose installed on the VM\u003c/li\u003e\n\u003cli\u003eSSH key registered with exe.dev (\u003ccode\u003essh-keygen -t ed25519\u003c/code\u003e + \u003ccode\u003essh exe.dev\u003c/code\u003e to register)\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2\u003eStep 1: Clone the repository\u003c/h2\u003e\n\u003cdiv class=\"remark-highlight\"\u003e\u003cpre class=\"language-bash\"\u003e\u003ccode class=\"language-bash\"\u003e\u003cspan class=\"token function\"\u003egit\u003c/span\u003e clone \u003cspan class=\"token parameter variable\"\u003e-b\u003c/span\u003e v3.2.0 --single-branch https://github.com/plausible/community-edition plausible-ce\n\u003cspan class=\"token builtin class-name\"\u003ecd\u003c/span\u003e plausible-ce\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003ch2\u003eStep 2: Create the environment file\u003c/h2\u003e\n\u003cdiv class=\"remark-highlight\"\u003e\u003cpre class=\"language-bash\"\u003e\u003ccode class=\"language-bash\"\u003e\u003cspan class=\"token function\"\u003etouch\u003c/span\u003e .env\n\u003cspan class=\"token builtin class-name\"\u003eecho\u003c/span\u003e \u003cspan class=\"token string\"\u003e\"BASE_URL=https://example.exe.xyz\"\u003c/span\u003e \u003cspan class=\"token operator\"\u003e\u003e\u003e\u003c/span\u003e .env\n\u003cspan class=\"token builtin class-name\"\u003eecho\u003c/span\u003e \u003cspan class=\"token string\"\u003e\"SECRET_KEY_BASE=\u003cspan class=\"token variable\"\u003e\u003cspan class=\"token variable\"\u003e$(\u003c/span\u003eopenssl rand \u003cspan class=\"token parameter variable\"\u003e-base64\u003c/span\u003e \u003cspan class=\"token number\"\u003e48\u003c/span\u003e\u003cspan class=\"token variable\"\u003e)\u003c/span\u003e\u003c/span\u003e\"\u003c/span\u003e \u003cspan class=\"token operator\"\u003e\u003e\u003e\u003c/span\u003e .env\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eA couple of things that are easy to get wrong here:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003eBASE_URL\u003c/code\u003e must match the exe.dev proxy hostname, not \u003ccode\u003elocalhost\u003c/code\u003e. Plausible's\norigin check will reject requests otherwise.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eSECRET_KEY_BASE\u003c/code\u003e must be at least 64 bytes â the \u003ccode\u003e-base64 48\u003c/code\u003e flag gives you 64.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2\u003eStep 3: Expose port 8000\u003c/h2\u003e\n\u003cp\u003eCreate a \u003ccode\u003ecompose.override.yml\u003c/code\u003e so Docker maps the Plausible HTTP port out to\nthe VM:\u003c/p\u003e\n\u003cdiv class=\"remark-highlight\"\u003e\u003cpre class=\"language-yaml\"\u003e\u003ccode class=\"language-yaml\"\u003e\u003cspan class=\"token key atrule\"\u003eservices\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e:\u003c/span\u003e\n \u003cspan class=\"token key atrule\"\u003eplausible\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e:\u003c/span\u003e\n \u003cspan class=\"token key atrule\"\u003eports\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e:\u003c/span\u003e\n \u003cspan class=\"token punctuation\"\u003e-\u003c/span\u003e 8000\u003cspan class=\"token punctuation\"\u003e:\u003c/span\u003e\u003cspan class=\"token number\"\u003e8000\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003ePlausible listens on 8000 by default, and the exe.dev proxy will forward\nHTTPS traffic to it.\u003c/p\u003e\n\u003ch2\u003eStep 4: Configure the exe.dev proxy\u003c/h2\u003e\n\u003cp\u003ePoint the proxy at port 8000:\u003c/p\u003e\n\u003cdiv class=\"remark-highlight\"\u003e\u003cpre class=\"language-bash\"\u003e\u003ccode class=\"language-bash\"\u003e\u003cspan class=\"token function\"\u003essh\u003c/span\u003e exe.dev share port example \u003cspan class=\"token number\"\u003e8000\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eBy default, the proxy is private â visitors get bounced to an exe.dev login\npage. That's the right default for most things, but it's wrong here: the\ntracker script and event endpoint need to be reachable by unauthenticated\nbrowsers on the sites I'm tracking. So the proxy has to be public:\u003c/p\u003e\n\u003cdiv class=\"remark-highlight\"\u003e\u003cpre class=\"language-bash\"\u003e\u003ccode class=\"language-bash\"\u003e\u003cspan class=\"token function\"\u003essh\u003c/span\u003e exe.dev share set-public example\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eIf you skip this
170step, requests to \u003ccode\u003e/js/script.js\u003c/code\u003e and \u003ccode\u003e/api/event\u003c/code\u003e get\nredirected to the login page and no analytics data is collected. Plausible\nhas its own dashboard auth, so the data behind the dashboard is still\nprotected even with the proxy public.\u003c/p\u003e\n\u003ch2\u003eStep 5: Start the services\u003c/h2\u003e\n\u003cdiv class=\"remark-highlight\"\u003e\u003cpre class=\"language-bash\"\u003e\u003ccode class=\"language-bash\"\u003e\u003cspan class=\"token function\"\u003edocker\u003c/span\u003e compose up \u003cspan class=\"token parameter variable\"\u003e-d\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eThis starts three containers:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eplausible\u003c/code\u003e\u003c/strong\u003e (\u003ccode\u003eghcr.io/plausible/community-edition:v3.2.0\u003c/code\u003e) â the web app\nand analytics ingestion endpoint.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eplausible_db\u003c/code\u003e\u003c/strong\u003e (\u003ccode\u003epostgres:16-alpine\u003c/code\u003e) â stores users, sites, and settings.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eplausible_events_db\u003c/code\u003e\u003c/strong\u003e (\u003ccode\u003eclickhouse/clickhouse-server:24.12-alpine\u003c/code\u003e) â\nstores the analytics events themselves.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eMigrations run automatically on first start.\u003c/p\u003e\n\u003ch2\u003eStep 6: Create your account\u003c/h2\u003e\n\u003cp\u003eVisit \u003ccode\u003ehttps://example.exe.xyz/register\u003c/code\u003e and create the first user.\nThat account becomes the admin.\u003c/p\u003e\n\u003ch2\u003eAdding the tracker to your site\u003c/h2\u003e\n\u003cp\u003eOnce Plausible is up, registering a site in the dashboard gives you a\nsnippet. Drop it into the \u003ccode\u003e\u0026#x3C;head\u003e\u003c/code\u003e of any page you want to track â a static\nHTML file, a templated page, or whatever your framework uses to add tags\nthere:\u003c/p\u003e\n\u003cdiv class=\"remark-highlight\"\u003e\u003cpre class=\"language-html\"\u003e\u003ccode class=\"language-html\"\u003e\u003cspan class=\"token tag\"\u003e\u003cspan class=\"token tag\"\u003e\u003cspan class=\"token punctuation\"\u003e\u0026#x3C;\u003c/span\u003escript\u003c/span\u003e \u003cspan class=\"token attr-name\"\u003edefer\u003c/span\u003e \u003cspan class=\"token attr-name\"\u003esrc\u003c/span\u003e\u003cspan class=\"token attr-value\"\u003e\u003cspan class=\"token punctuation attr-equals\"\u003e=\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e\"\u003c/span\u003ehttps://example.exe.xyz/js/script.js\u003cspan class=\"token punctuation\"\u003e\"\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"token script\"\u003e\u003c/span\u003e\u003cspan class=\"token tag\"\u003e\u003cspan class=\"token tag\"\u003e\u003cspan class=\"token punctuation\"\u003e\u0026#x3C;/\u003c/span\u003escript\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e\u003e\u003c/span\u003e\u003c/span\u003e\n\u003cspan class=\"token tag\"\u003e\u003cspan class=\"token tag\"\u003e\u003cspan class=\"token punctuation\"\u003e\u0026#x3C;\u003c/span\u003escript\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"token script\"\u003e\u003cspan class=\"token language-javascript\"\u003e\n \u003cspan class=\"token punctuation\"\u003e;\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e(\u003c/span\u003e\u003cspan class=\"token dom variable\"\u003ewindow\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e.\u003c/span\u003e\u003cspan class=\"token property-access\"\u003eplausible\u003c/span\u003e \u003cspan class=\"token operator\"\u003e=\u003c/span\u003e\n \u003cspan class=\"token dom variable\"\u003ewindow\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e.\u003c/span\u003e\u003cspan class=\"token property-access\"\u003eplausible\u003c/span\u003e \u003cspan class=\"token operator\"\u003e||\u003c/span\u003e\n \u003cspan class=\"token keyword\"\u003efunction\u003c/span\u003e \u003cspan class=\"token punctuation\"\u003e(\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e)\u003c/span\u003e \u003cspan class=\"token punctuation\"\u003e{\u003c/span\u003e\n \u003cspan class=\"token punctuation\"\u003e;\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e(\u003c/span\u003eplausible\u003cspan class=\"token punctuation\"\u003e.\u003c/span\u003e\u003cspan class=\"token property-access\"\u003eq\u003c/span\u003e \u003cspan class=\"token operator\"\u003e=\u003c/span\u003e plausible\u003cspan class=\"token punctuation\"\u003e.\u003c/span\u003e\u003cspan class=\"token property-access\"\u003eq\u003c/span\u003e \u003cspan class=\"token operator\"\u003e||\u003c/span\u003e \u003cspan class=\"token punctuation\"\u003e[\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e]\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e)\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e.\u003c/span\u003e\u003cspan class=\"token method function property-access\"\u003epush\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e(\u003c/span\u003earguments\u003cspan class=\"token punctuation\"\u003e)\u003c/span\u003e\n \u003cspan class=\"token punctuation\"\u003e}\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e)\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e,\u003c/span\u003e\n \u003cspan class=\"token punctuation\"\u003e(\u003c/span\u003eplausible\u003cspan class=\"token punctuation\"\u003e.\u003c/span\u003e\u003cspan class=\"token property-access\"\u003einit\u003c/span\u003e \u003cspan class=\"token operator\"\u003e=\u003c/span\u003e\n plausible\u003cspan class=\"token punctuation\"\u003e.\u003c/span\u003e\u003cspan class=\"token property-access\"\u003einit\u003c/span\u003e \u003cspan class=\"token operator\"\u003e||\u003c/span\u003e\n \u003cspan class=\"token keyword\"\u003efunction\u003c/span\u003e \u003cspan class=\"token punctuation\"\u003e(\u003c/span\u003e\u003cspan class=\"token parameter\"\u003ei\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e)\u003c/span\u003e \u003cspan class=\"token punctuation\"\u003e{\u003c/span\u003e\n plausible\u003cspan class=\"token punctuation\"\u003e.\u003c/span\u003e\u003cspan class=\"token property-access\"\u003eo\u003c/span\u003e \u003cspan class=\"token operator\"\u003e=\u003c/span\u003e i \u003cspan class=\"token operator\"\u003e||\u003c/span\u003e \u003cspan class=\"token punctuation\"\u003e{\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e}\u003c/span\u003e\n \u003cspan class=\"token punctuation\"\u003e}\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e)\u003c/span\u003e\n plausible\u003cspan class=\"token punctuation\"\u003e.\u003c/span\u003e\u003cspan class=\"token method function property-access\"\u003einit\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e(\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"token tag\"\u003e\u003cspan class=\"token tag\"\u003e\u003cspan class=\"token punctuation\"\u003e\u0026#x3C;/\u003c/span\u003escript\u003c/span\u003e\u003cspan class=\"token punctuation\"\u003e\u003e\u003c/span\u003e\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003cp\u003eThat's it. Pageviews start showing up in the dashboard within a few seconds.\u
170003c/p\u003e\n\u003cp\u003eIf you'd rather pull in a framework-specific wrapper, there are\n\u003ca href=\"https://www.npmjs.com/search?q=plausible\"\u003ea handful of npm packages\u003c/a\u003e for\nReact, Next.js, Vue, Svelte, and friends. They mostly wrap the same snippet\nabove with conveniences like route-change tracking â pick whichever fits\nyour stack.\u003c/p\u003e\n\u003ch2\u003eUseful commands\u003c/h2\u003e\n\u003cdiv class=\"remark-highlight\"\u003e\u003cpre class=\"language-bash\"\u003e\u003ccode class=\"language-bash\"\u003e\u003cspan class=\"token comment\"\u003e# Check service status\u003c/span\u003e\n\u003cspan class=\"token function\"\u003edocker\u003c/span\u003e compose \u003cspan class=\"token function\"\u003eps\u003c/span\u003e\n\n\u003cspan class=\"token comment\"\u003e# View Plausible logs\u003c/span\u003e\n\u003cspan class=\"token function\"\u003edocker\u003c/span\u003e compose logs plausible \u003cspan class=\"token parameter variable\"\u003e--tail\u003c/span\u003e \u003cspan class=\"token number\"\u003e50\u003c/span\u003e\n\n\u003cspan class=\"token comment\"\u003e# Restart Plausible (e.g. after changing .env)\u003c/span\u003e\n\u003cspan class=\"token function\"\u003edocker\u003c/span\u003e compose up \u003cspan class=\"token parameter variable\"\u003e-d\u003c/span\u003e --force-recreate plausible\n\n\u003cspan class=\"token comment\"\u003e# Stop everything\u003c/span\u003e\n\u003cspan class=\"token function\"\u003edocker\u003c/span\u003e compose down\n\n\u003cspan class=\"token comment\"\u003e# Stop and remove all data\u003c/span\u003e\n\u003cspan class=\"token function\"\u003edocker\u003c/span\u003e compose down \u003cspan class=\"token parameter variable\"\u003e-v\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003ch2\u003eNotes and gotchas\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eEmail\u003c/strong\u003e: SMTP isn't configured, so Plausible can't send verification or\nreport emails. The app still works without it â see the\n\u003ca href=\"https://github.com/plausible/community-edition/wiki/configuration#email\"\u003econfiguration wiki\u003c/a\u003e\nif you need it.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTLS\u003c/strong\u003e: Handled entirely by the exe.dev proxy. No certificate config on the VM.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eData persistence\u003c/strong\u003e: All data lives in Docker volumes (\u003ccode\u003edb-data\u003c/code\u003e, \u003ccode\u003eevent-data\u003c/code\u003e,\n\u003ccode\u003eevent-logs\u003c/code\u003e, \u003ccode\u003eplausible-data\u003c/code\u003e). Back those up if the data matters to you.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePublic proxy means public dashboard login\u003c/strong\u003e: Anyone on the internet can\nreach the login page and try to send events. Plausible's own auth is what\nkeeps the data safe. If that's not good enough, put a reverse proxy in\nfront that only allows public access to \u003ccode\u003e/js/*\u003c/code\u003e and \u003ccode\u003e/api/event\u003c/code\u003e, and\nrequires auth for everything else.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2\u003eWrap-up\u003c/h2\u003e\n\u003cp\u003eThis is the kind of project exe.dev makes really easy to deploy. No DNS, no\ncertificates, no nginx config â just a VM, a Docker compose file, and a\nhostname that works. I get analytics I actually own, and my visitors don't\nget tracked across the web, respecting their privacy.\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eCover image generated using Nano Banana 2.\u003c/em\u003e\u003c/p\u003e\n","ogImage":{"url":"/assets/blog/self-hosted-analytics/plausible-ce.png"},"coverImage":"/assets/blog/self-hosted-analytics/plausible-ce.png"}},"__N_SSG":true},"page":"/posts/[slug]","query":{"slug":"self-hosted-analytics"},"buildId":"4YgIulaonMc960M2xK8tQ","isFallback":false,"gsp":true,"scriptLoader":[]}</script>
170</body></html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.