# # `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: $POSTHOG_SECRET OBJECT_STORAGE_ACCESS_KEY_ID: 'object_storage_root_user' OBJECT_STORAGE_SECRET_ACCESS_KEY: 'object_storage_root_password' 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: 'any' SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: 'any' 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} # 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: $POSTHOG_SECRET OBJECT_STORAGE_ACCESS_KEY_ID: 'object_storage_root_user' OBJECT_STORAGE_SECRET_ACCESS_KEY: 'object_storage_root_password' SESSION_RECORDING_V2_S3_ACCESS_KEY_ID: 'any' SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: 'any' 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: $POSTHOG_SECRET OBJECT_STORAGE_ACCESS_KEY_ID: 'object_storage_root_user' OBJECT_STORAGE_SECRET_ACCESS_KEY: 'object_storage_root_password' 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: 'any' SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: 'any' 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: 'any' SESSION_RECORDING_V2_S3_SECRET_ACCESS_KEY: 'any' 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: - '80:80' - '443:443' volumes: - caddy-data:/data - caddy-config:/config environment: CADDY_TLS_BLOCK: $TLS_BLOCK CADDY_HOST: '$DOMAIN, http://, https://' depends_on: - web - livestream objectstorage: # Transitional legacy MinIO source for hobby migrations. # Dev/CI stacks use SeaweedFS-backed objectstorage from docker-compose.base.yml, # but hobby keeps MinIO so bin/migrate-storage-hobby still has a MinIO source. image: minio/minio:RELEASE.2025-04-22T22-12-26Z restart: on-failure environment: MINIO_ROOT_USER: object_storage_root_user MINIO_ROOT_PASSWORD: object_storage_root_password entrypoint: sh command: -c 'mkdir -p /data/posthog /data/ducklake-dev /data/ai-blobs && minio server --address ":19000" --console-address ":19001" /data' logging: *default-logging volumes: - objectstorage:/data ports: - '19000:19000' - '19001:19001' 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} 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: $POSTHOG_SECRET SKIP_ASYNC_MIGRATIONS_SETUP: 0 # Temporal containers temporal: extends: file: docker-compose.base.yml service: temporal logging: *default-logging environment: - ENABLE_ES=false ports: - 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: - 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: $POSTHOG_SECRET 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} # 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..." # Create topics needed by rust services and the combined analytics ingestion # consumer, which checks topic existence at startup for every output it produces # to (see analytics/outputs/registry.ts). Auto-create only fires on producer-first # writes, so pre-create them here. # Note: $$ escapes $ for docker-compose variable substitution TOPICS="clickhouse_events_json \ clickhouse_ai_events_json \ clickhouse_heatmap_events \ clickhouse_flag_evaluations \ clickhouse_ingestion_warnings \ events_plugin_ingestion_ai \ events_plugin_ingestion_dlq \ events_plugin_ingestion_overflow \ events_plugin_ingestion_async \ ingestion-clientwarnings-main-1 \ heatmaps_ingestion \ clickhouse_groups \ clickhouse_person \ clickhouse_person_distinct_id \ clickhouse_app_metrics2 \ log_entries \ clickhouse_tophog" 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 | grep -q "$$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" 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: 'any' OBJECT_STORAGE_SECRET_ACCESS_KEY: 'any' 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: $POSTHOG_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: 'any' OBJECT_STORAGE_SECRET_ACCESS_KEY: 'any' OBJECT_STORAGE_ENDPOINT: 'http://seaweedfs:8333' OBJECT_STORAGE_FORCE_PATH_STYLE: 'true' DATABASE_URL: 'postgres://posthog:posthog@db:5432/posthog' INTERNAL_API_SECRET: $POSTHOG_SECRET RUST_LOG: 'info' depends_on: seaweedfs: condition: service_healthy db: condition: service_healthy networks: otel_network: volumes: zookeeper-data: zookeeper-datalog: zookeeper-logs: objectstorage: seaweedfs: postgres-data: clickhouse-data: caddy-data: caddy-config: redis7-data: kafka-data: redpanda-data: