Set up PostHog on a website: events, proxy and privacy
PostHog is a product analytics platform: events, funnels, session replay, feature flags and more, in one place. Adding the snippet takes a minute. Getting data you can trust takes a little planning. This guide covers both:
outline {
- a short event plan;
- the install;
- identifying users;
- trimming autocapture;
- sending data through your own domain;
- masking replays;
- checking that it works.
It's written for a marketing site or small web app. This site runs PostHog through a first-party /ingest proxy too, with ui_host: 'https://us.posthog.com' and defaults: '2026-08-30'. The snippets below use person_profiles: 'identified_only' and trimmed autocapture; this site itself still uses person_profiles: 'always' and full autocapture.
What you need
- A PostHog account (Cloud US or EU), or a self-hosted instance. More on self-hosting at the end.
- Your project API key and host, from Project settings.
- Access to your site's HTML or front-end code.
- A privacy policy you can update. Analytics and replay may need consent where you operate. That's a legal question, and this guide doesn't answer it.
Step 1: Write a short event plan first
Autocapture records clicks and pageviews on its own. The events you actually report on should be named on purpose. Start with five to ten:
| Event name | When it fires | Properties |
|---|---|---|
contact_form_submitted | Contact form accepted by the server | topic |
checklist_requested | Lead form for the checklist submitted | source_page |
guide_cta_clicked | Contact link at the end of a guide clicked | guide_slug |
signup_completed | Account created (apps) | plan |
Use one naming style (object_action, lowercase, underscores), and keep the list in your repo. Never put personal data (emails, names, message text) in event properties.
Check it worked: each event on the list answers a question someone will actually ask, for example "which guides lead to contact requests?"
Step 2: Install the snippet
Paste the snippet from Project settings > Snippet into the <head> of every page, or install the package:
npm install posthog-jsimport posthog from 'posthog-js'
posthog.init('<project_api_key>', {
api_host: 'https://us.i.posthog.com',
defaults: '2026-08-30',
person_profiles: 'identified_only',
})defaultspins a dated set of default behaviours, so a library update doesn't silently change what's captured. Use the date PostHog's docs show for the current version.person_profiles: 'identified_only'creates person profiles only for users you identify (step 4). Anonymous visitors still send events, which are cheaper to process. Use'always'if you need profiles for anonymous visitors too.
Check it worked: open your site, then go to Activity in PostHog. A $pageview event from your browser appears within a minute.
Step 3: Send your named events
// After the server confirms the form was accepted
posthog.capture('contact_form_submitted', { topic: 'Automation and n8n' })For simple links, you don't need JavaScript. Autocapture records the click, and data-ph-capture-attribute-* attributes add properties to it:
<a href="/contact-me?subject=Automation%20and%20n8n"
data-ph-capture-attribute-guide-slug="self-host-n8n">Contact me</a>The click event then carries a guide-slug property you can filter on.
Check it worked: trigger each event once, and confirm it appears in Activity with the right properties.
Step 4: Identify users, carefully
On a site with logins, call identify when a user signs in, using your own stable user ID, not their email:
posthog.identify('user_8f3a2c', { plan: 'pro' })And on logout:
posthog.reset()reset() clears the identity on that browser, so the next person on a shared computer doesn't get merged into the last one. On a marketing site with no logins, you might never call identify. That's fine with identified_only.
Check it worked: after signing in, your test user appears under People with the ID you passed.
Step 5: Trim autocapture
Autocapture is useful, but it can record clicks you don't care about, and text you shouldn't keep. Narrow it:
posthog.init('<project_api_key>', {
api_host: 'https://us.i.posthog.com',
defaults: '2026-08-30',
person_profiles: 'identified_only',
autocapture: {
dom_event_allowlist: ['click', 'submit'],
element_allowlist: ['a', 'button', 'form'],
css_selector_ignorelist: ['.ph-no-autocapture', '[data-ph-no-autocapture]'],
},
})Add the ph-no-autocapture class to any element whose clicks or text you never want captured. Other options include url_allowlist and url_ignorelist (limit autocapture to some pages) and element_attribute_ignorelist.
autocapture: false turns off click capture, but pageviews keep coming. To stop those too, also set capture_pageview: false and capture_pageleave: false.
Check it worked: click an element you excluded. No $autocapture event for it appears in Activity.
Step 6: Send data through your own domain
Ad blockers often block requests to analytics domains. A reverse proxy on your own domain keeps events flowing, and the browser only ever talks to your site. Pick a path that's unique to you, such as /e-relay. PostHog's docs warn against subdomain words like analytics, tracking, telemetry, posthog, or ph. Path prefixes are a different choice; still avoid obvious tracker names. PostHog's official Caddy example uses a subdomain with handle, not path prefixes.
With Caddy and US Cloud, adapted from PostHog's Caddy proxy guide:
example.com {
handle_path /e-relay/static* {
rewrite * /static/{path}
reverse_proxy https://us-assets.i.posthog.com:443 {
header_up Host us-assets.i.posthog.com
header_up -Cookie
header_up -Authorization
header_down -Access-Control-Allow-Origin
}
}
handle_path /e-relay/array* {
rewrite * /array/{path}
reverse_proxy https://us-assets.i.posthog.com:443 {
header_up Host us-assets.i.posthog.com
header_up -Cookie
header_up -Authorization
header_down -Access-Control-Allow-Origin
}
}
handle_path /e-relay* {
rewrite * {path}
@non_bearer {
not header Authorization "Bearer phc_*"
not header Authorization "Bearer phs_*"
not header Authorization "Bearer phx_*"
}
request_header @non_bearer -Authorization
reverse_proxy https://us.i.posthog.com:443 {
header_up Host us.i.posthog.com
header_up -Cookie
header_down -Access-Control-Allow-Origin
}
}
# ... the rest of your site
}What it does:
header_up Hosttells PostHog which service the request is for. Without it, you get 401 errors.- Cookies, and any
Authorizationheader that isn't a PostHog key, are stripped, so your own site's session data never goes to PostHog. - The
/arrayroute carries remote config, so feature flags and replay settings keep updating.
Then point the library at it:
posthog.init('<project_api_key>', {
api_host: 'https://example.com/e-relay',
ui_host: 'https://us.posthog.com',
defaults: '2026-08-30',
person_profiles: 'identified_only',
})For EU Cloud, use the eu. hostnames throughout. PostHog also documents Cloudflare Workers, Nginx, Next.js and a managed proxy option. Copy their config for your platform rather than improvising, because the paths matter.
Check it worked: in the browser's network tab, events go to https://example.com/e-relay/... and return 200.
Step 7: Mask session replays
If you turn on session replay (Project settings > Session replay), decide what it may record. PostHog already masks input elements by default, because they often contain sensitive text. maskAllInputs: true is the explicit form of that default:
posthog.init('<project_api_key>', {
// ...
session_recording: {
maskAllInputs: true,
},
})Add the ph-no-capture class to any element that shouldn't appear in a replay at all, such as an account details panel. Then watch a few recordings of your own visits and look for anything personal that slipped through.
Check it worked: in a recording of you filling in a form, the fields show as masked.
Step 8: Cloud or self-hosted?
PostHog Cloud is the default, and the simplest. PostHog also offers a self-hosted "hobby" deployment:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/deploy-hobby)"The self-host docs ask for an Ubuntu VM with about 4 vCPU, 16 GB of RAM and more than 30 GB of disk, plus a domain with an A record for its Let's Encrypt certificate. PostHog says plainly that self-hosted instances are unsupported, come with no guarantees against data loss, and only get free-plan features. They also recommend always running the latest image. Read that page before you choose self-hosting for anything important.
Troubleshooting
- No events in Activity: check the browser console for errors, confirm the API key, and check whether an ad blocker is blocking the request (step 6 fixes that).
- Every visitor appears as a person:
person_profilesis set to'always', oridentifyis being called for anonymous visitors. - Two users merged into one person:
reset()isn't called on logout. - Proxy returns 404 for `/static` or `/array` paths: a route is missing. Caddy sorts same-named directives by matcher specificity (longer paths first), so you do not need to put the
/e-relay/staticblocks above the catch-all by hand. - Proxy returns 401: the
Hostheader isn't being rewritten, or the region in the hostnames doesn't match your project.
References
- JavaScript library: https://posthog.com/docs/libraries/js
- Autocapture configuration: https://posthog.com/docs/product-analytics/autocapture
- Identifying users: https://posthog.com/docs/product-analytics/identify
- Reverse proxy: https://posthog.com/docs/advanced/proxy
- Session replay privacy controls: https://posthog.com/docs/session-replay/privacy
- Self-hosting: https://posthog.com/docs/self-host