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:

author
Zahara Schaefer
published
updated
read
7 min
tested
posthog-js with defaults 2026-08-30, PostHog Cloud US (options from posthog.com/docs, including reverse proxy and replay privacy).
outline {
  1. What you need
  2. 1. Write a short event plan first
  3. 2. Install the snippet
  4. 3. Send your named events
  5. 4. Identify users, carefully
  6. 5. Trim autocapture
  7. 6. Send data through your own domain
  8. 7. Mask session replays
  9. 8. Cloud or self-hosted?
  10. Troubleshooting
  11. References
  • 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 nameWhen it firesProperties
contact_form_submittedContact form accepted by the servertopic
checklist_requestedLead form for the checklist submittedsource_page
guide_cta_clickedContact link at the end of a guide clickedguide_slug
signup_completedAccount 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:

bash
npm install posthog-js
javascript
import posthog from 'posthog-js'

posthog.init('<project_api_key>', {
  api_host: 'https://us.i.posthog.com',
  defaults: '2026-08-30',
  person_profiles: 'identified_only',
})
  • defaults pins 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

javascript
// 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:

html
<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:

javascript
posthog.identify('user_8f3a2c', { plan: 'pro' })

And on logout:

javascript
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:

javascript
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:

text
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 Host tells PostHog which service the request is for. Without it, you get 401 errors.
  • Cookies, and any Authorization header that isn't a PostHog key, are stripped, so your own site's session data never goes to PostHog.
  • The /array route carries remote config, so feature flags and replay settings keep updating.

Then point the library at it:

javascript
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:

javascript
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:

bash
/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_profiles is set to 'always', or identify is 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/static blocks above the catch-all by hand.
  • Proxy returns 401: the Host header isn't being rewritten, or the region in the hostnames doesn't match your project.

References