Snapshot both servers: the VPS catalog moves to server/vps, the home server joins as server/home
The catalog in server/containers had drifted from the VPS. Eleven compose
files and twelve units no longer matched what runs, and two running
services, Gbrown_Agent and BillTracker, were missing. server/vps is now a
copy of the live files, and panama-server reads from it.
server/home holds the home server's services in the same shape. They still
run under Docker, so the podman units there are drafts for a later move, and
nothing installs from that tree. PostHog, Sentry and Plausible are checkouts
of upstream repositories. Only their local files are kept, next to an
UPSTREAM file naming the repository and the exact commit.
Every secret in both trees is a ${VAR} reference named in .env.example, or
CHANGE_ME in files that are not compose files. The live servers still hold
some of those values inline, so a live file can differ from its copy on
those lines alone. Existing .env.example files keep their comments, with new
keys appended at the end.
The ignore rules and the secrets contract now cover both trees, plus the
volume/ and volumes/ directories the home server uses.
This commit is contained in:
1 parent
a664cf68f5
commit
913a57f8a6
146 files changed
+5258
-354
No files matched your search
@@ -0,0 +1,29 @@
|
||||
POSTHOG_SECRET=CHANGE_ME
|
||||
ENCRYPTION_SALT_KEYS=CHANGE_ME
|
||||
BROWSERLESS_SECRET=CHANGE_ME
|
||||
DOMAIN=CHANGE_ME
|
||||
REGISTRY_URL=CHANGE_ME
|
||||
POSTHOG_APP_TAG=CHANGE_ME
|
||||
TLS_BLOCK=CHANGE_ME
|
||||
CADDY_TLS_BLOCK=CHANGE_ME
|
||||
CADDY_HOST=CHANGE_ME
|
||||
POSTHOG_HTTP_PORT=CHANGE_ME
|
||||
ANTHROPIC_API_KEY=CHANGE_ME
|
||||
OPENAI_API_KEY=CHANGE_ME
|
||||
USESEND_API_KEY=CHANGE_ME
|
||||
POSTHOG_GITLAB_LOGIN_KEY=CHANGE_ME
|
||||
POSTHOG_GITLAB_LOGIN_SECRET=CHANGE_ME
|
||||
SECRET_KEY=CHANGE_ME
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID=CHANGE_ME
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY=CHANGE_ME
|
||||
SESSION_RECORDING_V2_S3_ACCESS_KEY_ID=CHANGE_ME
|
||||
SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY=CHANGE_ME
|
||||
INTERNAL_API_SECRET=CHANGE_ME
|
||||
CSP_REPORT_ENDPOINT=CHANGE_ME
|
||||
DOCKER_REGISTRY_PREFIX=CHANGE_ME
|
||||
ELAPSED=CHANGE_ME
|
||||
HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED=CHANGE_ME
|
||||
OPT_OUT_CAPTURE=CHANGE_ME
|
||||
POSTHOG_NODE_TAG=CHANGE_ME
|
||||
SEAWEEDFS_DOCKER_NAME=CHANGE_ME
|
||||
TIMEOUT=CHANGE_ME
|
||||
@@ -0,0 +1,391 @@
|
||||
# PostHog (self-hosted) on Fedora
|
||||
|
||||
PostHog's official installer (`bin/deploy-hobby`) assumes Ubuntu/Debian and a
|
||||
bare host. This directory is that install done by hand, adapted to this server.
|
||||
|
||||
## Why the official installer doesn't work here
|
||||
|
||||
| What `deploy-hobby` does | Why it fails / what we did instead |
|
||||
|---|---|
|
||||
| `apt update`, `apt install git brotli curl` | Not Debian. All three are already installed via dnf. |
|
||||
| Installs Docker from `download.docker.com/linux/ubuntu` | Docker 29.8 is already installed. |
|
||||
| Downloads the v1 `docker-compose` binary to `/usr/local/bin` | We use the Compose v5 plugin (`docker compose`). |
|
||||
| Runs Caddy on host ports `80:80` / `443:443` and gets its own Let's Encrypt certs | nginx-proxy-manager owns 80/443. Caddy now serves plain HTTP on `127.0.0.1:8002` and NPM terminates TLS in front of it. |
|
||||
| Publishes Temporal on `0.0.0.0:7233` and its UI on `0.0.0.0:8081` | Rebound to `127.0.0.1`. |
|
||||
| Phones home to PostHog Cloud with your domain | Skipped. |
|
||||
| Generates `compose/start` calling `./compose/wait` | Upstream bug, fixed here. WORKDIR is `/code` but the mount is at `/compose`, so the relative path never resolves. Harmless (`bin/migrate` retries 10x) but it meant the readiness check never ran. Ours calls `/compose/wait`. |
|
||||
|
||||
Note: Docker here runs *without* SELinux labelling (`docker info` lists only
|
||||
seccomp and cgroupns), so the bind mounts need no `:z` / `:Z` relabelling even
|
||||
though the host is SELinux-enforcing.
|
||||
|
||||
Caddy is kept rather than replaced by NPM because it does path-based routing
|
||||
that NPM can't express in one proxy host: `/e`, `/i/v0`, `/batch`, `/capture`
|
||||
go to `capture`; `/s` to `replay-capture`; `/i/v1/logs` to `capture-logs`;
|
||||
`/livestream` to `livestream`; `/posthog/*` to object storage; everything else
|
||||
to `web`. NPM just forwards the whole hostname to Caddy.
|
||||
|
||||
## Layout
|
||||
|
||||
compose.yml docker-compose.hobby.yml + local patches
|
||||
compose.override.yml additive local settings, auto-merged by compose
|
||||
docker-compose.base.yml verbatim from the repo (compose.yml `extends` it)
|
||||
.env secrets + domain (chmod 600, never regenerate)
|
||||
.env.services verbatim from the repo
|
||||
posthog/ upstream checkout, only used for config bind mounts
|
||||
share/ GeoLite2-City.mmdb
|
||||
compose/ entrypoint scripts the web/worker containers run,
|
||||
plus local-patches.py (see "Multiple projects" below)
|
||||
bin/upgrade Fedora-native replacement for bin/upgrade-hobby
|
||||
bin/apply-local-patches.py replays our compose.yml deltas after an upgrade
|
||||
|
||||
## nginx-proxy-manager
|
||||
|
||||
Add a proxy host:
|
||||
|
||||
- Domain: `posthog.gbrown.org`
|
||||
- Scheme: `http`, Forward host: `posthog`, Forward port: `80`
|
||||
- **Websockets: ON** (required — the `/livestream` endpoint)
|
||||
- Request an SSL cert, force SSL
|
||||
|
||||
`posthog` is a network alias on `nginx-bridge` for the Caddy container.
|
||||
|
||||
Also worth raising NPM's `client_max_body_size` for this host (Advanced tab:
|
||||
`client_max_body_size 50m;`) — session-recording batches and source-map
|
||||
uploads exceed the 1 MB default.
|
||||
|
||||
## Two config files, two reasons
|
||||
|
||||
`compose.yml` holds changes that *replace* upstream values -- the proxy's ports
|
||||
and networks. These have to be patched into the file directly, because Compose
|
||||
merges list-valued keys like `ports` by appending: an override file saying
|
||||
`8002:80` would leave you with 80, 443 *and* 8002. `bin/apply-local-patches.py`
|
||||
replays them after an upgrade overwrites the file.
|
||||
|
||||
`compose.override.yml` holds purely *additive* settings (currently
|
||||
`TRUST_ALL_PROXIES` for the three Django services). Compose merges it
|
||||
automatically and no upgrade ever touches it, so nothing needs replaying.
|
||||
|
||||
Rule of thumb: if you're adding an env var, put it in the override file. If
|
||||
you're changing a port or a network, patch compose.yml and add it to
|
||||
`bin/apply-local-patches.py`. `command` is the exception: Compose replaces it
|
||||
on merge rather than appending, so the worker's command lives in the override.
|
||||
|
||||
## Operating it
|
||||
|
||||
docker compose up -d # start
|
||||
docker compose stop # stop (keeps volumes)
|
||||
docker compose logs -f web # watch migrations / app
|
||||
docker compose ps # status
|
||||
bin/upgrade # upgrade (runs async migration checks first)
|
||||
|
||||
First boot runs Django + ClickHouse migrations and takes 5-15 minutes before
|
||||
`/_health` returns 200. The first person to visit the site creates the admin
|
||||
account, so log in promptly.
|
||||
|
||||
## Multiple projects (source patches)
|
||||
|
||||
Without a license, upstream PostHog allows one project per org and hides the
|
||||
"New project" button. PostHog no longer sells self-hosted licenses, so
|
||||
`compose/local-patches.py` patches two files in `/code` when `web`, `worker`
|
||||
and `temporal-django-worker` start:
|
||||
|
||||
- `posthog/utils.py`: `can_create_org` follows `MULTI_ORG_ENABLED` alone, which
|
||||
shows the button.
|
||||
- `posthog/models/organization.py`: the org keeps the `organizations_projects`
|
||||
feature, which the project API checks. Without this, the hourly
|
||||
`sync_all_organization_available_product_features` task resets the org's
|
||||
features to `[]` and the API refuses a second project.
|
||||
|
||||
Both are gated on `MULTI_ORG_ENABLED` in `compose.override.yml`. Because they run
|
||||
at container start, recreating a container no longer loses them. An earlier
|
||||
version patched running containers by hand and was wiped when the containers
|
||||
were recreated on 2026-09-22.
|
||||
|
||||
If upstream changes the code, the script logs `PATTERN NOT FOUND` and the app
|
||||
starts unpatched. Check after upgrades (`bin/upgrade` prints it):
|
||||
|
||||
docker compose logs web worker | grep LOCAL-PATCH
|
||||
|
||||
## Rebooting the host
|
||||
|
||||
A host boot is not `docker compose up`. Dockerd starts containers from each one's
|
||||
restart policy alone, in no order, and never reads `depends_on`, so the dependency
|
||||
ordering the stack is built around does not exist at boot time.
|
||||
|
||||
Upstream ships `restart: no` or `on-failure` on nearly every service, and
|
||||
`on-failure` does not retry a clean exit. Several services exit *zero* on purpose:
|
||||
`hypercache-server` when it loses the race to `redis7` on a cold boot, and
|
||||
`capture` when it loses Kafka (on 2026-09-27 a host memory spike stalled Kafka,
|
||||
capture shut down gracefully, and it stayed down for three days). So
|
||||
`compose.override.yml` sets `restart: unless-stopped` on every long-running
|
||||
service. A manual `docker compose stop` is still respected.
|
||||
|
||||
Expect `hypercache-server` to flap a few times early in boot until `redis7`
|
||||
answers. That is the retry working, not a fault.
|
||||
|
||||
`kafka-init` sitting at `Exited (0)` is correct. It is a one-shot init job and
|
||||
must keep `restart: no`.
|
||||
|
||||
The web UI serves fine while these four are missing, so check
|
||||
`docker ps -a --filter name=posthog` for anything not `Up` before assuming a
|
||||
reboot went cleanly. To bring back anything stopped, in dependency order, without
|
||||
recreating running containers:
|
||||
|
||||
docker compose up -d --no-recreate
|
||||
|
||||
## Watchtower is deliberately not enabled
|
||||
|
||||
Every other stack here carries `com.centurylinklabs.watchtower.enable=true`.
|
||||
PostHog does not, on purpose: its upgrades run schema migrations that must be
|
||||
checked with `asyncmigrationscheck` before the app restarts, and a silent image
|
||||
swap underneath a running ClickHouse/Kafka pipeline can corrupt data. Use
|
||||
`bin/upgrade` instead.
|
||||
|
||||
## Resource notes
|
||||
|
||||
This stack is 37 containers (ClickHouse, Kafka, Zookeeper, Postgres, Redis,
|
||||
Valkey, Temporal, Elasticsearch, SeaweedFS, Browserless, and ~12 Rust/Node
|
||||
consumers). PostHog asks for 8 GB minimum; with Sentry already running here,
|
||||
keep an eye on `free -h`.
|
||||
|
||||
If memory gets tight, these can be commented out of `compose.yml` with little
|
||||
loss on a personal instance:
|
||||
|
||||
- `elasticsearch` — Temporal is configured with `ENABLE_ES=false`, so it is
|
||||
only there for Temporal's advanced visibility. Saves ~1 GB.
|
||||
- `temporal-ui` — debugging UI for Temporal workflows only.
|
||||
- `cymbal` / `cymbal-resolution` — error-tracking stack-trace symbolication.
|
||||
Skip if you're keeping errors in Sentry rather than PostHog.
|
||||
|
||||
## Ad blockers and the hostname
|
||||
|
||||
The host is `analytics.gbrown.org`. It was originally `posthog.gbrown.org`, which
|
||||
uBlock Origin blocked, leaving the dashboard a blank white page.
|
||||
|
||||
The culprit is EasyPrivacy line 2964 (EasyPrivacy is enabled by default in uBO):
|
||||
|
||||
://posthog.$script,domain=~posthog.com
|
||||
|
||||
That is an *unanchored substring* pattern, not a domain rule, so any URL
|
||||
containing `://posthog.` matches -- including `https://posthog.gbrown.org/...`.
|
||||
The `$script` option is why the symptom was a white page rather than a failed
|
||||
connection: the HTML document is a `document` request and loaded fine, but every
|
||||
`/static/*.js` was blocked, so React never mounted.
|
||||
|
||||
Renaming fixes it. Checked against all five lists uBO enables by default
|
||||
(EasyPrivacy, EasyList, uBO filters/privacy/badware, ~160k rules), with controls
|
||||
validating in both directions:
|
||||
|
||||
posthog.gbrown.org BLOCKED (rule above)
|
||||
analytics.gbrown.org clean
|
||||
stats/insights/metrics clean
|
||||
bills.gbrown.org clean (control)
|
||||
|
||||
Also clean on the new host: `/static/array.js`, `/static/recorder.js`,
|
||||
`/static/recorder-v2.js`, `/e/`, `/i/v0/e/`, `/batch/`, `/capture/`, `/decide/`.
|
||||
|
||||
Generic words like "analytics" survive because the lists anchor to specific
|
||||
vendor domains (`||google-analytics.com`) rather than bare words -- blocking the
|
||||
word outright would break the web. The `://posthog.` rule is the unusual one.
|
||||
|
||||
### To change the hostname again
|
||||
|
||||
1. Edit `DOMAIN` in `.env`.
|
||||
2. Add the proxy host in nginx-proxy-manager: http -> `posthog` -> 80,
|
||||
websockets on, `client_max_body_size 50m;`.
|
||||
3. `docker compose up -d`.
|
||||
|
||||
Caddy listens on `:80` regardless of hostname, so nothing inside the stack needs
|
||||
touching -- that is the payoff of terminating TLS at NPM instead of in Caddy.
|
||||
Note that `SITE_URL` feeds `CSRF_TRUSTED_ORIGINS`, so existing browser sessions
|
||||
may need a fresh login after a rename. Capture/API traffic is unaffected.
|
||||
|
||||
Still worth proxying capture through the app's own origin (a Next.js `/ingest`
|
||||
rewrite): it is same-origin, so no CORS and no preflight, and it survives future
|
||||
filter-list changes regardless of what this host is called.
|
||||
|
||||
## Capture settings for the bill-tracker app (decided 2026-09-17)
|
||||
|
||||
The bill tracker (Next + Expo + Convex) sends to this instance. Gib asked for
|
||||
everything on, so both clients ship with session replay, autocapture, heatmaps,
|
||||
console log capture and exception capture ENABLED, on posthog-js defaults:
|
||||
|
||||
maskAllInputs: true (default — form inputs are masked)
|
||||
rendered text: NOT masked (default)
|
||||
|
||||
The tradeoff, recorded deliberately rather than discovered later: this is a
|
||||
finance app, so bill amounts, balances, payee names and due dates are rendered
|
||||
*text*, not inputs, and therefore land in session replays verbatim. Autocapture
|
||||
also stores the text of clicked elements, so a button reading "Pay $1,247.33 to
|
||||
Chase" becomes an event property. Console log capture ships whatever the app
|
||||
logs, which in dev is often a whole transaction object.
|
||||
|
||||
This is considered acceptable because the instance is self-hosted on Gib's own
|
||||
hardware and serves his own household — the data never leaves the house.
|
||||
|
||||
To tighten later:
|
||||
|
||||
maskTextSelector: '*' // or scope to the money elements
|
||||
class="ph-no-capture" // on balance / amount / payee nodes
|
||||
|
||||
One asymmetry worth remembering: tightening does NOT retroactively clean stored
|
||||
replays. Anything recorded before the change stays recorded, so the cheap moment
|
||||
to decide is before real household data flows, not after.
|
||||
|
||||
Client config lives in the bill-tracker repo, not here. The host is read from
|
||||
NEXT_PUBLIC_POSTHOG_HOST / EXPO_PUBLIC_POSTHOG_HOST (Infisical), and web routes
|
||||
capture through a same-origin /ingest rewrite rather than hitting this host
|
||||
directly.
|
||||
|
||||
## Blocked asset filenames — why the client requests ext-a.js / ext-b.js
|
||||
|
||||
Renaming the host to `analytics.gbrown.org` fixed the dashboard, but two of
|
||||
PostHog's own script filenames are blocked by rules that match the FILENAME, not
|
||||
the host, so a reverse proxy cannot dodge them:
|
||||
|
||||
easyprivacy.txt line 1906: /posthog-recorder.js
|
||||
easyprivacy.txt line 809: /dead-clicks-autocapture.js
|
||||
|
||||
Both are unanchored substrings with no type or domain options, so they match on
|
||||
any host, any request type, first-party included. Confirmed blocked both direct
|
||||
and through the app's /ingest proxy. Symptom: session replay and dead-click
|
||||
capture silently never start for anyone running uBlock.
|
||||
|
||||
The bill-tracker app works around this with posthog-js's
|
||||
`prepare_external_dependency_script` hook, serving them under inert names:
|
||||
|
||||
posthog-recorder.js -> ext-a.js
|
||||
dead-clicks-autocapture.js -> ext-b.js
|
||||
|
||||
(`strict_script_versioning` off, so only the plain `/static/<name>.js?v=` form is
|
||||
requested.) The Next rewrite maps the disguised names back. Verified: replay
|
||||
blobs land in `posthog.session_replay_events`, which was stuck at 0 before.
|
||||
|
||||
Everything else PostHog serves is clean on both paths — `array.js`,
|
||||
`array.full.js`, `recorder.js`, `recorder-v2.js`, `surveys.js`,
|
||||
`exception-autocapture.js`, `web-vitals-with-attribution.js`,
|
||||
`tracing-headers.js`, `toolbar.js`, `external-scripts-loader.js`, `/e/`,
|
||||
`/flags/`. Note the legacy `recorder.js` and `recorder-v2.js` names are NOT
|
||||
listed; only the newer `posthog-recorder.js` is.
|
||||
|
||||
One more, shape-dependent: `/s/array/<token>/config` is blocked by
|
||||
ubo-privacy.txt line 3159 (`$script,xhr,1p` — first-party too), but
|
||||
`/array/<token>/config.js` is clean. posthog-js 1.433.10 requests the latter, so
|
||||
it does not bite today. Worth re-checking after a major posthog-js upgrade.
|
||||
|
||||
### Expected absences (not bugs)
|
||||
|
||||
- `$pageleave` does NOT fire on client-side SPA navigation. posthog-js hangs it
|
||||
off the real unload/visibilitychange lifecycle, so it tracks actual page exits
|
||||
only. Verified against every pageleave in the instance.
|
||||
- `$web_vitals` flushes on page hide; `$dead_click` needs a click that resolves
|
||||
to nothing. Both stay absent until those conditions occur.
|
||||
|
||||
## nginx-proxy-manager buffer sizes (required, or replay breaks)
|
||||
|
||||
Symptom: the Session Replay list page shows "Error while trying to load
|
||||
recordings". `GET /api/environments/<id>/session_recordings` returns 502 from
|
||||
openresty in ~130ms, while a SINGLE recording
|
||||
(`.../session_recordings/<session_id>`) returns 200 and `/events` returns 200.
|
||||
|
||||
Cause is nginx, not PostHog. From `/data/logs/proxy-host-121_error.log`:
|
||||
|
||||
upstream sent too big header while reading response header from upstream
|
||||
|
||||
nginx's default `proxy_buffer_size` is 4-8KB. PostHog's session_recordings LIST
|
||||
endpoint sends response headers larger than that, so nginx aborts before reading
|
||||
the body and returns 502. The request never reaches Django, which is why nothing
|
||||
appears in the web container logs and Caddy logs no 5xx at all. The single
|
||||
recording endpoint sends normal-sized headers, hence the asymmetry.
|
||||
|
||||
Required in the NPM Advanced tab for this proxy host:
|
||||
|
||||
client_max_body_size 50m;
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
|
||||
The Sentry proxy host (1.conf) already carried the same three buffer directives —
|
||||
same nginx default, same fix, just never applied to PostHog's host.
|
||||
|
||||
Verified in `/data/logs/proxy-host-121_access.log`: 502 at 22:46:06 before the
|
||||
change, 200 at 22:46:36 after, no further "too big header" entries.
|
||||
|
||||
Do NOT edit the generated `.conf` files directly — NPM regenerates them from its
|
||||
own database, so a direct edit reverts on the next save.
|
||||
|
||||
### Verifying replay from the server side
|
||||
|
||||
ClickHouse `session_replay_events` proves events ARRIVED; it does not prove they
|
||||
can be PLAYED BACK. Those are different paths (ingestion -> Kafka -> object
|
||||
storage vs. API -> proxy -> browser). To check the storage side:
|
||||
|
||||
docker compose exec seaweedfs sh -c \
|
||||
"echo 'fs.ls -l /buckets/posthog/session_recordings/5y' | weed shell -master=localhost:9333"
|
||||
|
||||
Blobs live under `session_recordings/<retention>/`, named `<epoch_ms>-<hash>`.
|
||||
|
||||
## "No recordings found" with replay working fine
|
||||
|
||||
The Session Replay list ships a DEFAULT filter of `> 5 active seconds`. Any
|
||||
session with 5 or fewer *active* seconds is hidden, even though it recorded
|
||||
perfectly and plays back fine.
|
||||
|
||||
Active time is not wall-clock time. The first verified session here ran 458s
|
||||
(7.6 min) but logged only 643ms of activity — one click and a little mouse
|
||||
movement in an otherwise idle tab. PostHog counts interaction, not duration,
|
||||
deliberately, to suppress bounces and accidental loads.
|
||||
|
||||
That default makes quick dev checks vanish and looks identical to "replay is
|
||||
broken". To see them: open the duration chip on /replay/home and set the
|
||||
condition to `> 0 active seconds`, or save a filter preset. Date range and the
|
||||
"filter out internal and test users" toggle are usually NOT the culprit.
|
||||
|
||||
Check the real numbers server-side before assuming anything is missing:
|
||||
|
||||
docker compose exec clickhouse clickhouse-client -q \
|
||||
"select session_id, sum(active_milliseconds) active_ms,
|
||||
dateDiff('second', min(min_first_timestamp), max(max_last_timestamp)) duration_s,
|
||||
sum(click_count) clicks, sum(event_count) evts
|
||||
from posthog.session_replay_events group by session_id"
|
||||
|
||||
Verified 2026-09-17: session 01a0b177-d35c-7292-ac59-021af608d29a recorded,
|
||||
listed once the threshold was lowered, and played back in the browser.
|
||||
|
||||
## Real client IPs through NPM -> Caddy
|
||||
|
||||
By default Django logged `x_forwarded_for=172.25.0.8` — nginx-proxy-manager's own
|
||||
container IP — so every event's `$ip` resolved to the proxy and GeoIP failed on a
|
||||
private address.
|
||||
|
||||
NPM was not at fault; it sends `X-Forwarded-For $proxy_add_x_forwarded_for` and
|
||||
`X-Real-IP $remote_addr` from its global proxy include. The loss was in Caddy:
|
||||
its global options block was empty, so `trusted_proxies` was unset, and Caddy's
|
||||
default is to distrust an incoming X-Forwarded-For and OVERWRITE it with the
|
||||
direct peer. Proven by sending `X-Forwarded-For: 9.9.9.9` to Caddy and watching
|
||||
Django log `172.25.0.1`.
|
||||
|
||||
Fixed in `.env` — no compose patch, because `CADDY_TLS_BLOCK` is substituted
|
||||
directly into Caddy's global options block:
|
||||
|
||||
CADDY_TLS_BLOCK="servers {
|
||||
trusted_proxies static 172.25.0.0/24
|
||||
}"
|
||||
|
||||
Multi-line values work in `.env`; `docker compose config` resolves it correctly
|
||||
and `caddy validate` accepts it. Apply with `docker compose up -d proxy`.
|
||||
|
||||
Verified after: a spoofed `X-Forwarded-For: 9.9.9.9` arrives as `ip=9.9.9.9`, and
|
||||
a real request through NPM arrives as the true client address.
|
||||
|
||||
TRADEOFF: `/24` trusts every container on nginx-bridge (~42 of them), any of
|
||||
which could forge an X-Forwarded-For that Caddy now believes — compounded by
|
||||
`TRUST_ALL_PROXIES=true` on Django (see compose.override.yml). Acceptable for a
|
||||
LAN-only household instance; tighten to NPM's address if this is ever exposed to
|
||||
the internet. Pinning `172.25.0.8/32` is narrower but breaks silently whenever
|
||||
Docker reassigns NPM's IP.
|
||||
|
||||
Note this does NOT produce geolocation: LAN clients are 192.168.x.x, which is not
|
||||
in the MaxMind database, so GeoIP still errors. What it buys is the ability to
|
||||
tell devices apart.
|
||||
@@ -0,0 +1,6 @@
|
||||
checkout: ~/Server/PostHog/posthog/
|
||||
repo: https://github.com/PostHog/posthog
|
||||
ref: master at this commit
|
||||
commit: ddfaf8843fca8e796bf1f795976842d2d1adb380
|
||||
|
||||
docker-compose.base.yml is the upstream file at that commit, unchanged.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Re-apply this server's deltas to a freshly-copied docker-compose.hobby.yml.
|
||||
|
||||
Upstream's bin/upgrade-hobby overwrites compose.yml from the repo on every
|
||||
upgrade, so these three changes have to be replayed afterwards. Idempotent:
|
||||
running it on an already-patched file is a no-op.
|
||||
|
||||
Usage: bin/apply-local-patches.py [compose.yml]
|
||||
"""
|
||||
import sys
|
||||
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else 'compose.yml'
|
||||
s = open(path).read()
|
||||
changed = []
|
||||
|
||||
# 1) nginx-proxy-manager owns host 80/443. Publish Caddy on loopback instead and
|
||||
# put it on nginx-bridge so NPM can reach it by name.
|
||||
old = """ ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
"""
|
||||
new = """ ports:
|
||||
# nginx-proxy-manager owns host 80/443. Loopback only; NPM reaches
|
||||
# this container over nginx-bridge as "posthog".
|
||||
- '127.0.0.1:${POSTHOG_HTTP_PORT:-8002}:80'
|
||||
networks:
|
||||
default:
|
||||
nginx-bridge:
|
||||
aliases:
|
||||
- posthog
|
||||
"""
|
||||
if old in s:
|
||||
s = s.replace(old, new)
|
||||
changed.append('proxy ports + nginx-bridge')
|
||||
|
||||
# 2) TLS terminates at NPM, so Caddy must not try to get its own certs. Take the
|
||||
# site address from .env (CADDY_HOST=:80) instead of the hardcoded domain.
|
||||
old = " CADDY_HOST: '$DOMAIN, http://, https://'\n"
|
||||
if old in s:
|
||||
s = s.replace(old, " CADDY_HOST: $CADDY_HOST\n")
|
||||
changed.append('CADDY_HOST from .env')
|
||||
|
||||
# 3) Temporal gRPC + UI on loopback, not every interface.
|
||||
for a, b in ((" - 7233:7233\n", " - 127.0.0.1:7233:7233\n"),
|
||||
(" - 8081:8080\n", " - 127.0.0.1:8081:8080\n")):
|
||||
if a in s:
|
||||
s = s.replace(a, b)
|
||||
changed.append(a.strip())
|
||||
|
||||
# 4) nginx-bridge is created and owned by ~/Server/Nginx.
|
||||
old = "networks:\n otel_network:\n"
|
||||
if old in s and 'nginx-bridge:\n external: true' not in s:
|
||||
s = s.replace(old, "networks:\n otel_network:\n nginx-bridge:\n external: true\n")
|
||||
changed.append('nginx-bridge external')
|
||||
|
||||
open(path, 'w').write(s)
|
||||
print(f"{path}: " + (", ".join(changed) if changed else "already patched, no changes"))
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Fedora-native replacement for posthog/bin/upgrade-hobby.
|
||||
#
|
||||
# Upstream's script is unusable here: it calls apt-get, re-downloads the v1
|
||||
# `docker-compose` binary, and overwrites compose.yml from the repo (throwing
|
||||
# away this server's nginx-proxy-manager and port changes). This does the same
|
||||
# work using dnf-era tooling and replays the local patches afterwards.
|
||||
#
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> Refreshing the PostHog checkout"
|
||||
git -C posthog fetch origin
|
||||
git -C posthog reset --hard origin/master
|
||||
echo " now on $(git -C posthog rev-parse --short HEAD)"
|
||||
|
||||
echo "==> Refreshing compose files and re-applying local patches"
|
||||
cp posthog/docker-compose.base.yml docker-compose.base.yml
|
||||
cp posthog/docker-compose.hobby.yml compose.yml
|
||||
cp posthog/.env.services .env.services
|
||||
bin/apply-local-patches.py compose.yml
|
||||
docker compose config >/dev/null && echo " compose config OK"
|
||||
|
||||
echo "==> Refreshing the GeoIP database"
|
||||
curl -sL 'https://mmdbcdn.posthog.net/' --http1.1 | brotli --decompress > share/GeoLite2-City.mmdb
|
||||
echo "{\"date\": \"$(date +%F)\"}" > share/GeoLite2-City.json
|
||||
chmod 644 share/GeoLite2-City.mmdb share/GeoLite2-City.json
|
||||
|
||||
echo "==> Pulling images"
|
||||
docker compose pull
|
||||
|
||||
echo "==> Checking async migrations against the new version"
|
||||
docker compose up -d db redis7 clickhouse
|
||||
docker compose run --rm asyncmigrationscheck
|
||||
|
||||
echo "==> Restarting the stack"
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# web, worker and temporal-django-worker apply compose/local-patches.py on start.
|
||||
# Surface the result so an upstream change that breaks a patch is not missed.
|
||||
echo "==> Local source patches"
|
||||
sleep 15
|
||||
docker compose logs --since 5m web worker temporal-django-worker 2>&1 | grep LOCAL-PATCH || \
|
||||
echo " no LOCAL-PATCH lines yet, check: docker compose logs web worker | grep LOCAL-PATCH"
|
||||
|
||||
echo
|
||||
echo "Done. Watch the migrations with: docker compose logs -f web"
|
||||
@@ -0,0 +1,99 @@
|
||||
services:
|
||||
web: &id001
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TRUST_ALL_PROXIES: 'true'
|
||||
MULTI_ORG_ENABLED: 'true'
|
||||
# SSO: the "GitLab" login button is really Authentik. NPM on the VPS maps GitLab's
|
||||
# /oauth/* and /api/v4/user paths on auth.gbrown.org to Authentik, the same shim
|
||||
# Infisical uses. GitLab login needs no license here; only Google is gated.
|
||||
SOCIAL_AUTH_GITLAB_API_URL: https://auth.gbrown.org
|
||||
SOCIAL_AUTH_GITLAB_KEY: ${POSTHOG_GITLAB_LOGIN_KEY:?set POSTHOG_GITLAB_LOGIN_KEY in .env}
|
||||
SOCIAL_AUTH_GITLAB_SECRET: ${POSTHOG_GITLAB_LOGIN_SECRET:?set POSTHOG_GITLAB_LOGIN_SECRET in .env}
|
||||
PERSONHOG_ADDR: personhog-router:50052
|
||||
PERSISTED_FEATURE_FLAGS: sql-editor-vim-mode,cdp-hog-sources,customer-analytics-roadmap,marketing-analytics,web-experiments,dashboard-widgets,twig,tracing,metrics
|
||||
EMAIL_ENABLED: 'true'
|
||||
EMAIL_HOST: smtp.usesend.gbrown.org
|
||||
EMAIL_PORT: '587'
|
||||
EMAIL_HOST_USER: usesend
|
||||
EMAIL_HOST_PASSWORD: ${USESEND_API_KEY:?set USESEND_API_KEY in .env}
|
||||
EMAIL_USE_TLS: 'true'
|
||||
EMAIL_USE_SSL: 'false'
|
||||
EMAIL_DEFAULT_FROM: PostHog <[email protected]>
|
||||
EMAIL_REPLY_TO: [email protected]
|
||||
worker:
|
||||
<<: *id001
|
||||
# compose/worker applies compose/local-patches.py, then runs upstream's
|
||||
# command. `command` is replaced on merge (not appended), so it is safe here.
|
||||
command: /compose/worker
|
||||
volumes:
|
||||
- ./compose:/compose
|
||||
temporal-django-worker: *id001
|
||||
# Every long-running service restarts regardless of exit code. Upstream ships
|
||||
# `on-failure` (or `no`), which never retries a clean exit, and these services
|
||||
# exit 0 on purpose: capture shuts down gracefully when it loses Kafka
|
||||
# (2026-09-27, down 3 days), hypercache-server exits 0 when it beats redis7 at
|
||||
# boot. web/worker/temporal-django-worker get this via the anchor above.
|
||||
# kafka-init and asyncmigrationscheck are one-shots and must stay `no`.
|
||||
browserless:
|
||||
restart: unless-stopped
|
||||
capture:
|
||||
restart: unless-stopped
|
||||
capture-logs:
|
||||
restart: unless-stopped
|
||||
clickhouse:
|
||||
restart: unless-stopped
|
||||
cymbal:
|
||||
restart: unless-stopped
|
||||
cymbal-resolution:
|
||||
restart: unless-stopped
|
||||
db:
|
||||
restart: unless-stopped
|
||||
elasticsearch:
|
||||
restart: unless-stopped
|
||||
feature-flags:
|
||||
restart: unless-stopped
|
||||
hypercache-server:
|
||||
restart: unless-stopped
|
||||
ingestion-error-tracking:
|
||||
restart: unless-stopped
|
||||
ingestion-general:
|
||||
restart: unless-stopped
|
||||
ingestion-logs:
|
||||
restart: unless-stopped
|
||||
ingestion-sessionreplay:
|
||||
restart: unless-stopped
|
||||
ingestion-traces:
|
||||
restart: unless-stopped
|
||||
kafka:
|
||||
restart: unless-stopped
|
||||
livestream:
|
||||
restart: unless-stopped
|
||||
objectstorage:
|
||||
restart: unless-stopped
|
||||
personhog-replica:
|
||||
restart: unless-stopped
|
||||
personhog-router:
|
||||
restart: unless-stopped
|
||||
plugins:
|
||||
restart: unless-stopped
|
||||
property-defs-rs:
|
||||
restart: unless-stopped
|
||||
recording-api:
|
||||
restart: unless-stopped
|
||||
redis7:
|
||||
restart: unless-stopped
|
||||
replay-capture:
|
||||
restart: unless-stopped
|
||||
seaweedfs:
|
||||
restart: unless-stopped
|
||||
temporal:
|
||||
restart: unless-stopped
|
||||
temporal-admin-tools:
|
||||
restart: unless-stopped
|
||||
temporal-ui:
|
||||
restart: unless-stopped
|
||||
valkey:
|
||||
restart: unless-stopped
|
||||
zookeeper:
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,767 @@
|
||||
#
|
||||
# `docker-compose` file used ONLY for hobby deployments.
|
||||
#
|
||||
# Please take a look at https://posthog.com/docs/self-host/deploy/hobby
|
||||
# for more info.
|
||||
#
|
||||
# PostHog has sunset support for self-hosted K8s deployments.
|
||||
# See: https://posthog.com/blog/sunsetting-helm-support-posthog
|
||||
#
|
||||
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '50m'
|
||||
max-file: '3'
|
||||
|
||||
services:
|
||||
db:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: db
|
||||
image: ${DOCKER_REGISTRY_PREFIX:-}postgres:15.12-alpine
|
||||
logging: *default-logging
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
redis7:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: redis7
|
||||
logging: *default-logging
|
||||
volumes:
|
||||
- redis7-data:/data
|
||||
|
||||
valkey:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: valkey
|
||||
logging: *default-logging
|
||||
|
||||
clickhouse:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: clickhouse
|
||||
restart: on-failure
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- kafka
|
||||
- zookeeper
|
||||
volumes:
|
||||
- ./posthog/posthog/idl:/idl
|
||||
- ./posthog/docker/clickhouse/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
|
||||
- ./posthog/docker/clickhouse/config.xml:/etc/clickhouse-server/config.xml
|
||||
- ./posthog/docker/clickhouse/config.d/default.xml:/etc/clickhouse-server/config.d/default.xml
|
||||
- ./posthog/docker/clickhouse/users.xml:/etc/clickhouse-server/users.xml
|
||||
- ./posthog/docker/clickhouse/user_defined_function.xml:/etc/clickhouse-server/user_defined_function.xml
|
||||
- ./posthog/posthog/user_scripts:/var/lib/clickhouse/user_scripts
|
||||
- clickhouse-data:/var/lib/clickhouse
|
||||
|
||||
zookeeper:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: zookeeper
|
||||
logging: *default-logging
|
||||
environment:
|
||||
ZOO_AUTOPURGE_PURGEINTERVAL: 1
|
||||
ZOO_AUTOPURGE_SNAPRETAINCOUNT: 3
|
||||
volumes:
|
||||
- zookeeper-datalog:/datalog
|
||||
- zookeeper-data:/data
|
||||
- zookeeper-logs:/logs
|
||||
kafka:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: kafka
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- zookeeper
|
||||
environment:
|
||||
KAFKA_LOG_RETENTION_MS: 3600000
|
||||
KAFKA_LOG_RETENTION_CHECK_INTERVAL_MS: 300000
|
||||
KAFKA_LOG_RETENTION_HOURS: 1
|
||||
volumes:
|
||||
- kafka-data:/bitnami/kafka
|
||||
|
||||
worker:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: worker
|
||||
logging: *default-logging
|
||||
environment:
|
||||
SITE_URL: https://$DOMAIN
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY}
|
||||
OBJECT_STORAGE_ENDPOINT: http://objectstorage:19000
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT: https://$DOMAIN
|
||||
SESSION_RECORDING_V2_S3_ENDPOINT: http://seaweedfs:8333
|
||||
SESSION_RECORDING_V2_S3_ACCESS_KEY_ID: ${SESSION_RECORDING_V2_S3_ACCESS_KEY_ID}
|
||||
SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: ${SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY}
|
||||
OBJECT_STORAGE_ENABLED: true
|
||||
RECORDING_API_URL: http://recording-api:6738
|
||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-$POSTHOG_SECRET}
|
||||
ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS}
|
||||
POSTHOG_SKIP_MIGRATION_CHECKS: '1'
|
||||
PERSONHOG_ADDR: 'personhog-router:50052'
|
||||
PERSONHOG_ENABLED: 'true'
|
||||
BROWSERLESS_CDP_URL: 'ws://browserless:3000'
|
||||
BROWSERLESS_TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET}
|
||||
HEATMAP_BROWSERLESS_URL: 'http://browserless:3000'
|
||||
HEATMAP_BROWSERLESS_TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET}
|
||||
HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED: ${HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED:-false}
|
||||
# Point Django at the Rust feature-flags container; the setting
|
||||
# otherwise defaults to localhost:3001.
|
||||
FEATURE_FLAGS_SERVICE_URL: 'http://feature-flags:3001'
|
||||
# The logs product uses a dedicated ClickHouse cluster config (separate
|
||||
# from CLICKHOUSE_HOST) whose host defaults to localhost and whose SECURE
|
||||
# defaults to true outside DEBUG. Point it at the stack's ClickHouse over
|
||||
# plaintext; the other logs-cluster settings already default correctly
|
||||
# (native port 9000, user "default", empty password).
|
||||
CLICKHOUSE_LOGS_CLUSTER_HOST: 'clickhouse'
|
||||
CLICKHOUSE_LOGS_CLUSTER_SECURE: 'false'
|
||||
# AI provider keys for PostHog's AI features. Blank unless set in .env.
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
image: $REGISTRY_URL:$POSTHOG_APP_TAG
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis7:
|
||||
condition: service_healthy
|
||||
clickhouse:
|
||||
condition: service_started
|
||||
kafka:
|
||||
condition: service_healthy
|
||||
web:
|
||||
condition: service_started
|
||||
personhog-router:
|
||||
condition: service_started
|
||||
browserless:
|
||||
condition: service_started
|
||||
web:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: web
|
||||
command: /compose/start
|
||||
logging: *default-logging
|
||||
volumes:
|
||||
- ./compose:/compose
|
||||
image: $REGISTRY_URL:$POSTHOG_APP_TAG
|
||||
environment:
|
||||
SITE_URL: https://$DOMAIN
|
||||
LIVESTREAM_HOST: 'https://${DOMAIN}/livestream'
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY}
|
||||
SESSION_RECORDING_V2_S3_ACCESS_KEY_ID: ${SESSION_RECORDING_V2_S3_ACCESS_KEY_ID}
|
||||
SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: ${SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY}
|
||||
OBJECT_STORAGE_ENDPOINT: http://objectstorage:19000
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT: https://$DOMAIN
|
||||
SESSION_RECORDING_V2_S3_ENDPOINT: http://seaweedfs:8333
|
||||
OBJECT_STORAGE_ENABLED: true
|
||||
RECORDING_API_URL: http://recording-api:6738
|
||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-$POSTHOG_SECRET}
|
||||
ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS}
|
||||
OTEL_SERVICE_NAME: 'posthog'
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ''
|
||||
OTEL_SDK_DISABLED: 'true'
|
||||
OPT_OUT_CAPTURE: ${OPT_OUT_CAPTURE:-false}
|
||||
# Blank sends no CSP reports. Point it at this instance's own /report/ endpoint to
|
||||
# collect them yourself.
|
||||
CSP_REPORT_ENDPOINT: ${CSP_REPORT_ENDPOINT:-}
|
||||
DEPLOYMENT: 'hobby'
|
||||
PERSONHOG_ADDR: 'personhog-router:50052'
|
||||
PERSONHOG_ENABLED: 'true'
|
||||
# Point Django at the Rust feature-flags container; the setting
|
||||
# otherwise defaults to localhost:3001.
|
||||
FEATURE_FLAGS_SERVICE_URL: 'http://feature-flags:3001'
|
||||
# The logs product uses a dedicated ClickHouse cluster config (separate
|
||||
# from CLICKHOUSE_HOST) whose host defaults to localhost and whose SECURE
|
||||
# defaults to true outside DEBUG. Point it at the stack's ClickHouse over
|
||||
# plaintext; the other logs-cluster settings already default correctly
|
||||
# (native port 9000, user "default", empty password).
|
||||
CLICKHOUSE_LOGS_CLUSTER_HOST: 'clickhouse'
|
||||
CLICKHOUSE_LOGS_CLUSTER_SECURE: 'false'
|
||||
# AI provider keys for PostHog's AI features. Blank unless set in .env.
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
- clickhouse
|
||||
- kafka
|
||||
- objectstorage
|
||||
- seaweedfs
|
||||
- personhog-router
|
||||
|
||||
plugins:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: plugins
|
||||
image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
environment:
|
||||
SITE_URL: https://$DOMAIN
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY}
|
||||
OBJECT_STORAGE_ENDPOINT: http://objectstorage:19000
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT: https://$DOMAIN
|
||||
OBJECT_STORAGE_ENABLED: true
|
||||
CDP_REDIS_HOST: redis7
|
||||
CDP_REDIS_PORT: 6379
|
||||
LOGS_REDIS_HOST: redis7
|
||||
LOGS_REDIS_PORT: 6379
|
||||
LOGS_REDIS_TLS: 'false'
|
||||
TRACES_REDIS_HOST: redis7
|
||||
TRACES_REDIS_PORT: 6379
|
||||
TRACES_REDIS_TLS: 'false'
|
||||
# Every CDP process dual-writes to Valkey and refuses to start without a host.
|
||||
CDP_VALKEY_HOST: valkey
|
||||
CDP_VALKEY_PORT: 6379
|
||||
ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS}
|
||||
PERSONS_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog'
|
||||
BEHAVIORAL_COHORTS_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog'
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ''
|
||||
volumes:
|
||||
- ./share:/share
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
- valkey
|
||||
- clickhouse
|
||||
- kafka
|
||||
- objectstorage
|
||||
- seaweedfs
|
||||
|
||||
ingestion-general:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: ingestion-general
|
||||
image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
environment:
|
||||
PERSONS_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog'
|
||||
BEHAVIORAL_COHORTS_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog'
|
||||
ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS}
|
||||
# The combined-mode AI consumer reads persons through PersonHog
|
||||
PERSONHOG_ADDR: 'personhog-router:50052'
|
||||
PERSONHOG_ENABLED: 'true'
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
- clickhouse
|
||||
- kafka
|
||||
# The combined AI consumer's blob store healthchecks the ai-blobs bucket at startup
|
||||
- objectstorage
|
||||
- personhog-router
|
||||
|
||||
ingestion-sessionreplay:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: ingestion-sessionreplay
|
||||
image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
environment:
|
||||
SESSION_RECORDING_V2_S3_ACCESS_KEY_ID: ${SESSION_RECORDING_V2_S3_ACCESS_KEY_ID}
|
||||
SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: ${SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY}
|
||||
SESSION_RECORDING_V2_S3_TIMEOUT_MS: 120000
|
||||
SESSION_RECORDING_V2_S3_ENDPOINT: http://seaweedfs:8333
|
||||
ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS}
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
- kafka
|
||||
- seaweedfs
|
||||
|
||||
recording-api:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: recording-api
|
||||
image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
environment:
|
||||
# Read side of the session recording V2 blob store. Must point at the same
|
||||
# SeaweedFS the ingestion-sessionreplay writer uses — without these the
|
||||
# service falls back to its localhost:8333 default, so every playback
|
||||
# request 500s with ECONNREFUSED ("We're unable to play this recording").
|
||||
SESSION_RECORDING_V2_S3_ACCESS_KEY_ID: ${SESSION_RECORDING_V2_S3_ACCESS_KEY_ID}
|
||||
SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: ${SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY}
|
||||
SESSION_RECORDING_V2_S3_ENDPOINT: http://seaweedfs:8333
|
||||
ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS}
|
||||
# Must resolve to the same value as the web and worker services, which send it as
|
||||
# X-Internal-Api-Secret. The JWT signing secret is deliberately left unset here, so hobby
|
||||
# stays on the legacy shared secret until that scheme is rolled out.
|
||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-$POSTHOG_SECRET}
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
- clickhouse
|
||||
- seaweedfs
|
||||
|
||||
ingestion-error-tracking:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: ingestion-error-tracking
|
||||
image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
environment:
|
||||
ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS}
|
||||
ERROR_TRACKING_CYMBAL_BASE_URL: 'http://cymbal:3302'
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
- kafka
|
||||
|
||||
ingestion-logs:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: ingestion-logs
|
||||
image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
environment:
|
||||
LOGS_REDIS_TLS: 'false'
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
- kafka
|
||||
|
||||
ingestion-traces:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: ingestion-traces
|
||||
image: ${REGISTRY_URL}-node:${POSTHOG_NODE_TAG:-latest}
|
||||
logging: *default-logging
|
||||
environment:
|
||||
TRACES_REDIS_TLS: 'false'
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
- kafka
|
||||
|
||||
proxy:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: proxy
|
||||
logging: *default-logging
|
||||
ports:
|
||||
# nginx-proxy-manager owns host 80/443. Loopback only; NPM reaches
|
||||
# this container over nginx-bridge as "posthog".
|
||||
- '127.0.0.1:${POSTHOG_HTTP_PORT:-8002}:80'
|
||||
networks:
|
||||
default:
|
||||
nginx-bridge:
|
||||
aliases:
|
||||
- posthog
|
||||
volumes:
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
environment:
|
||||
CADDY_TLS_BLOCK: $TLS_BLOCK
|
||||
CADDY_HOST: $CADDY_HOST
|
||||
depends_on:
|
||||
- web
|
||||
- livestream
|
||||
objectstorage:
|
||||
# SeaweedFS on the same objectstorage:19000 endpoint and credentials this service
|
||||
# served when it ran MinIO, so no other service in this file changes.
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: objectstorage
|
||||
restart: on-failure
|
||||
logging: *default-logging
|
||||
# The dev/CI bootstrap this replaces also registers an `anonymous` identity with
|
||||
# Admin rights. Hobby must not, because Caddy proxies /posthog/* straight to this
|
||||
# service: an anonymous identity opens every object to the internet. With only the
|
||||
# posthog identity, SeaweedFS rejects unsigned requests and the presigned URLs
|
||||
# Django hands out keep working. There is no duckgres here, so there is no
|
||||
# ducklake-dev bucket either. The loop holds no shell variables on purpose:
|
||||
# bin/upgrade-hobby pipes this file through envsubst, which collapses a
|
||||
# doubled-dollar variable reference to a bare dollar sign.
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
(
|
||||
while true; do
|
||||
printf 's3.configure -access_key=object_storage_root_user -secret_key=object_storage_root_password -user=posthog -actions=Admin -apply\ns3.bucket.create -name posthog\ns3.bucket.create -name ai-blobs\n' \
|
||||
| /usr/bin/weed shell -master=localhost:19001 >/dev/null 2>&1 || true
|
||||
if echo 's3.bucket.list' | /usr/bin/weed shell -master=localhost:19001 2>/dev/null | grep -q ai-blobs \
|
||||
&& echo 's3.configure' | /usr/bin/weed shell -master=localhost:19001 2>/dev/null | grep -q object_storage_root_user; then
|
||||
touch /tmp/objectstorage-ready
|
||||
echo "Objectstorage SeaweedFS ready with required buckets and identities"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
) &
|
||||
exec /usr/bin/weed "$$@"
|
||||
- --
|
||||
# A new volume. SeaweedFS cannot read the MinIO layout, so the old objects stay on
|
||||
# the `objectstorage` volume until the operator copies them across with
|
||||
# bin/migrate-storage-hobby or deletes the volume.
|
||||
volumes:
|
||||
- objectstorage-data:/data
|
||||
ports:
|
||||
# Localhost only. MinIO published this port on every interface.
|
||||
- '127.0.0.1:19000:19000'
|
||||
|
||||
seaweedfs:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: seaweedfs
|
||||
container_name: '${SEAWEEDFS_DOCKER_NAME:-seaweedfs-main}'
|
||||
restart: on-failure
|
||||
logging: *default-logging
|
||||
ports:
|
||||
- '127.0.0.1:8333:8333' # S3 API
|
||||
- '127.0.0.1:9333:9333' # Master server
|
||||
volumes:
|
||||
- seaweedfs:/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'sh',
|
||||
'-c',
|
||||
"echo 's3.bucket.list' | /usr/bin/weed shell -master=localhost:9333 2>&1 | grep -q posthog",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
browserless:
|
||||
# Renders image exports and heatmap screenshots (no browser ships in the PostHog image).
|
||||
image: ghcr.io/browserless/chromium:v2.51.2
|
||||
restart: on-failure
|
||||
logging: *default-logging
|
||||
environment:
|
||||
TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET}
|
||||
LOG_LEVEL: warn
|
||||
DEBUG: '-*'
|
||||
|
||||
asyncmigrationscheck:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: asyncmigrationscheck
|
||||
image: $REGISTRY_URL:$POSTHOG_APP_TAG
|
||||
logging: *default-logging
|
||||
environment:
|
||||
SITE_URL: https://$DOMAIN
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
SKIP_ASYNC_MIGRATIONS_SETUP: 0
|
||||
|
||||
# Temporal containers
|
||||
temporal:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: temporal
|
||||
logging: *default-logging
|
||||
environment:
|
||||
- ENABLE_ES=false
|
||||
ports:
|
||||
- 127.0.0.1:7233:7233
|
||||
volumes:
|
||||
- ./posthog/docker/temporal/dynamicconfig:/etc/temporal/config/dynamicconfig
|
||||
elasticsearch:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: elasticsearch
|
||||
logging: *default-logging
|
||||
temporal-admin-tools:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: temporal-admin-tools
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- temporal
|
||||
temporal-ui:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: temporal-ui
|
||||
logging: *default-logging
|
||||
ports:
|
||||
- 127.0.0.1:8081:8080
|
||||
depends_on:
|
||||
temporal:
|
||||
condition: service_started
|
||||
db:
|
||||
condition: service_healthy
|
||||
temporal-django-worker:
|
||||
command: /compose/temporal-django-worker
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: temporal-django-worker
|
||||
logging: *default-logging
|
||||
volumes:
|
||||
- ./compose:/compose
|
||||
image: $REGISTRY_URL:$POSTHOG_APP_TAG
|
||||
environment:
|
||||
SITE_URL: https://$DOMAIN
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
BROWSERLESS_CDP_URL: 'ws://browserless:3000'
|
||||
BROWSERLESS_TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET}
|
||||
HEATMAP_BROWSERLESS_URL: 'http://browserless:3000'
|
||||
HEATMAP_BROWSERLESS_TOKEN: ${BROWSERLESS_SECRET:-$POSTHOG_SECRET}
|
||||
HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED: ${HEATMAP_BROWSERLESS_SCREENSHOT_COOKIES_ENABLED:-false}
|
||||
# This worker runs the recording deletion Temporal activities, which call
|
||||
# recording-api. Without these two the calls go out unauthenticated and
|
||||
# recording-api rejects them with a 401.
|
||||
RECORDING_API_URL: http://recording-api:6738
|
||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-$POSTHOG_SECRET}
|
||||
# Point Django at the Rust feature-flags container; the setting
|
||||
# otherwise defaults to localhost:3001.
|
||||
FEATURE_FLAGS_SERVICE_URL: 'http://feature-flags:3001'
|
||||
# The logs product uses a dedicated ClickHouse cluster config (separate
|
||||
# from CLICKHOUSE_HOST) whose host defaults to localhost and whose SECURE
|
||||
# defaults to true outside DEBUG. Point it at the stack's ClickHouse over
|
||||
# plaintext; the other logs-cluster settings already default correctly
|
||||
# (native port 9000, user "default", empty password). This worker runs
|
||||
# the logs alert-check Temporal activities, which query ClickHouse under
|
||||
# Workload.LOGS.
|
||||
CLICKHOUSE_LOGS_CLUSTER_HOST: 'clickhouse'
|
||||
CLICKHOUSE_LOGS_CLUSTER_SECURE: 'false'
|
||||
# AI provider keys for PostHog's AI features. Blank unless set in .env.
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
- clickhouse
|
||||
- kafka
|
||||
- objectstorage
|
||||
- seaweedfs
|
||||
- temporal
|
||||
- browserless
|
||||
|
||||
capture:
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: capture
|
||||
logging: *default-logging
|
||||
|
||||
capture-logs:
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: capture-logs
|
||||
logging: *default-logging
|
||||
|
||||
replay-capture:
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: replay-capture
|
||||
logging: *default-logging
|
||||
|
||||
property-defs-rs:
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: property-defs-rs
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
kafka-init:
|
||||
condition: service_completed_successfully
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
livestream:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: livestream
|
||||
logging: *default-logging
|
||||
environment:
|
||||
- LIVESTREAM_JWT_SECRET=${POSTHOG_SECRET}
|
||||
volumes:
|
||||
- ./posthog/docker/livestream/configs-hobby.yml:/configs/configs.yml
|
||||
|
||||
personhog-replica:
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: personhog-replica
|
||||
logging: *default-logging
|
||||
environment:
|
||||
PRIMARY_DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog'
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
personhog-router:
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: personhog-router
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- personhog-replica
|
||||
|
||||
feature-flags:
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: feature-flags
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- db
|
||||
- redis7
|
||||
|
||||
hypercache-server:
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: hypercache-server
|
||||
logging: *default-logging
|
||||
depends_on:
|
||||
- redis7
|
||||
|
||||
kafka-init:
|
||||
image: docker.io/redpandadata/redpanda:v25.1.9
|
||||
entrypoint: /bin/sh
|
||||
logging: *default-logging
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
set -x
|
||||
echo "Waiting for Kafka broker to accept connections..."
|
||||
TIMEOUT=60
|
||||
ELAPSED=0
|
||||
# Use rpk topic list as the readiness check - it's a simple operation
|
||||
# that confirms the broker is accepting Kafka protocol requests
|
||||
until rpk topic list --brokers kafka:9092 2>/dev/null; do
|
||||
echo "Kafka broker not ready yet (elapsed: $${ELAPSED}s)..."
|
||||
sleep 2
|
||||
ELAPSED=$$((ELAPSED + 2))
|
||||
if [ $$ELAPSED -ge $$TIMEOUT ]; then
|
||||
echo "Timeout waiting for Kafka broker after $${TIMEOUT}s"
|
||||
echo "Final attempt to list topics:"
|
||||
rpk topic list --brokers kafka:9092 || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Kafka broker is accepting requests, creating topics..."
|
||||
# The topic list is docker/kafka/topics.txt, mounted below, shared with
|
||||
# docker-compose.dev.yml so the two stacks cannot drift apart. That header
|
||||
# explains what needs pre-creating.
|
||||
# Note: $$ escapes $ for docker-compose variable substitution
|
||||
TOPICS=$$(grep -vE '^[[:space:]]*#|^[[:space:]]*$$' /topics.txt)
|
||||
for topic in $$TOPICS; do
|
||||
if rpk topic create "$$topic" --brokers kafka:9092 -p 1 -r 1 2>&1; then
|
||||
echo "Topic $$topic created successfully"
|
||||
else
|
||||
if rpk topic list --brokers kafka:9092 | awk '{print $$1}' | grep -qx "$$topic"; then
|
||||
echo "Topic $$topic already exists, continuing"
|
||||
else
|
||||
echo "Failed to create topic $$topic"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "Final topic list:"
|
||||
rpk topic list --brokers kafka:9092
|
||||
echo "Topics ready"
|
||||
volumes:
|
||||
- ./posthog/docker/kafka/topics.txt:/topics.txt:ro
|
||||
depends_on:
|
||||
kafka:
|
||||
condition: service_healthy
|
||||
|
||||
cymbal:
|
||||
image: ghcr.io/posthog/posthog/cymbal:master
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
args:
|
||||
BIN: cymbal
|
||||
restart: on-failure
|
||||
logging: *default-logging
|
||||
volumes:
|
||||
- ./share:/share
|
||||
environment:
|
||||
KAFKA_HOSTS: 'kafka:9092'
|
||||
OBJECT_STORAGE_BUCKET: posthog
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY}
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://seaweedfs:8333'
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: 'true'
|
||||
BIND_HOST: '0.0.0.0'
|
||||
BIND_PORT: '3302'
|
||||
DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog'
|
||||
PERSONS_URL: 'postgres://posthog:posthog@db:5432/posthog'
|
||||
MAXMIND_DB_PATH: '/share/GeoLite2-City.mmdb'
|
||||
REDIS_URL: 'redis://redis7:6379/'
|
||||
ISSUE_BUCKETS_REDIS_URL: 'redis://redis7:6379/'
|
||||
RUST_LOG: 'info'
|
||||
# Processing offloads all symbol resolution to cymbal-resolution over gRPC. Both are
|
||||
# mandatory: startup aborts when either is empty, so omitting them crash-loops cymbal.
|
||||
CYMBAL_REMOTE_RESOLUTION_HOST: 'cymbal-resolution'
|
||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
||||
depends_on:
|
||||
kafka-init:
|
||||
condition: service_completed_successfully
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis7:
|
||||
condition: service_healthy
|
||||
cymbal-resolution:
|
||||
condition: service_started
|
||||
|
||||
# The cymbal binary in resolution mode: the gRPC symbol-resolution service that cymbal's
|
||||
# processing mode calls. Needs only Postgres and object storage — no Kafka or Redis — hence
|
||||
# the narrower env than cymbal above.
|
||||
cymbal-resolution:
|
||||
image: ghcr.io/posthog/posthog/cymbal:master
|
||||
build:
|
||||
context: ./posthog/rust
|
||||
args:
|
||||
BIN: cymbal
|
||||
restart: on-failure
|
||||
logging: *default-logging
|
||||
environment:
|
||||
CYMBAL_MODE: resolution
|
||||
GRPC_ADDRESS: '0.0.0.0:50061'
|
||||
OBJECT_STORAGE_BUCKET: posthog
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY}
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://seaweedfs:8333'
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: 'true'
|
||||
DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog'
|
||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
||||
RUST_LOG: 'info'
|
||||
depends_on:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
otel_network:
|
||||
nginx-bridge:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
zookeeper-data:
|
||||
zookeeper-datalog:
|
||||
zookeeper-logs:
|
||||
objectstorage-data:
|
||||
seaweedfs:
|
||||
postgres-data:
|
||||
clickhouse-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
redis7-data:
|
||||
kafka-data:
|
||||
redpanda-data:
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Local source patches, applied to /code at container start.
|
||||
|
||||
Called from compose/start, compose/worker and compose/temporal-django-worker
|
||||
before the app boots, so every recreated container comes back patched. (The old
|
||||
approach patched running containers by hand and silently vanished on the next
|
||||
`docker compose up`.)
|
||||
|
||||
Both patches exist because PostHog no longer sells self-hosted licenses, so the
|
||||
license branches below are unreachable here. Both are gated on
|
||||
MULTI_ORG_ENABLED (set in compose.override.yml), so unsetting it restores
|
||||
upstream behaviour without un-patching. Only posthog/ files are touched, which
|
||||
is MIT-licensed; ee/ is not involved.
|
||||
|
||||
Idempotent. If upstream changes shape, this warns loudly and lets the app start
|
||||
unpatched rather than taking PostHog down. Check with:
|
||||
docker compose logs web worker | grep LOCAL-PATCH
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
PATCHES = [
|
||||
(
|
||||
# Shows the "New project" / "New organization" buttons. Upstream gates
|
||||
# them on preflight's can_create_org, which demands a license.
|
||||
"can_create_org",
|
||||
"/code/posthog/utils.py",
|
||||
""" if settings.MULTI_ORG_ENABLED:
|
||||
license = get_cached_instance_license()
|
||||
if license is not None and AvailableFeature.ZAPIER in license.available_features:
|
||||
return True
|
||||
else:
|
||||
logger.warning("You have configured MULTI_ORG_ENABLED, but not the required premium PostHog plan!")
|
||||
""",
|
||||
""" if settings.MULTI_ORG_ENABLED:
|
||||
# Local patch, see compose/local-patches.py
|
||||
return True
|
||||
""",
|
||||
),
|
||||
(
|
||||
# Lifts the 1-project-per-org limit in the project API. Without a
|
||||
# license, every org save and the periodic feature sync reset the org's
|
||||
# features to [], and an org without `organizations_projects` may only
|
||||
# have one project. With it and no `limit`, the cap is upstream's
|
||||
# MAX_ALLOWED_PROJECTS_PER_ORG (2000).
|
||||
"organizations_projects",
|
||||
"/code/posthog/models/organization.py",
|
||||
""" return self.available_product_features
|
||||
|
||||
def get_available_feature(""",
|
||||
""" # Local patch, see compose/local-patches.py
|
||||
if settings.MULTI_ORG_ENABLED and not self.get_available_feature("organizations_projects"):
|
||||
self.available_product_features = [
|
||||
*self.available_product_features,
|
||||
{"key": "organizations_projects", "name": "Organizations projects"},
|
||||
]
|
||||
|
||||
return self.available_product_features
|
||||
|
||||
def get_available_feature(""",
|
||||
),
|
||||
]
|
||||
|
||||
for name, path, old, new in PATCHES:
|
||||
src = open(path).read()
|
||||
if new in src:
|
||||
status = "already applied"
|
||||
elif src.count(old) == 1:
|
||||
open(path, "w").write(src.replace(old, new))
|
||||
status = "applied"
|
||||
else:
|
||||
status = "PATTERN NOT FOUND, upstream changed, running UNPATCHED"
|
||||
print(f"LOCAL-PATCH {name}: {status}", file=sys.stderr, flush=True)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
python /compose/local-patches.py
|
||||
/compose/wait
|
||||
./bin/migrate
|
||||
./bin/docker-server
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
python /compose/local-patches.py
|
||||
./bin/temporal-django-worker
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
def loop():
|
||||
print("Waiting for ClickHouse and Postgres to be ready")
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect(('clickhouse', 9000))
|
||||
print("Clickhouse is ready")
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect(('db', 5432))
|
||||
print("Postgres is ready")
|
||||
except ConnectionRefusedError as e:
|
||||
time.sleep(5)
|
||||
loop()
|
||||
|
||||
loop()
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
python /compose/local-patches.py
|
||||
exec ./bin/docker-worker-celery --with-scheduler
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Podman Compose: PostHog
|
||||
After=network-online.target podman.socket
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=%h/Server/PostHog
|
||||
ExecStart=/usr/bin/podman compose up -d
|
||||
ExecStop=/usr/bin/podman compose down
|
||||
RemainAfterExit=yes
|
||||
TimeoutStartSec=900
|
||||
TimeoutStopSec=90
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Reference in new issue
Block a user